mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
470948c191 | ||
|
|
b095b9f6b0 | ||
|
|
f4d6580cd3 | ||
|
|
b2f62f70e1 | ||
|
|
dcb2ab7fac | ||
|
|
eb56f4a1e0 | ||
|
|
c28803b037 | ||
|
|
cd8474954b | ||
|
|
d7e07d042d | ||
|
|
45852082c0 | ||
|
|
99ad3838e1 | ||
|
|
637b512f6a | ||
|
|
16ad6548c8 |
No files matched your search
Generated
+513
-793
File diff suppressed because it is too large.
Load diff
+14
-10
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.9.1"
|
||||
version = "1.10.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.1" }
|
||||
proton-drive-rs = { version = "0.6.1" }
|
||||
proton-sdk = { version = "0.6.4" }
|
||||
proton-drive-rs = { version = "0.6.4" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -25,7 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
@@ -35,24 +35,28 @@ anyhow = "1"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
directories = "6"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
# Google Takeout exports are zip sets; the importer reads their central
|
||||
# directories and streams entries out without extracting to disk first.
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||
# import has to speak SHA-1 whatever its age.
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# 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
|
||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||
keyring-core = "1"
|
||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.17"
|
||||
fuser = "0.18"
|
||||
libc = "0.2"
|
||||
@@ -69,7 +69,7 @@ pdfs-app # the desktop app
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
@@ -124,6 +124,51 @@ launcher command, set them in `config.json`:
|
||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||
own prompt flag is appended.
|
||||
|
||||
### Live launcher search (fzf)
|
||||
|
||||
The two-step above exists because no dmenu-style launcher can call back for new
|
||||
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
||||
exactly like the built-in window:
|
||||
|
||||
```bash
|
||||
pdfs-prompt --fzf # pinned files, then live results as you type
|
||||
pdfs-prompt --fzf --query invoice # start on a search
|
||||
```
|
||||
|
||||
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
||||
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
||||
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
||||
do. Enter opens; Escape closes.
|
||||
|
||||
fzf is a terminal program, so when it is started without one — from a WM
|
||||
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
||||
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
||||
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
||||
Hyprland:
|
||||
|
||||
```
|
||||
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||
```
|
||||
|
||||
Set it as the default front end, and pick the terminal explicitly, in
|
||||
`config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": {
|
||||
"mode": "fzf",
|
||||
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
||||
"menu_limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
||||
without one it is appended, which is what every detected terminal expects.
|
||||
|
||||
## Choosing How Files Open
|
||||
|
||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||
|
||||
+350
-10
@@ -17,6 +17,7 @@ use pdfs_core::control::{
|
||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -176,6 +177,43 @@ enum Command {
|
||||
#[arg(long)]
|
||||
wait: bool,
|
||||
},
|
||||
/// Fix photos filed under the day they were imported instead of the day
|
||||
/// they were taken.
|
||||
///
|
||||
/// A Takeout whose metadata sidecars did not match imports with no capture
|
||||
/// time, and Proton then stamps every photo with the moment it arrived. The
|
||||
/// date is still in the file name (`IMG-20230219-WA0001.jpg`), so this finds
|
||||
/// photos whose name disagrees with their capture time by more than a day
|
||||
/// and rewrites them at the right time.
|
||||
///
|
||||
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
||||
/// the photo and trashing the original — favourites and albums are carried
|
||||
/// across, and the original goes to Proton's trash, not away. Run it with
|
||||
/// `--dry-run` first.
|
||||
RedatePhotos {
|
||||
/// Report what would be re-dated without uploading or trashing anything.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Stop after this many photos.
|
||||
#[arg(long)]
|
||||
limit: Option<usize>,
|
||||
/// Only touch photos whose current capture time is on or after this
|
||||
/// date (`YYYY-MM-DD`, UTC) — aim the repair at the days an import
|
||||
/// landed on.
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// Only touch photos whose current capture time is before this date
|
||||
/// (`YYYY-MM-DD`, UTC).
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
/// Stay attached and print the report when the run finishes.
|
||||
#[arg(long)]
|
||||
wait: bool,
|
||||
},
|
||||
/// Show how the running (or last) photo re-date is doing.
|
||||
RedateStatus,
|
||||
/// Stop the running photo re-date.
|
||||
CancelRedate,
|
||||
/// Show how the running (or last) Google Photos import is doing.
|
||||
ImportStatus,
|
||||
/// Stop the running Google Photos import.
|
||||
@@ -616,6 +654,15 @@ fn main() -> Result<()> {
|
||||
dry_run,
|
||||
wait,
|
||||
} => cmd_import_google_photos(archives, dry_run, wait),
|
||||
Command::RedatePhotos {
|
||||
dry_run,
|
||||
limit,
|
||||
from,
|
||||
to,
|
||||
wait,
|
||||
} => cmd_redate_photos(dry_run, limit, from, to, wait),
|
||||
Command::RedateStatus => cmd_redate_status(),
|
||||
Command::CancelRedate => cmd_cancel_redate(),
|
||||
Command::ImportStatus => cmd_import_status(),
|
||||
Command::CancelImport => cmd_cancel_import(),
|
||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||
@@ -1082,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
||||
format!(" ({})", e.role)
|
||||
};
|
||||
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
||||
if let Some(line) = shared_by_line(&e) {
|
||||
println!("{:>15}{line}", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
@@ -1090,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who shared an entry with me, and when, as a line printed under it — `None`
|
||||
/// when the invitation said nothing (owned content, or a node inside a shared
|
||||
/// folder rather than its root). An inviter whose signature did not verify is
|
||||
/// said to be claimed, not stated: the name may be forged.
|
||||
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
|
||||
if entry.shared_by.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut line = format!("shared by {}", entry.shared_by);
|
||||
if entry.shared_at > 0 {
|
||||
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
|
||||
}
|
||||
if entry.shared_by_unverified {
|
||||
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
|
||||
}
|
||||
Some(line)
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
|
||||
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
|
||||
fn format_epoch_utc(secs: i64) -> String {
|
||||
let z = secs.div_euclid(86_400) + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
format!("{year:04}-{month:02}-{day:02}")
|
||||
}
|
||||
|
||||
/// Download a file shared with me. The daemon puts the plaintext in its content
|
||||
/// cache and replies with that path; with a `dest` we copy it out, so the caller
|
||||
/// gets a file it owns rather than one the cache may later evict.
|
||||
@@ -1309,6 +1392,15 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
||||
other => anyhow::Error::new(other).context("login failed"),
|
||||
})?;
|
||||
|
||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||
// between keyring checks; restart it so the mount appears now instead of
|
||||
// whenever the next poll fires. Only when it is already active — `pdfs
|
||||
// login` deliberately does not enable the unit the way the GUI does, so a
|
||||
// headless user driving `pdfs daemon` by hand keeps that setup.
|
||||
if service::is_active() {
|
||||
service::restart();
|
||||
}
|
||||
|
||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1448,6 +1540,59 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long to wait before the next login check, after `attempts` checks have
|
||||
/// already come back [`pdfs_core::Error::NotLoggedIn`].
|
||||
///
|
||||
/// Doubles from 3s to a 15s ceiling. The interval is not just a sleep:
|
||||
/// [`auth::load`] builds a fresh `keyring::Entry` on every call, so each check
|
||||
/// opens a new D-Bus connection to the Secret Service and negotiates an
|
||||
/// encrypted session over it. A flat 3s poll meant 28,800 of those a day from a
|
||||
/// daemon that had nothing to do; the ceiling cuts that by 80%.
|
||||
///
|
||||
/// The ceiling stays low because the sleep is also the worst-case login →
|
||||
/// mount latency for anything that does *not* poke systemd: front-ends restart
|
||||
/// the unit on login (`service::enable_start`), which interrupts the sleep
|
||||
/// immediately, but a hand-run `pdfs daemon` has no such wake-up path.
|
||||
fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
||||
const BASE_SECS: u64 = 3;
|
||||
const MAX_SECS: u64 = 15;
|
||||
|
||||
// `checked_shl` rather than `<<`: the daemon can sit unauthenticated for
|
||||
// weeks, and a shift past the width of the type is a panic, not a big number.
|
||||
let factor = 1u64.checked_shl(attempts).unwrap_or(u64::MAX);
|
||||
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||
}
|
||||
|
||||
/// Block until a session is stored, backing off between checks.
|
||||
///
|
||||
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
||||
/// schedule can be tested without a keyring and without real time passing.
|
||||
fn wait_for_session<L, S>(mut load: L, mut sleep: S) -> Result<()>
|
||||
where
|
||||
L: FnMut() -> std::result::Result<(), pdfs_core::Error>,
|
||||
S: FnMut(std::time::Duration),
|
||||
{
|
||||
let mut attempts: u32 = 0;
|
||||
loop {
|
||||
match load() {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||
let delay = login_poll_delay(attempts);
|
||||
// Say it once at INFO. Repeating it every interval buried the
|
||||
// journal in tens of thousands of identical lines a day.
|
||||
if attempts == 0 {
|
||||
tracing::info!("not logged in; waiting for a session…");
|
||||
} else {
|
||||
tracing::debug!(delay_secs = delay.as_secs(), "still not logged in");
|
||||
}
|
||||
sleep(delay);
|
||||
attempts = attempts.saturating_add(1);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
|
||||
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
|
||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||
@@ -1456,16 +1601,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||
loop {
|
||||
// Wait until a session is stored. The GUI enables this service on login,
|
||||
// but the service may also start at boot before the user has logged in.
|
||||
loop {
|
||||
match auth::load() {
|
||||
Ok(_) => break,
|
||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||
tracing::info!("not logged in; waiting…");
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
||||
|
||||
match mount_once(mountpoint.clone()) {
|
||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||
@@ -1787,6 +1923,115 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_redate_photos(
|
||||
dry_run: bool,
|
||||
limit: Option<usize>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
wait: bool,
|
||||
) -> Result<()> {
|
||||
let range = match (from.as_deref(), to.as_deref()) {
|
||||
(None, None) => None,
|
||||
(from, to) => Some((
|
||||
from.map(parse_day).transpose()?.unwrap_or(i64::MIN),
|
||||
to.map(parse_day).transpose()?.unwrap_or(i64::MAX),
|
||||
)),
|
||||
};
|
||||
match control_request(CtlRequest::RedatePhotos {
|
||||
dry_run,
|
||||
limit,
|
||||
range,
|
||||
})? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
if !wait {
|
||||
println!("Run `pdfs redate-status` to follow it.");
|
||||
return Ok(());
|
||||
}
|
||||
// Polled for the same reason the import is: request/response protocol, and a
|
||||
// run over a whole library is a download plus an upload per photo.
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
match control_request(CtlRequest::RedateStatus)? {
|
||||
CtlResponse::RedateStatus { running: true, .. } => continue,
|
||||
CtlResponse::RedateStatus { summary, .. } => {
|
||||
print_redate_summary(summary.as_ref());
|
||||
return Ok(());
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout
|
||||
/// scanner's date reader, which already accepts exactly this spelling.
|
||||
fn parse_day(day: &str) -> Result<i64> {
|
||||
pdfs_core::takeout::capture_time_from_name(day)
|
||||
.ok_or_else(|| anyhow!("not a date (expected YYYY-MM-DD): {day}"))
|
||||
}
|
||||
|
||||
fn cmd_redate_status() -> Result<()> {
|
||||
match control_request(CtlRequest::RedateStatus)? {
|
||||
CtlResponse::RedateStatus { running, summary } => {
|
||||
if running {
|
||||
println!("Re-date running. `pdfs transfers` shows its progress.");
|
||||
}
|
||||
print_redate_summary(summary.as_ref());
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_cancel_redate() -> Result<()> {
|
||||
match control_request(CtlRequest::CancelRedate)? {
|
||||
CtlResponse::Ok { message } => println!("{message}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
|
||||
let Some(summary) = summary else {
|
||||
println!("No re-date has run yet.");
|
||||
return;
|
||||
};
|
||||
println!("Photos examined : {}", summary.examined);
|
||||
println!("Mis-dated : {}", summary.candidates);
|
||||
if summary.dry_run {
|
||||
println!("(dry run — nothing was uploaded or trashed)");
|
||||
} else {
|
||||
println!("Re-dated : {}", summary.redated);
|
||||
if summary.album_links > 0 {
|
||||
println!("Albums refiled : {}", summary.album_links);
|
||||
}
|
||||
if summary.failed > 0 {
|
||||
println!("Failed : {}", summary.failed);
|
||||
}
|
||||
if summary.bytes > 0 {
|
||||
println!("Uploaded bytes : {}", summary.bytes);
|
||||
}
|
||||
}
|
||||
for (name, stored, wanted) in &summary.samples {
|
||||
// Epoch seconds, as `pdfs photos` prints capture times.
|
||||
println!(" {name}: {stored} -> {wanted}");
|
||||
}
|
||||
if summary.candidates > summary.samples.len() {
|
||||
println!(
|
||||
" … and {} more",
|
||||
summary.candidates - summary.samples.len()
|
||||
);
|
||||
}
|
||||
if summary.cancelled {
|
||||
println!("Run was cancelled before it finished.");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_import_status() -> Result<()> {
|
||||
match control_request(CtlRequest::ImportStatus)? {
|
||||
CtlResponse::ImportStatus { running, summary } => {
|
||||
@@ -2537,3 +2782,98 @@ mod diagnose_tests {
|
||||
std::fs::remove_dir(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod login_wait_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn poll_delay_doubles_then_holds_at_the_ceiling() {
|
||||
assert_eq!(login_poll_delay(0), Duration::from_secs(3));
|
||||
assert_eq!(login_poll_delay(1), Duration::from_secs(6));
|
||||
assert_eq!(login_poll_delay(2), Duration::from_secs(12));
|
||||
assert_eq!(login_poll_delay(3), Duration::from_secs(15));
|
||||
assert_eq!(login_poll_delay(4), Duration::from_secs(15));
|
||||
// A daemon left unauthenticated for weeks reaches attempt counts well
|
||||
// past the width of the shift; that must cap, not panic.
|
||||
assert_eq!(login_poll_delay(64), Duration::from_secs(15));
|
||||
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_for_a_session_stops_hammering_the_keyring() {
|
||||
let mut checks_left = 20;
|
||||
let mut slept: Vec<Duration> = Vec::new();
|
||||
|
||||
wait_for_session(
|
||||
|| {
|
||||
if checks_left == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
checks_left -= 1;
|
||||
Err(pdfs_core::Error::NotLoggedIn)
|
||||
}
|
||||
},
|
||||
|delay| slept.push(delay),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(slept.len(), 20, "one wait per failed check");
|
||||
assert!(
|
||||
slept.windows(2).all(|w| w[1] >= w[0]),
|
||||
"the interval must never shrink: {slept:?}"
|
||||
);
|
||||
|
||||
// The point of the change. Every check is a fresh D-Bus session against
|
||||
// the Secret Service, so 20 of them must not fit inside a single minute
|
||||
// the way a flat 3s poll did.
|
||||
let total: Duration = slept.iter().sum();
|
||||
assert!(
|
||||
total >= Duration::from_secs(4 * 60),
|
||||
"20 checks should span at least 4 minutes, spanned {total:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_session_is_not_waited_for_at_all() {
|
||||
let mut slept = 0;
|
||||
wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
|
||||
assert_eq!(slept, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_seconds_format_as_utc_calendar_dates() {
|
||||
assert_eq!(format_epoch_utc(0), "1970-01-01");
|
||||
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
|
||||
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
|
||||
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forged_looking_invitation_is_flagged_not_stated() {
|
||||
let mut entry = pdfs_core::control::DirEntry {
|
||||
name: "Budget.ods".into(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
path: String::new(),
|
||||
role: "viewer".into(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
};
|
||||
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
|
||||
entry.shared_by = "alice@proton.me".into();
|
||||
entry.shared_at = 1_700_000_000;
|
||||
assert_eq!(
|
||||
shared_by_line(&entry).as_deref(),
|
||||
Some("shared by alice@proton.me at 2023-11-14")
|
||||
);
|
||||
entry.shared_by_unverified = true;
|
||||
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ sha2.workspace = true
|
||||
sha1.workspace = true
|
||||
zip.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
@@ -6,7 +6,7 @@
|
||||
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||
|
||||
use keyring::Entry;
|
||||
use keyring_core::Entry;
|
||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
}
|
||||
|
||||
fn keyring_entry() -> Result<Entry> {
|
||||
// Installed lazily rather than once at startup so a session bus that was not
|
||||
// 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()?);
|
||||
}
|
||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||
}
|
||||
|
||||
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
|
||||
let entry = keyring_entry()?;
|
||||
match entry.get_password() {
|
||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,43 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Re-date photos whose capture time is when they were *imported* rather
|
||||
/// than when they were taken.
|
||||
///
|
||||
/// Proton has no API for editing a photo's capture time — it is sealed into
|
||||
/// the revision — so the only repair is to upload the photo again with the
|
||||
/// right time and trash the original. That is what this does, for photos
|
||||
/// whose file name carries a date that disagrees with their stored capture
|
||||
/// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is
|
||||
/// the signature of an import whose metadata sidecars did not match.
|
||||
///
|
||||
/// Favourites and album memberships are carried across; the original is
|
||||
/// **trashed**, not deleted, so a bad run is recoverable from Proton's
|
||||
/// trash. With `dry_run` nothing is uploaded or trashed and the report says
|
||||
/// what would have been done.
|
||||
///
|
||||
/// Acks immediately with [`Response::Ok`] and works in the background — each
|
||||
/// photo is a download plus an upload. Progress shows up in
|
||||
/// [`Request::GetQueueStatus`] as a job, and the counts in
|
||||
/// [`Request::RedateStatus`].
|
||||
RedatePhotos {
|
||||
#[serde(default)]
|
||||
dry_run: bool,
|
||||
/// Stop after this many photos. `None` is the whole library.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
/// Only consider photos whose *stored* capture time falls in this
|
||||
/// `[from, to)` window — the way to aim the repair at the days an
|
||||
/// import landed on and leave the rest of the library untouched.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
},
|
||||
/// How the running (or last finished) re-date is doing. Replies with
|
||||
/// [`Response::RedateStatus`].
|
||||
RedateStatus,
|
||||
/// Ask the running re-date to stop. It finishes the photo on the wire, then
|
||||
/// reports `cancelled`. Replies with [`Response::Ok`].
|
||||
CancelRedate,
|
||||
/// How the running (or last finished) Takeout import is doing. Replies with
|
||||
/// [`Response::ImportStatus`].
|
||||
ImportStatus,
|
||||
@@ -869,6 +906,22 @@ pub struct DirEntry {
|
||||
/// report. Defaulted for wire-compat with clients/daemons predating it.
|
||||
#[serde(default)]
|
||||
pub role: String,
|
||||
/// Who shared this node with me, as the invitation claims it — empty for
|
||||
/// content I own, for a node reached *inside* a shared folder (only the share
|
||||
/// root carries the invitation), and when the API did not say. Trust it only
|
||||
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
|
||||
/// Defaulted for wire-compat with clients/daemons predating it.
|
||||
#[serde(default)]
|
||||
pub shared_by: String,
|
||||
/// When it was shared with me, epoch seconds; 0 when unknown.
|
||||
#[serde(default)]
|
||||
pub shared_at: i64,
|
||||
/// The invitation's signature did not check out against `shared_by`'s keys,
|
||||
/// so the name may be forged. False when it verified *and* when it could not
|
||||
/// be checked at all (no signature, no keys): only an actual mismatch is a
|
||||
/// warning worth showing.
|
||||
#[serde(default)]
|
||||
pub shared_by_unverified: bool,
|
||||
}
|
||||
|
||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||
@@ -1109,6 +1162,35 @@ pub struct PhotoMonth {
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// How a photo re-date is going, or how the last one ended
|
||||
/// ([`Response::RedateStatus`]). Cumulative for the run, so the same shape
|
||||
/// serves progress and the final report.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RedateSummary {
|
||||
/// Photos examined.
|
||||
pub examined: usize,
|
||||
/// Photos whose name implies a capture time more than a day from the one
|
||||
/// they carry — what the run would (or did) repair.
|
||||
pub candidates: usize,
|
||||
/// Photos re-uploaded with the right capture time, their original trashed.
|
||||
pub redated: usize,
|
||||
/// Photos that could not be downloaded, re-uploaded, or trashed. The
|
||||
/// original is left alone in every failure case.
|
||||
pub failed: usize,
|
||||
/// Album memberships re-created on the new copies.
|
||||
pub album_links: usize,
|
||||
/// Bytes uploaded.
|
||||
pub bytes: u64,
|
||||
/// True when the run stopped early because it was cancelled.
|
||||
pub cancelled: bool,
|
||||
/// True when nothing was actually uploaded or trashed.
|
||||
pub dry_run: bool,
|
||||
/// Up to a handful of examples, as `(name, stored capture time, the time the
|
||||
/// name implies)` — what a dry run is read for.
|
||||
#[serde(default)]
|
||||
pub samples: Vec<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||
/// ([`Response::ImportStatus`]).
|
||||
///
|
||||
@@ -1434,6 +1516,14 @@ pub enum Response {
|
||||
#[serde(default)]
|
||||
summary: Option<ImportSummary>,
|
||||
},
|
||||
/// How the photo re-date is doing (reply to [`Request::RedateStatus`]).
|
||||
/// `running` is false once it has finished, when `summary` is the final
|
||||
/// report; `summary` is `None` when no re-date has run this session.
|
||||
RedateStatus {
|
||||
running: bool,
|
||||
#[serde(default)]
|
||||
summary: Option<RedateSummary>,
|
||||
},
|
||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||
/// newest first.
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
|
||||
@@ -188,6 +188,19 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
|
||||
/// which has to put the re-uploaded copy back into the albums the original
|
||||
/// was in.
|
||||
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([uid], |r| r.get(0))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many photos of `album_uid` are persisted.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
|
||||
@@ -165,6 +165,31 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The whole persisted timeline, in stored order. Only for whole-library
|
||||
/// passes (the photo re-date) — the gallery pages with
|
||||
/// [`Db::photos_page`].
|
||||
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite \
|
||||
FROM photos ORDER BY seq",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(StoredPhoto {
|
||||
uid: r.get(0)?,
|
||||
capture_time: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// The months the timeline spans, newest first, each with how many photos it
|
||||
/// holds — the data behind the date scrubber. Buckets are local-time
|
||||
/// `(year, month)` so they line up with the day headings the gallery draws
|
||||
|
||||
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
};
|
||||
db.upsert_node(&parent_node).unwrap();
|
||||
|
||||
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
};
|
||||
db.upsert_node(&child_node).unwrap();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ pub enum Error {
|
||||
Proton(#[from] proton_sdk::ProtonError),
|
||||
|
||||
#[error("keyring: {0}")]
|
||||
Keyring(#[from] keyring::Error),
|
||||
Keyring(#[from] keyring_core::Error),
|
||||
|
||||
#[error("serialization: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// dirs, which hold blobs no user searches for by name.
|
||||
///
|
||||
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
mountpoint.to_path_buf(),
|
||||
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
||||
]
|
||||
}
|
||||
|
||||
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||
///
|
||||
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||
/// (docs/BUGS.md B90).
|
||||
///
|
||||
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||
/// change what ends up indexed: a nested mount is a different device, so
|
||||
/// `same_file_system(true)` was already going to skip it.
|
||||
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_nested_mount_points(&mounts, root)
|
||||
}
|
||||
|
||||
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||
/// be tested without a real `/proc`.
|
||||
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||
mounts
|
||||
.lines()
|
||||
.filter_map(|line| line.split_whitespace().nth(1))
|
||||
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||
// whole scan.
|
||||
.filter(|path| path.starts_with(root) && path != root)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||
fn unescape_mount_field(field: &str) -> String {
|
||||
let mut out = String::with_capacity(field.len());
|
||||
let mut rest = field;
|
||||
while let Some(at) = rest.find('\\') {
|
||||
out.push_str(&rest[..at]);
|
||||
let escape = rest[at + 1..].get(..3);
|
||||
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||
Some(byte) => {
|
||||
out.push(byte as char);
|
||||
rest = &rest[at + 4..];
|
||||
}
|
||||
// Not an octal escape after all — keep the backslash verbatim.
|
||||
None => {
|
||||
out.push('\\');
|
||||
rest = &rest[at + 1..];
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -268,4 +331,42 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||
/// being a mountpoint must not exclude the entire walk.
|
||||
#[test]
|
||||
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||
let mounts = "\
|
||||
/dev/sda2 /home ext4 rw 0 0
|
||||
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||
";
|
||||
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||
assert_eq!(
|
||||
nested,
|
||||
vec![
|
||||
PathBuf::from("/home/narl/remote/narl.io"),
|
||||
PathBuf::from("/home/narl/remote/gdrive"),
|
||||
]
|
||||
);
|
||||
|
||||
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||
// scan. `/run/user/1000` is unrelated.
|
||||
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||
}
|
||||
|
||||
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||
/// mount whose path contains one back into the walk.
|
||||
#[test]
|
||||
fn mount_paths_are_unescaped() {
|
||||
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||
assert_eq!(
|
||||
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||
);
|
||||
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,25 @@ const KNOWN_MENUS: [MenuFlavor; 6] = [
|
||||
},
|
||||
];
|
||||
|
||||
/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first.
|
||||
///
|
||||
/// The fzf front end is a terminal program, so a keybinding that has no tty
|
||||
/// needs one spawned for it. Each entry sets a stable app-id/class
|
||||
/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this
|
||||
/// window, and ends with the flag after which the command to run follows.
|
||||
const KNOWN_TERMINALS: [&[&str]; 6] = [
|
||||
&["foot", "--app-id=pdfs-prompt", "--"],
|
||||
&["ghostty", "--class=pdfs-prompt", "-e"],
|
||||
&["kitty", "--class", "pdfs-prompt"],
|
||||
&["alacritty", "--class", "pdfs-prompt", "-e"],
|
||||
&["wezterm", "start", "--class", "pdfs-prompt", "--"],
|
||||
&["xterm", "-class", "pdfs-prompt", "-e"],
|
||||
];
|
||||
|
||||
/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a
|
||||
/// user writes for one terminal keeps working if they switch.
|
||||
pub const TERMINAL_APP_ID: &str = "pdfs-prompt";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PromptFlag {
|
||||
/// `--prompt=TEXT`
|
||||
@@ -105,6 +124,13 @@ pub struct PromptConfig {
|
||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu: Option<Vec<String>>,
|
||||
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
|
||||
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
|
||||
/// anywhere in the argv is replaced by the command to run; without one the
|
||||
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
|
||||
/// expects.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<Vec<String>>,
|
||||
/// How many results to feed the launcher. Unset means
|
||||
/// [`DEFAULT_MENU_LIMIT`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -124,6 +150,8 @@ pub enum PromptMode {
|
||||
Gtk,
|
||||
/// The user's own launcher, driven over stdin/stdout.
|
||||
Dmenu,
|
||||
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
|
||||
Fzf,
|
||||
}
|
||||
|
||||
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
Some(argv)
|
||||
}
|
||||
|
||||
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
|
||||
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
|
||||
/// including the "run this command" flag — the caller appends the command, or
|
||||
/// substitutes it for a `{cmd}` token, via [`with_command`].
|
||||
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||
return Some(argv.clone());
|
||||
}
|
||||
let flavor = KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
|
||||
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
|
||||
}
|
||||
|
||||
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
|
||||
/// user wrote one, otherwise appended.
|
||||
///
|
||||
/// The token exists for terminals that want the command in the middle of their
|
||||
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
|
||||
/// last element is the flag the command follows.
|
||||
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
|
||||
if !argv.iter().any(|arg| arg == "{cmd}") {
|
||||
let mut argv = argv.to_vec();
|
||||
argv.extend_from_slice(command);
|
||||
return argv;
|
||||
}
|
||||
let mut out = Vec::with_capacity(argv.len() + command.len());
|
||||
for arg in argv {
|
||||
if arg == "{cmd}" {
|
||||
out.extend_from_slice(command);
|
||||
} else {
|
||||
out.push(arg.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||
///
|
||||
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||
@@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
||||
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||
}
|
||||
|
||||
fn on_path(program: &str) -> bool {
|
||||
/// Whether `program` is an executable file on `PATH`.
|
||||
pub fn on_path(program: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
@@ -408,6 +474,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_terminal_is_used_verbatim() {
|
||||
let configured = vec!["my-term".to_string(), "-e".to_string()];
|
||||
assert_eq!(
|
||||
resolve_terminal(Some(&configured)),
|
||||
Some(configured.clone())
|
||||
);
|
||||
// An empty list is "not configured", not "run nothing".
|
||||
assert_eq!(
|
||||
resolve_terminal(Some(&Vec::new())),
|
||||
resolve_terminal(None),
|
||||
"an empty terminal list must fall back to detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_is_appended_unless_the_user_placed_it() {
|
||||
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
|
||||
assert_eq!(
|
||||
with_command(&["foot".into(), "--".into()], &command),
|
||||
vec!["foot", "--", "pdfs-prompt", "--fzf"]
|
||||
);
|
||||
assert_eq!(
|
||||
with_command(
|
||||
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
|
||||
&command
|
||||
),
|
||||
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_terminal_sets_the_same_app_id() {
|
||||
for argv in KNOWN_TERMINALS {
|
||||
assert!(
|
||||
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
|
||||
"{argv:?} must be floatable by one window rule"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_launcher_is_used_verbatim() {
|
||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
//! ```
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
||||
spawn(&argv, path);
|
||||
}
|
||||
|
||||
/// Launch `argv`, detached from whatever is opening it.
|
||||
///
|
||||
/// The child gets its own process group and no inherited stdio, because the
|
||||
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
|
||||
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
|
||||
/// closes the moment the selection is made. A child left in the terminal's
|
||||
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
|
||||
/// "open this folder in a terminal" would open a window that dies immediately
|
||||
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
|
||||
/// first place: every `argv` is another application, and a terminal one is
|
||||
/// wrapped in its own emulator by the `terminal` rule flag.
|
||||
fn spawn(argv: &[String], path: &Path) {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return;
|
||||
};
|
||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||
if let Err(e) = Command::new(program).args(args).spawn() {
|
||||
let spawned = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn();
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(command = ?argv, "open failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
||||
/// successful login.
|
||||
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||
/// Called on successful login.
|
||||
///
|
||||
/// `restart` rather than `enable --now`: the unit may already be active but
|
||||
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
|
||||
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||
/// Restarting makes the new session take effect immediately, and starts the
|
||||
/// unit when it is not running at all.
|
||||
pub fn enable_start() -> bool {
|
||||
systemctl(&["enable", "--now"])
|
||||
let enabled = systemctl(&["enable"]);
|
||||
systemctl(&["restart"]) && enabled
|
||||
}
|
||||
|
||||
/// Stop the running mount without disabling it. Called by the tray's
|
||||
|
||||
+318
-29
@@ -393,22 +393,27 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
||||
.unwrap_or(&file_name)
|
||||
.to_string();
|
||||
|
||||
// Sidecar first, then the date stamped into the name.
|
||||
let capture_time = sidecar
|
||||
.photo_taken_time
|
||||
.as_ref()
|
||||
.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(&file_name));
|
||||
|
||||
scan.photos.push(TakeoutPhoto {
|
||||
archive: raw.archive,
|
||||
entry: raw.entry.clone(),
|
||||
name,
|
||||
size: raw.size,
|
||||
media_type: media_type.to_string(),
|
||||
capture_time: sidecar
|
||||
.photo_taken_time
|
||||
.as_ref()
|
||||
.and_then(SidecarTime::epoch_seconds)
|
||||
.or_else(|| {
|
||||
sidecar
|
||||
.creation_time
|
||||
.as_ref()
|
||||
.and_then(SidecarTime::epoch_seconds)
|
||||
}),
|
||||
capture_time,
|
||||
favorite: sidecar.favorited,
|
||||
bucket,
|
||||
});
|
||||
@@ -507,34 +512,218 @@ fn unswap_duplicate_marker(stem: &str) -> Option<String> {
|
||||
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
|
||||
}
|
||||
|
||||
/// The capture time a *file name* implies, in epoch seconds, or `None` when it
|
||||
/// carries no plausible date.
|
||||
///
|
||||
/// The last resort when a photo has no sidecar. Camera and messenger apps stamp
|
||||
/// the date into the name — `PXL_20260818_171030868.jpg`,
|
||||
/// `IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`,
|
||||
/// `IMG-20230219-WA0001.jpg`, `Screenshot from 2023-01-01 10-29-50.png` — and a
|
||||
/// date that is merely *approximately* right still files the photo in the right
|
||||
/// year, which "the moment it was imported" does not.
|
||||
///
|
||||
/// The wall clock in a name has no zone attached, so it is read as UTC. That can
|
||||
/// be a few hours out; it is never the years out that the import-time fallback
|
||||
/// is. A sidecar always wins over this.
|
||||
pub fn capture_time_from_name(name: &str) -> Option<i64> {
|
||||
let bytes = name.as_bytes();
|
||||
let digit = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit);
|
||||
let num =
|
||||
|start: usize, len: usize| -> Option<i64> { name.get(start..start + len)?.parse().ok() };
|
||||
|
||||
for start in 0..bytes.len() {
|
||||
if !digit(start) || (start > 0 && digit(start - 1)) {
|
||||
continue;
|
||||
}
|
||||
let run = |from: usize, len: usize| (0..len).all(|k| digit(from + k));
|
||||
// `YYYYMMDD` as one run of exactly eight digits, or `YYYY-MM-DD` with
|
||||
// any single separator repeated between the parts.
|
||||
let (year, month, day, after) = if run(start, 8) && !digit(start + 8) {
|
||||
(
|
||||
num(start, 4)?,
|
||||
num(start + 4, 2)?,
|
||||
num(start + 6, 2)?,
|
||||
start + 8,
|
||||
)
|
||||
} else if run(start, 4)
|
||||
&& !digit(start + 4)
|
||||
&& bytes.get(start + 4) == bytes.get(start + 7)
|
||||
&& run(start + 5, 2)
|
||||
&& run(start + 8, 2)
|
||||
&& !digit(start + 10)
|
||||
{
|
||||
(
|
||||
num(start, 4)?,
|
||||
num(start + 5, 2)?,
|
||||
num(start + 8, 2)?,
|
||||
start + 10,
|
||||
)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if !(1990..=2100).contains(&year) || !(1..=12).contains(&month) || !(1..=31).contains(&day)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// An optional time right behind the date, past one separator character.
|
||||
let after = match bytes.get(after) {
|
||||
Some(b'_' | b'-' | b' ' | b'.' | b',' | b'T' | b't' | b'@') => after + 1,
|
||||
_ => after,
|
||||
};
|
||||
let time = if run(after, 6) {
|
||||
// Six digits, possibly with the milliseconds tail Pixel appends.
|
||||
Some((num(after, 2)?, num(after + 2, 2)?, num(after + 4, 2)?))
|
||||
} else if run(after, 2)
|
||||
&& !digit(after + 2)
|
||||
&& bytes.get(after + 2) == bytes.get(after + 5)
|
||||
&& run(after + 3, 2)
|
||||
&& run(after + 6, 2)
|
||||
{
|
||||
Some((num(after, 2)?, num(after + 3, 2)?, num(after + 6, 2)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (hour, minute, second) = match time {
|
||||
Some((h, m, sec)) if h < 24 && m < 60 && sec < 60 => (h, m, sec),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
|
||||
return Some(
|
||||
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second,
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
let year = year - i64::from(month <= 2);
|
||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||
let year_of_era = year - era * 400;
|
||||
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
era * 146_097 + day_of_era - 719_468
|
||||
}
|
||||
|
||||
/// The inverse of [`unswap_duplicate_marker`]: `IMG_1234(1).jpg` as Google
|
||||
/// writes its sidecar, `IMG_1234.jpg(1)`. `None` when the name carries no
|
||||
/// duplicate marker.
|
||||
fn swap_duplicate_marker(file_name: &str) -> Option<String> {
|
||||
let marker_start = file_name.rfind('(')?;
|
||||
let marker_end = marker_start + file_name[marker_start..].find(')')? + 1;
|
||||
let marker = &file_name[marker_start..marker_end];
|
||||
if !marker[1..marker.len() - 1]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let tail = &file_name[marker_end..];
|
||||
if !tail.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
|
||||
}
|
||||
|
||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
||||
///
|
||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a
|
||||
/// prefix match for the case where truncation cut into the photo name itself.
|
||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||
/// unknown trailing suffix, then a prefix match for the case where truncation
|
||||
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
||||
fn find_sidecar<'a>(
|
||||
file_name: &str,
|
||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||
) -> Option<&'a Vec<u8>> {
|
||||
let mut prefix_match: Option<&'a Vec<u8>> = None;
|
||||
let mut prefix_len = 0usize;
|
||||
|
||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||
for (sidecar_name, body) in in_folder {
|
||||
let stem = strip_sidecar_suffix(sidecar_name);
|
||||
if stem == file_name {
|
||||
return Some(body);
|
||||
}
|
||||
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
|
||||
return Some(body);
|
||||
}
|
||||
// Truncated: the stem is a prefix of the photo's name. Keep the longest
|
||||
// such prefix — a folder holding `IMG_1.jpg` and `IMG_12.jpg` must not
|
||||
// let the shorter stem claim the longer photo.
|
||||
if !stem.is_empty() && file_name.starts_with(&stem) && stem.len() > prefix_len {
|
||||
prefix_len = stem.len();
|
||||
prefix_match = Some(body);
|
||||
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(current, _)| rank < *current) {
|
||||
best = Some((rank, body));
|
||||
}
|
||||
}
|
||||
prefix_match
|
||||
best.map(|(_, body)| body)
|
||||
}
|
||||
|
||||
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
||||
/// key where **lower is better**: the leading number is the rule that matched,
|
||||
/// the second breaks ties within it.
|
||||
fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
|
||||
// 0 — the stem is the photo name once the (English) suffix is off.
|
||||
let plain = strip_sidecar_suffix(stem);
|
||||
if plain == file_name {
|
||||
return Some((0, 0));
|
||||
}
|
||||
// 1 — the same, for `IMG_1234.jpg(1).json` against `IMG_1234(1).jpg`.
|
||||
if unswap_duplicate_marker(&plain).as_deref() == Some(file_name) {
|
||||
return Some((1, 0));
|
||||
}
|
||||
// 2 — the photo name is intact but a suffix we do not recognise follows it.
|
||||
// Google *localizes* `.supplemental-metadata` (a German export writes
|
||||
// `.ergänzende-Metadaten`) and has shipped misspelt variants of it, so the
|
||||
// suffix cannot be enumerated — matching on the intact name in front of it
|
||||
// is what stays locale-agnostic. The shortest leftover wins, so a folder
|
||||
// holding both `IMG_1.jpg.<suffix>` and `IMG_1.jpg(1).<suffix>` gives each
|
||||
// photo its own.
|
||||
for candidate in [
|
||||
Some(file_name.to_string()),
|
||||
swap_duplicate_marker(file_name),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(rest) = stem.strip_prefix(&candidate)
|
||||
&& looks_like_sidecar_suffix(rest)
|
||||
{
|
||||
return Some((2, rest.len()));
|
||||
}
|
||||
}
|
||||
// 3 — truncation ate into the photo name itself, leaving only a prefix of
|
||||
// it. Every suffix boundary in the stem is tried, so this holds for a
|
||||
// localized suffix too; the longest surviving prefix wins, because a folder
|
||||
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the
|
||||
// longer photo.
|
||||
let mut longest: Option<usize> = None;
|
||||
for head in suffix_boundaries(stem) {
|
||||
if !head.is_empty()
|
||||
&& file_name.starts_with(head)
|
||||
&& longest.is_none_or(|len| head.len() > len)
|
||||
{
|
||||
longest = Some(head.len());
|
||||
}
|
||||
}
|
||||
longest.map(|len| (3, usize::MAX - len))
|
||||
}
|
||||
|
||||
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
||||
/// possible boundaries between a photo name and a sidecar suffix.
|
||||
fn suffix_boundaries(stem: &str) -> impl Iterator<Item = &str> {
|
||||
std::iter::once(stem).chain(
|
||||
stem.char_indices()
|
||||
.filter(|(_, c)| *c == '.')
|
||||
.map(|(index, _)| &stem[..index])
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `rest` — what follows an intact photo name in a sidecar's stem —
|
||||
/// looks like a metadata suffix rather than the tail of a different file's name.
|
||||
/// Google's suffixes all start at a `.`, or at the `(N)` duplicate marker.
|
||||
fn looks_like_sidecar_suffix(rest: &str) -> bool {
|
||||
rest.starts_with('.') || rest.starts_with('(')
|
||||
}
|
||||
|
||||
/// Drop a trailing `.json`, case-insensitively.
|
||||
fn strip_json_suffix(sidecar: &str) -> &str {
|
||||
match sidecar.len().checked_sub(5) {
|
||||
Some(cut) if sidecar[cut..].eq_ignore_ascii_case(".json") => &sidecar[..cut],
|
||||
_ => sidecar,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -663,6 +852,106 @@ mod tests {
|
||||
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_supplemental_suffix_still_matches() {
|
||||
// A German export writes `.ergänzende-Metadaten` where an English one
|
||||
// writes `.supplemental-metadata`; the photo name in front of it is
|
||||
// what the match hangs on.
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg.ergänzende-Metadaten.json",
|
||||
r#"{"photoTakenTime":{"timestamp":"1676800000"}}"#,
|
||||
);
|
||||
let scan = assemble(
|
||||
vec![entry(
|
||||
0,
|
||||
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
|
||||
10,
|
||||
)],
|
||||
&sidecars,
|
||||
);
|
||||
assert_eq!(scan.photos[0].capture_time, Some(1_676_800_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_suffix_does_not_let_one_sidecar_claim_a_sibling() {
|
||||
let mut sidecars = HashMap::new();
|
||||
for name in ["IMG_1.jpg", "IMG_1(1).jpg"] {
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
&format!("Takeout/Google Fotos/Album/{name}.ergänzende-Metadaten.json"),
|
||||
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||
);
|
||||
}
|
||||
// Google writes the duplicate's sidecar with the marker after the
|
||||
// extension, so this is the spelling that actually ships.
|
||||
sidecars.remove("Album/IMG_1(1).jpg.ergänzende-Metadaten.json");
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Fotos/Album/IMG_1.jpg(1).ergänzende-Metadaten.json",
|
||||
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||
);
|
||||
let scan = assemble(
|
||||
vec![
|
||||
entry(0, "Takeout/Google Fotos/Album/IMG_1.jpg", 10),
|
||||
entry(0, "Takeout/Google Fotos/Album/IMG_1(1).jpg", 10),
|
||||
],
|
||||
&sidecars,
|
||||
);
|
||||
assert_eq!(scan.photos[0].capture_time, Some(1));
|
||||
assert_eq!(scan.photos[1].capture_time, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_name_date_is_the_fallback_when_no_sidecar_matches() {
|
||||
let scan = assemble(
|
||||
vec![entry(
|
||||
0,
|
||||
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
|
||||
10,
|
||||
)],
|
||||
&HashMap::new(),
|
||||
);
|
||||
// 2023-02-19 00:00:00 UTC.
|
||||
assert_eq!(scan.photos[0].capture_time, Some(1_676_764_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_name_dates_cover_the_common_camera_and_messenger_spellings() {
|
||||
// 2026-08-18 17:10:30 UTC, with Pixel's milliseconds tail.
|
||||
assert_eq!(
|
||||
capture_time_from_name("PXL_20260818_171030868.RAW-01.COVER.jpg"),
|
||||
Some(1_787_073_030)
|
||||
);
|
||||
assert_eq!(
|
||||
capture_time_from_name("IMG_20260818_171030.jpg"),
|
||||
Some(1_787_073_030)
|
||||
);
|
||||
assert_eq!(
|
||||
capture_time_from_name("Screenshot_20260818-171030.png"),
|
||||
Some(1_787_073_030)
|
||||
);
|
||||
assert_eq!(
|
||||
capture_time_from_name("Screenshot from 2026-08-18 17-10-30.png"),
|
||||
Some(1_787_073_030)
|
||||
);
|
||||
// A date with no usable time lands at midnight, not at import time.
|
||||
assert_eq!(
|
||||
capture_time_from_name("IMG-20260818-WA0001.jpg"),
|
||||
Some(1_787_011_200)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_without_a_plausible_date_yields_nothing() {
|
||||
assert_eq!(capture_time_from_name("100_1234.JPG"), None);
|
||||
assert_eq!(capture_time_from_name("DSC00042.jpg"), None);
|
||||
// A nine-digit run is not a date; neither is an out-of-range month.
|
||||
assert_eq!(capture_time_from_name("IMG_202608181.jpg"), None);
|
||||
assert_eq!(capture_time_from_name("IMG_20261818.jpg"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_supplemental_suffixes_still_match() {
|
||||
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
||||
|
||||
@@ -487,7 +487,12 @@ fn scan_local_once(
|
||||
}
|
||||
};
|
||||
|
||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||
let mut excludes =
|
||||
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||
// Resolved per scan rather than once at startup: mounts come and go, and a
|
||||
// mount that appeared since the daemon started is exactly the one most
|
||||
// likely to be a half-alive network filesystem (see `nested_mount_points`).
|
||||
excludes.extend(localindex::nested_mount_points(&home));
|
||||
indexing.store(true, Ordering::Relaxed);
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -541,6 +546,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +411,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
CtlResponse::error(CoreError::invalid("no import is running"))
|
||||
}
|
||||
}
|
||||
// The scan reads the whole persisted timeline and every candidate is a
|
||||
// download plus an upload, so this goes to a background thread and is
|
||||
// read back through `RedateStatus`.
|
||||
Ok(CtlRequest::RedatePhotos {
|
||||
dry_run,
|
||||
limit,
|
||||
range,
|
||||
}) => match core.spawn_redate(dry_run, limit, range) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: if dry_run {
|
||||
"checking photo dates".to_string()
|
||||
} else {
|
||||
"fixing photo dates".to_string()
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RedateStatus) => {
|
||||
let (running, summary) = crate::redate::redate_status();
|
||||
CtlResponse::RedateStatus { running, summary }
|
||||
}
|
||||
Ok(CtlRequest::CancelRedate) => {
|
||||
if crate::redate::cancel_redate() {
|
||||
CtlResponse::Ok {
|
||||
message: "stopping the re-date".to_string(),
|
||||
}
|
||||
} else {
|
||||
CtlResponse::error(CoreError::invalid("no photo re-date is running"))
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::UploadPhoto {
|
||||
name,
|
||||
media_type,
|
||||
|
||||
@@ -29,12 +29,14 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use futures::StreamExt;
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||
use proton_drive_rs::Node;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{Node, NodeMoveItem};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
@@ -642,61 +644,46 @@ impl Core {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// The name half goes first, so that a collision on the move half below is
|
||||
// about the name the node will actually land under rather than the one it
|
||||
// is about to lose.
|
||||
let mut landed = node.name.clone();
|
||||
if landed != name {
|
||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
||||
Ok(()) => landed = name.clone(),
|
||||
// Someone took the name while we were offline. Renaming to a
|
||||
let landed = if node.parent_uid.as_ref() == Some(&parent) {
|
||||
self.drain_rename_in_place(&uid, &node.name, &name)?
|
||||
} else {
|
||||
// Move and rename land as one request (`move-multiple` takes a
|
||||
// target name), so there is no half-applied state between them and
|
||||
// no window for the requirements to go stale between two calls —
|
||||
// the race B46 queued this op to get away from.
|
||||
let target = (node.name != name).then_some(name.as_str());
|
||||
match self.move_renaming(&uid, &parent, target) {
|
||||
Ok(()) => name.clone(),
|
||||
// The destination holds that name already. Landing under a
|
||||
// *different* name is the non-destructive resolution: it neither
|
||||
// clobbers their file nor drops ours, and it is visible.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
landed = alt.clone();
|
||||
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&name,
|
||||
format!("name was taken remotely; renamed to {alt}"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
if node.parent_uid.as_ref() != Some(&parent) {
|
||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
||||
Ok(()) => {}
|
||||
// The destination holds a `landed` of its own. Same resolution as
|
||||
// a name collision, and it has to happen before the move: the API
|
||||
// has no move-and-rename, so the node is renamed out of the way
|
||||
// here and moved second.
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(&landed, now_secs());
|
||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
||||
self.rt
|
||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
format!("destination already had that name; moved as {alt}"),
|
||||
false,
|
||||
);
|
||||
alt
|
||||
}
|
||||
// The destination folder is gone. Leaving the node in its current
|
||||
// parent is the honest outcome: it is not where the user asked for
|
||||
// it, but it exists, it is where it has always been, and the
|
||||
// rename half above still applied. Retrying could only fail again
|
||||
// — the folder is not coming back — and would wedge the queue.
|
||||
// it, but it exists and it is where it has always been. The name
|
||||
// the user chose still applies there. Retrying the move could
|
||||
// only fail again — the folder is not coming back — and would
|
||||
// wedge the queue.
|
||||
Err(e) if is_gone(&e) => {
|
||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
||||
warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
|
||||
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
|
||||
Ok(landed) => landed,
|
||||
// The node went with it; nothing is left to rename.
|
||||
Err(e) if is_gone(&e) => node.name.clone(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
@@ -704,16 +691,73 @@ impl Core {
|
||||
.to_string(),
|
||||
false,
|
||||
);
|
||||
landed
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.db.delete_op(op.id)?;
|
||||
self.note_self_change(&uid);
|
||||
info!(%uid, name = %landed, "pending rename landed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename `uid` without moving it, from `current` to `name`, and return the
|
||||
/// name it landed under. Someone may have taken the name while we were
|
||||
/// offline; the node then lands under a conflict name instead.
|
||||
fn drain_rename_in_place(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
current: &str,
|
||||
name: &str,
|
||||
) -> Result<String, ProtonError> {
|
||||
if current == name {
|
||||
return Ok(current.to_string());
|
||||
}
|
||||
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
|
||||
Ok(()) => Ok(name.to_string()),
|
||||
Err(e) if is_already_exists(&e) => {
|
||||
let alt = conflict_name(name, now_secs());
|
||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
|
||||
self.adopt_drained_name(uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
name,
|
||||
format!("name was taken remotely; renamed to {alt}"),
|
||||
false,
|
||||
);
|
||||
Ok(alt)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
|
||||
/// the way when one is given.
|
||||
fn move_renaming(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
parent: &NodeUid,
|
||||
target_name: Option<&str>,
|
||||
) -> Result<(), ProtonError> {
|
||||
let item = NodeMoveItem {
|
||||
uid: uid.clone(),
|
||||
target_name: target_name.map(str::to_string),
|
||||
};
|
||||
self.rt.block_on(async {
|
||||
let mut outcomes =
|
||||
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
|
||||
match outcomes.next().await {
|
||||
Some(Ok((_, outcome))) => outcome,
|
||||
Some(Err(e)) => Err(e),
|
||||
None => Err(ProtonError::invalid_operation(format!(
|
||||
"move of {uid} reported no outcome"
|
||||
))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a queued trash to the remote.
|
||||
///
|
||||
/// A node that is already gone is a success, not a failure: the outcome the
|
||||
@@ -1587,6 +1631,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1879,6 +1925,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ mod mount;
|
||||
mod photos;
|
||||
mod profile;
|
||||
mod reads;
|
||||
mod redate;
|
||||
mod revisions;
|
||||
mod sharing;
|
||||
mod shutdown;
|
||||
@@ -3737,6 +3738,9 @@ impl Core {
|
||||
// My own tree: a role only means something under a share, and
|
||||
// this listing never crosses into one.
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -4318,6 +4322,9 @@ impl Core {
|
||||
uid: item.uid,
|
||||
path: String::new(),
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -4856,7 +4863,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
||||
|
||||
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
||||
/// receive uids as strings over the control socket and pass them back verbatim.
|
||||
fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||
pub(crate) fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||
let (vol, link) = s.split_once('~')?;
|
||||
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
||||
return None;
|
||||
@@ -4993,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
||||
album: None,
|
||||
// Nothing signed it: it has never been near the crypto layer.
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5917,6 +5926,8 @@ mod pending_size_tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7127,6 +7138,9 @@ mod tests {
|
||||
share_id,
|
||||
membership_id: ShareMembershipId::from("membership"),
|
||||
permissions,
|
||||
invite_time: None,
|
||||
inviter_email: None,
|
||||
inviter_verification: None,
|
||||
});
|
||||
node
|
||||
};
|
||||
@@ -7587,6 +7601,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Repairing photos whose capture time is when they were *imported* rather than
|
||||
//! when they were taken.
|
||||
//!
|
||||
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
|
||||
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
|
||||
//! and the SDK then seals "now" into every revision. A decade of photos lands on
|
||||
//! one day.
|
||||
//!
|
||||
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||
//! rewrite: download the photo, upload it again with the capture time its file
|
||||
//! name implies, put the new copy back into the albums and favourites the old
|
||||
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||
//!
|
||||
//! A photo is a candidate only when its name carries a date that disagrees with
|
||||
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
|
||||
//! a photo whose name says nothing about its date is left alone, because there
|
||||
//! is nothing better to set it to.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use pdfs_core::control::{RedateSummary, TransferDirection};
|
||||
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::Core;
|
||||
use crate::parse_uid as parse_node_uid;
|
||||
use crate::transfers::CountingReader;
|
||||
|
||||
/// How far a stored capture time may sit from the one the file name implies
|
||||
/// before the photo counts as mis-dated. A day, because a name usually carries
|
||||
/// only the date and the stored time carries a clock — and because a timezone
|
||||
/// can legitimately move a photo by hours.
|
||||
const DRIFT: i64 = 86_400;
|
||||
|
||||
/// How many examples a report carries.
|
||||
const SAMPLES: usize = 10;
|
||||
|
||||
/// The one re-date this daemon may be running, and how the last one ended.
|
||||
/// Process-global for the same reasons as the Takeout import's state: one run
|
||||
/// per daemon however many `Core` clones exist, and a front-end asking after it
|
||||
/// finished must still find the report.
|
||||
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
|
||||
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||
|
||||
#[derive(Default)]
|
||||
struct RedateState {
|
||||
running: bool,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
last: Option<RedateSummary>,
|
||||
}
|
||||
|
||||
/// Whether a re-date is running, and the last one's report.
|
||||
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||
let state = REDATE.lock();
|
||||
(state.running, state.last.clone())
|
||||
}
|
||||
|
||||
/// Ask the running re-date to stop. False when none is running.
|
||||
pub(crate) fn cancel_redate() -> bool {
|
||||
match &REDATE.lock().cancel {
|
||||
Some(flag) => {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One photo to repair.
|
||||
struct Candidate {
|
||||
uid: NodeUid,
|
||||
name: String,
|
||||
/// The capture time it carries now.
|
||||
stored: i64,
|
||||
/// The capture time its name implies.
|
||||
wanted: i64,
|
||||
favorite: bool,
|
||||
/// Album node uids the original is filed under.
|
||||
albums: Vec<String>,
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Re-date mis-dated photos. Blocking; run on its own thread.
|
||||
///
|
||||
/// `cancel` stops the run at the next photo boundary. A photo is only ever
|
||||
/// trashed after its replacement has uploaded, so a cancel — or a crash —
|
||||
/// can leave a duplicate, never a hole.
|
||||
pub(crate) fn redate_photos(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
limit: Option<usize>,
|
||||
range: Option<(i64, i64)>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
) -> CoreResult<RedateSummary> {
|
||||
let job = self.transfers.begin_job("Fixing photo dates");
|
||||
job.detail("looking for mis-dated photos");
|
||||
|
||||
let stored = self
|
||||
.db
|
||||
.photos_all()
|
||||
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
|
||||
let mut summary = RedateSummary {
|
||||
examined: stored.len(),
|
||||
dry_run,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
for photo in stored {
|
||||
if let Some((from, to)) = range
|
||||
&& !(from..to).contains(&photo.capture_time)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(wanted) = capture_time_from_name(&name) else {
|
||||
continue;
|
||||
};
|
||||
if (wanted - photo.capture_time).abs() <= DRIFT {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = parse_node_uid(&photo.uid) else {
|
||||
continue;
|
||||
};
|
||||
candidates.push(Candidate {
|
||||
uid,
|
||||
name,
|
||||
stored: photo.capture_time,
|
||||
wanted,
|
||||
favorite: photo.favorite,
|
||||
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
if let Some(limit) = limit {
|
||||
candidates.truncate(limit);
|
||||
}
|
||||
summary.candidates = candidates.len();
|
||||
// Sampled after the limit, so the examples are of photos the run would
|
||||
// actually touch.
|
||||
summary.samples = candidates
|
||||
.iter()
|
||||
.take(SAMPLES)
|
||||
.map(|c| (c.name.clone(), c.stored, c.wanted))
|
||||
.collect();
|
||||
job.set_total(candidates.len() as u64);
|
||||
info!(
|
||||
examined = summary.examined,
|
||||
candidates = summary.candidates,
|
||||
dry_run,
|
||||
"photo re-date scan complete"
|
||||
);
|
||||
|
||||
if dry_run || candidates.is_empty() {
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
job.detail(candidate.name.clone());
|
||||
if let Err(e) = self.redate_one(&candidate, &mut summary) {
|
||||
summary.failed += 1;
|
||||
warn!(name = candidate.name, error = %e, "cannot re-date photo");
|
||||
}
|
||||
job.step();
|
||||
}
|
||||
|
||||
self.spawn_timeline_refresh();
|
||||
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// Rewrite one photo at the capture time its name implies.
|
||||
///
|
||||
/// Order is load-bearing: upload first, file the new copy, and only then
|
||||
/// trash the original. Every earlier step failing leaves the account exactly
|
||||
/// as it was.
|
||||
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let path = self.open_photo(&candidate.uid)?;
|
||||
let size = std::fs::metadata(&path)
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||
.len();
|
||||
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
|
||||
let file = std::fs::File::open(&path)
|
||||
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
|
||||
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: Some(candidate.wanted),
|
||||
..Default::default()
|
||||
};
|
||||
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||
&candidate.name,
|
||||
media_type,
|
||||
reader,
|
||||
size as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
));
|
||||
drop(guard);
|
||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||
|
||||
// Favourite and albums before the trash: a failure here leaves both
|
||||
// copies on the account, which is recoverable by hand. Losing the
|
||||
// original first would not be.
|
||||
if candidate.favorite {
|
||||
let update = PhotoTagsUpdate {
|
||||
node_uid: new_uid.clone(),
|
||||
tags_to_add: vec![PhotoTag::Favorite],
|
||||
tags_to_remove: Vec::new(),
|
||||
};
|
||||
if let Err(e) = self.rt.block_on(photos.update_photos(&[update])) {
|
||||
warn!(name = candidate.name, error = %e, "cannot re-favourite re-dated photo");
|
||||
}
|
||||
}
|
||||
for album in &candidate.albums {
|
||||
let Some(album_uid) = parse_node_uid(album) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.rt
|
||||
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
|
||||
{
|
||||
Ok(_) => summary.album_links += 1,
|
||||
Err(e) => {
|
||||
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||
}
|
||||
|
||||
summary.redated += 1;
|
||||
summary.bytes += size;
|
||||
info!(
|
||||
name = candidate.name,
|
||||
from = candidate.stored,
|
||||
to = candidate.wanted,
|
||||
"photo re-dated"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a re-date on its own thread. Errors when one is already running.
|
||||
pub(crate) fn spawn_redate(
|
||||
&self,
|
||||
dry_run: bool,
|
||||
limit: Option<usize>,
|
||||
range: Option<(i64, i64)>,
|
||||
) -> CoreResult<()> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let mut state = REDATE.lock();
|
||||
if state.running {
|
||||
return Err(CoreError::conflict(
|
||||
"a photo re-date is already running; wait for it to finish or cancel it",
|
||||
));
|
||||
}
|
||||
state.running = true;
|
||||
state.cancel = Some(cancel.clone());
|
||||
state.last = None;
|
||||
}
|
||||
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let outcome = core.redate_photos(dry_run, limit, range, cancel);
|
||||
let mut state = REDATE.lock();
|
||||
state.running = false;
|
||||
state.cancel = None;
|
||||
state.last = outcome.as_ref().ok().cloned();
|
||||
match &outcome {
|
||||
Ok(summary) => info!(
|
||||
candidates = summary.candidates,
|
||||
redated = summary.redated,
|
||||
failed = summary.failed,
|
||||
"photo re-date finished"
|
||||
),
|
||||
Err(e) => warn!(error = %e, "photo re-date failed"),
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_uid_without_both_halves_is_not_a_node() {
|
||||
assert!(parse_node_uid("vol~link").is_some());
|
||||
assert!(parse_node_uid("vol~").is_none());
|
||||
assert!(parse_node_uid("~link").is_none());
|
||||
assert!(parse_node_uid("nothing").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_disagreement_larger_than_a_day_counts() {
|
||||
// The name says 2023-02-19; a stored time inside that day is fine.
|
||||
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
|
||||
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
|
||||
// An import three years later is not.
|
||||
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let invitation = n.membership.as_ref();
|
||||
let shared_by = invitation
|
||||
.and_then(|m| m.inviter_email.clone())
|
||||
.unwrap_or_default();
|
||||
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||
// Only a signature that is present and wrong is a warning. An invitation
|
||||
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||
// is not evidence of forgery, and flagging every such share would teach the
|
||||
// user to ignore the flag.
|
||||
let shared_by_unverified = invitation
|
||||
.and_then(|m| m.inviter_verification)
|
||||
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
role,
|
||||
shared_by,
|
||||
shared_at,
|
||||
shared_by_unverified,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1120,6 +1120,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,6 +1130,9 @@ mod tests {
|
||||
share_id: ShareId::from("share"),
|
||||
membership_id: ShareMembershipId::from("membership"),
|
||||
permissions,
|
||||
invite_time: None,
|
||||
inviter_email: None,
|
||||
inviter_verification: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ksni = "0.2"
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||
ksni = { version = "0.3", features = ["blocking"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
webkit6 = "0.4"
|
||||
webkit6 = "0.6"
|
||||
@@ -24,7 +24,7 @@ use widgets::versions_dialog::*;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -484,7 +484,8 @@ fn build_window(app: &adw::Application) {
|
||||
decode_idle: Cell::new(false),
|
||||
thumb_source: RefCell::new(None),
|
||||
relayout_source: RefCell::new(None),
|
||||
bound: RefCell::new(HashMap::new()),
|
||||
bound: RefCell::new(BTreeMap::new()),
|
||||
list: gallery_widgets.list.clone(),
|
||||
},
|
||||
shared: SharedState {
|
||||
content: shared_widgets.content.clone(),
|
||||
|
||||
@@ -2345,6 +2345,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
|
||||
@@ -5,8 +5,12 @@ pub(crate) struct GalleryState {
|
||||
/// Every photo loaded so far, newest first — the order the lightbox's
|
||||
/// prev/next walks. The visible day sections are derived from this.
|
||||
pub(crate) model: gio::ListStore,
|
||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||
/// [`Self::model`] by [`repaint_gallery`].
|
||||
/// The Photos ListView's own model: one item per *rendered row* — a day
|
||||
/// heading, or one row of that day's tile grid — rebuilt from
|
||||
/// [`Self::model`] by [`repaint_gallery`]. Rows rather than whole days
|
||||
/// because the ListView only realises what is on screen, and a day holding
|
||||
/// 1,600 photos (a Takeout import lands one) would otherwise be 1,600
|
||||
/// widgets built in a single bind.
|
||||
pub(crate) groups: gio::ListStore,
|
||||
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
||||
@@ -110,11 +114,14 @@ pub(crate) struct GalleryState {
|
||||
/// re-flow, replaced on each new trigger so only the last one fires.
|
||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The day sections currently realised by the ListView, by their index in
|
||||
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
||||
/// rebuilding the ListStore instead would reset the scroll position out from
|
||||
/// under the user.
|
||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
||||
/// The rows currently realised by the ListView, as row index -> the uid of
|
||||
/// its first photo (absent for a heading row). A resize or a zoom step
|
||||
/// changes how many tiles fit per row, so the row model has to be rebuilt —
|
||||
/// this is what lets the rebuild put the user back where they were.
|
||||
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
|
||||
/// The ListView itself, so a rebuild can scroll back to the row the user was
|
||||
/// looking at.
|
||||
pub(crate) list: gtk4::ListView,
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
@@ -158,13 +165,71 @@ pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
|
||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||
/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by
|
||||
/// [`group_photos`] and rendered as one [`gtk4::ListView`] row.
|
||||
/// [`group_photos`], then flattened into [`GalleryRow`]s for rendering.
|
||||
pub(crate) struct PhotoGroup {
|
||||
/// "Today", "Yesterday", or e.g. "3 June 2026".
|
||||
pub(crate) heading: String,
|
||||
pub(crate) photos: Vec<PhotoItem>,
|
||||
}
|
||||
|
||||
/// One item of the ListView's model — the unit the gallery virtualises at.
|
||||
///
|
||||
/// A whole day is *not* one item: a Google Photos Takeout drops thousands of
|
||||
/// photos onto a single date, and one ListView item per day means GTK builds
|
||||
/// every one of those tiles in one bind, on the main thread, before the row can
|
||||
/// be shown. Splitting the day into its grid rows keeps a bind to a handful of
|
||||
/// widgets no matter how big the day is.
|
||||
pub(crate) enum GalleryRow {
|
||||
/// A day heading: "Today", "3 June 2026".
|
||||
Heading(String),
|
||||
/// One row of a day's grid, already laid out to the current width and zoom.
|
||||
Tiles {
|
||||
photos: Vec<PhotoItem>,
|
||||
edge: i32,
|
||||
/// True for the last row of its day, which carries the section's bottom
|
||||
/// margin so days stay visually separated.
|
||||
last: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl GalleryRow {
|
||||
/// The uid of the row's first photo, or `None` for a heading — the anchor a
|
||||
/// relayout scrolls back to.
|
||||
fn anchor(&self) -> Option<String> {
|
||||
match self {
|
||||
GalleryRow::Heading(_) => None,
|
||||
GalleryRow::Tiles { photos, .. } => photos.first().map(|p| p.uid.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether re-rendering `self` would produce exactly what `other` shows —
|
||||
/// the test [`repaint_gallery`] diffs on, so appending a page only touches
|
||||
/// the rows that actually changed.
|
||||
fn same_as(&self, other: &GalleryRow) -> bool {
|
||||
match (self, other) {
|
||||
(GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b,
|
||||
(
|
||||
GalleryRow::Tiles {
|
||||
photos: a,
|
||||
edge: ae,
|
||||
last: al,
|
||||
},
|
||||
GalleryRow::Tiles {
|
||||
photos: b,
|
||||
edge: be,
|
||||
last: bl,
|
||||
},
|
||||
) => {
|
||||
ae == be
|
||||
&& al == bl
|
||||
&& a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| x.uid == y.uid)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widgets [`build_gallery_page`] hands back to [`build_window`].
|
||||
pub(crate) struct GalleryWidgets {
|
||||
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
|
||||
@@ -611,40 +676,64 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
section.set_margin_bottom(16);
|
||||
let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
heading.add_css_class("heading");
|
||||
heading.add_css_class("gallery-day");
|
||||
section.append(&heading);
|
||||
item.set_child(Some(§ion));
|
||||
// One container for both row kinds: the bind fills it with either a
|
||||
// heading label or a strip of tiles. The alternative — two factories, or
|
||||
// a Stack per row — buys nothing, since a row's children are rebuilt on
|
||||
// bind either way.
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||
row.set_halign(gtk4::Align::Start);
|
||||
item.set_child(Some(&row));
|
||||
item.set_activatable(false);
|
||||
});
|
||||
|
||||
let ui_bind = ui.clone();
|
||||
factory.connect_bind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let section = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let row_box = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
|
||||
let group = obj.borrow::<PhotoGroup>();
|
||||
let row = obj.borrow::<GalleryRow>();
|
||||
|
||||
let heading = section.first_child().and_downcast::<gtk4::Label>().unwrap();
|
||||
heading.set_label(&group.heading);
|
||||
|
||||
fill_section(&ui_bind, §ion, &group.photos);
|
||||
// Remember the realised section so a learned aspect ratio or a resize can
|
||||
// re-justify it in place, without rebuilding the ListStore (which would
|
||||
// yank the scroll position back to the top).
|
||||
while let Some(child) = row_box.first_child() {
|
||||
row_box.remove(&child);
|
||||
}
|
||||
match &*row {
|
||||
GalleryRow::Heading(heading) => {
|
||||
row_box.set_margin_top(16);
|
||||
row_box.set_margin_bottom(8);
|
||||
let label = gtk4::Label::builder()
|
||||
.label(heading)
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
label.add_css_class("heading");
|
||||
label.add_css_class("gallery-day");
|
||||
row_box.append(&label);
|
||||
}
|
||||
GalleryRow::Tiles { photos, edge, last } => {
|
||||
row_box.set_margin_top(0);
|
||||
// The last row of a day carries the gap to the next heading.
|
||||
row_box.set_margin_bottom(if *last { 8 } else { TILE_GAP });
|
||||
for photo in photos {
|
||||
row_box.append(&photo_tile(
|
||||
&ui_bind,
|
||||
Tile {
|
||||
photo: photo.clone(),
|
||||
edge: *edge,
|
||||
},
|
||||
));
|
||||
}
|
||||
schedule_thumbs(&ui_bind);
|
||||
}
|
||||
}
|
||||
ui_bind
|
||||
.gallery
|
||||
.bound
|
||||
.borrow_mut()
|
||||
.insert(item.position(), section);
|
||||
.insert(item.position(), row.anchor());
|
||||
});
|
||||
|
||||
// ListView recycles section widgets, so a scrolled-away day must give up its
|
||||
// claim on the widgets — otherwise a thumbnail landing late would paint into
|
||||
// a tile that now shows a different day.
|
||||
// ListView recycles row widgets, so a scrolled-away row must give up its
|
||||
// claim on them — otherwise a thumbnail landing late would paint into a tile
|
||||
// that now shows a different photo.
|
||||
let ui_unbind = ui.clone();
|
||||
factory.connect_unbind(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
@@ -653,10 +742,11 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
.bound
|
||||
.borrow_mut()
|
||||
.remove(&item.position());
|
||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
|
||||
let group = obj.borrow::<PhotoGroup>();
|
||||
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
|
||||
&& let GalleryRow::Tiles { photos, .. } = &*obj.borrow::<GalleryRow>()
|
||||
{
|
||||
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
|
||||
for photo in &group.photos {
|
||||
for photo in photos {
|
||||
wanted.remove(&photo.uid);
|
||||
}
|
||||
}
|
||||
@@ -917,34 +1007,6 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
||||
(columns, edge)
|
||||
}
|
||||
|
||||
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
|
||||
/// current content width and hand each tile whatever thumbnail is already in
|
||||
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
|
||||
/// heading — so a re-flow never touches the ListView's model or scroll.
|
||||
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
||||
let Some(heading) = section.first_child() else {
|
||||
return;
|
||||
};
|
||||
while let Some(old) = heading.next_sibling() {
|
||||
section.remove(&old);
|
||||
}
|
||||
|
||||
let width = gallery_width(ui);
|
||||
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
||||
for row in grid_rows(ui, photos, width) {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||
// A short last row stays left-aligned at the same tile size rather than
|
||||
// stretching: an even grid is the whole point of the square layout.
|
||||
row_box.set_halign(gtk4::Align::Start);
|
||||
for tile in row {
|
||||
row_box.append(&photo_tile(ui, tile));
|
||||
}
|
||||
rows.append(&row_box);
|
||||
}
|
||||
section.append(&rows);
|
||||
schedule_thumbs(ui);
|
||||
}
|
||||
|
||||
/// The width the grid is laid out to: the ListView's own width, less a couple of
|
||||
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||
/// Falls back to a sane guess before the first allocation.
|
||||
@@ -1238,27 +1300,35 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
*ui.gallery.relayout_source.borrow_mut() = Some(source);
|
||||
}
|
||||
|
||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
||||
/// width and zoom. Sections that are *not* realised need no work: they lay
|
||||
/// themselves out against the current state when the ListView binds them.
|
||||
/// Re-flow the timeline at the current width and zoom.
|
||||
///
|
||||
/// A different column count means different rows, so unlike the old
|
||||
/// section-per-day model there is nothing to patch in place — the row model is
|
||||
/// rebuilt. What that would cost the user is their scroll position, so the
|
||||
/// topmost realised row's first photo is remembered and scrolled back to.
|
||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||
.gallery
|
||||
.bound
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|(pos, section)| (*pos, section.clone()))
|
||||
.collect();
|
||||
for (pos, section) in bound {
|
||||
let Some(obj) = ui.gallery.groups.item(pos) else {
|
||||
continue;
|
||||
};
|
||||
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
|
||||
continue;
|
||||
};
|
||||
let photos = boxed.borrow::<PhotoGroup>().photos.clone();
|
||||
fill_section(ui, §ion, &photos);
|
||||
}
|
||||
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
|
||||
repaint_gallery(ui);
|
||||
let Some(anchor) = anchor else { return };
|
||||
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
|
||||
return;
|
||||
};
|
||||
ui.gallery
|
||||
.list
|
||||
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
|
||||
}
|
||||
|
||||
/// Which row of the rendered model holds `uid`, if any.
|
||||
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
|
||||
(0..store.n_items()).find(|i| {
|
||||
store
|
||||
.item(*i)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
|
||||
GalleryRow::Heading(_) => false,
|
||||
GalleryRow::Tiles { photos, .. } => photos.iter().any(|p| p.uid == uid),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
|
||||
@@ -1276,46 +1346,75 @@ pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||
schedule_relayout(ui);
|
||||
}
|
||||
|
||||
/// Rebuild the day sections from the flat photo model. The timeline arrives
|
||||
/// newest-first, so photos of the same day are already contiguous — one pass
|
||||
/// splits them.
|
||||
/// Rebuild the ListView's row model from the flat photo model: each day becomes
|
||||
/// a heading row followed by its grid rows, laid out to the current width and
|
||||
/// zoom.
|
||||
///
|
||||
/// The groups are diffed into the existing store rather than replacing it: a
|
||||
/// "load more" only really changes the last day (the one the new page continues)
|
||||
/// and appends after it, and clearing the store instead would scroll the user
|
||||
/// back to the top of the timeline at the exact moment they asked for more.
|
||||
/// The rows are diffed into the existing store rather than replacing it: a "load
|
||||
/// more" only really changes the last day (the one the new page continues) and
|
||||
/// appends after it, and clearing the store instead would scroll the user back
|
||||
/// to the top of the timeline at the exact moment they asked for more.
|
||||
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
||||
let groups = group_photos(&ui.gallery.model);
|
||||
let rows = build_rows(ui);
|
||||
let store = &ui.gallery.groups;
|
||||
|
||||
for (i, group) in groups.iter().enumerate() {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
let unchanged = store
|
||||
.item(i)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|old| {
|
||||
let old = old.borrow::<PhotoGroup>();
|
||||
old.heading == group.heading && old.photos.len() == group.photos.len()
|
||||
});
|
||||
.is_some_and(|old| row.same_as(&old.borrow::<GalleryRow>()));
|
||||
if unchanged {
|
||||
continue;
|
||||
}
|
||||
let boxed = BoxedAnyObject::new(PhotoGroup {
|
||||
heading: group.heading.clone(),
|
||||
photos: group.photos.clone(),
|
||||
});
|
||||
let boxed = BoxedAnyObject::new(clone_row(row));
|
||||
if i < store.n_items() {
|
||||
store.splice(i, 1, &[boxed]);
|
||||
} else {
|
||||
store.append(&boxed);
|
||||
}
|
||||
}
|
||||
// Photos only ever get appended, so a shorter model means a fresh load.
|
||||
if store.n_items() > groups.len() as u32 {
|
||||
let len = groups.len() as u32;
|
||||
if store.n_items() > rows.len() as u32 {
|
||||
let len = rows.len() as u32;
|
||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
||||
}
|
||||
|
||||
update_gallery_subtitle(ui);
|
||||
}
|
||||
|
||||
/// Flatten the loaded photos into the rows the ListView renders.
|
||||
fn build_rows(ui: &Rc<Ui>) -> Vec<GalleryRow> {
|
||||
let width = gallery_width(ui);
|
||||
let mut rows = Vec::new();
|
||||
for group in group_photos(&ui.gallery.model) {
|
||||
rows.push(GalleryRow::Heading(group.heading));
|
||||
let grid = grid_rows(ui, &group.photos, width);
|
||||
let last_index = grid.len().saturating_sub(1);
|
||||
for (index, tiles) in grid.into_iter().enumerate() {
|
||||
let edge = tiles.first().map(|t| t.edge).unwrap_or(TILE_DEFAULT);
|
||||
rows.push(GalleryRow::Tiles {
|
||||
photos: tiles.into_iter().map(|t| t.photo).collect(),
|
||||
edge,
|
||||
last: index == last_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn clone_row(row: &GalleryRow) -> GalleryRow {
|
||||
match row {
|
||||
GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()),
|
||||
GalleryRow::Tiles { photos, edge, last } => GalleryRow::Tiles {
|
||||
photos: photos.clone(),
|
||||
edge: *edge,
|
||||
last: *last,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// "1,204 photos" under the page title.
|
||||
fn update_gallery_subtitle(ui: &Rc<Ui>) {
|
||||
let loaded = ui.gallery.model.n_items() as usize;
|
||||
ui.gallery.subtitle.set_visible(loaded > 0);
|
||||
// An album counts what the server says it holds, not how much of it has been
|
||||
|
||||
@@ -309,6 +309,17 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
// The "shared by" in the subtitle is what the invitation claims. When its
|
||||
// signature does not check out against that person's keys, the claim must
|
||||
// not read as fact — someone may be impersonating a contact to get a file
|
||||
// opened.
|
||||
if entry.shared_by_unverified {
|
||||
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||
warning.add_css_class("warning");
|
||||
warning.set_valign(gtk4::Align::Center);
|
||||
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
|
||||
row.add_suffix(&warning);
|
||||
}
|
||||
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||
let ui_act = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
@@ -329,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row
|
||||
}
|
||||
|
||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
||||
/// pointing at a directory that is not there.
|
||||
/// Tooltip on the warning shown next to a share whose invitation did not verify.
|
||||
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
|
||||
It may not really be from them.";
|
||||
|
||||
/// A shared row's subtitle: who shared it and when (share roots only — that is
|
||||
/// where the invitation lives), its size (files only), and where it can be
|
||||
/// reached in the local mount once the synthetic `Shared with me/` directory has
|
||||
/// interned it. An unlisted share has no local path yet, and says nothing rather
|
||||
/// than pointing at a directory that is not there.
|
||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !entry.shared_by.is_empty() {
|
||||
let date = if entry.shared_at > 0 {
|
||||
format_modified(entry.shared_at)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
parts.push(if date.is_empty() {
|
||||
format!("Shared by {}", entry.shared_by)
|
||||
} else {
|
||||
format!("Shared by {} on {date}", entry.shared_by)
|
||||
});
|
||||
}
|
||||
if !entry.is_dir {
|
||||
parts.push(human_bytes(entry.size));
|
||||
}
|
||||
@@ -723,9 +751,25 @@ mod tests {
|
||||
uid: "vol~link".into(),
|
||||
path: path.into(),
|
||||
role: role.into(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_root_says_who_shared_it() {
|
||||
let mut root = entry(true, "", "viewer");
|
||||
root.shared_by = "alice@proton.me".into();
|
||||
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
|
||||
root.shared_at = 1_700_000_000;
|
||||
let subtitle = shared_row_subtitle(&root);
|
||||
assert!(
|
||||
subtitle.starts_with("Shared by alice@proton.me on "),
|
||||
"{subtitle}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||
// Before the synthetic directory has been listed, the daemon cannot
|
||||
|
||||
@@ -293,5 +293,8 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
path: item.path.clone(),
|
||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
}
|
||||
}
|
||||
+15
-172
@@ -1,26 +1,23 @@
|
||||
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
||||
//!
|
||||
//! Everything here is blocking and GTK-free — the process exists for one
|
||||
//! round-trip and then exits, so there is no main loop to keep off. It reuses
|
||||
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
|
||||
//! activation policy, so the two front ends cannot drift on what "open" means.
|
||||
//! round-trip and then exits, so there is no main loop to keep off. Talking to
|
||||
//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
|
||||
//! fzf front end and built on the GTK prompt's own [`Hit`](crate::Hit) model
|
||||
//! and activation policy; this module is only presentation.
|
||||
//!
|
||||
//! Launchers filter a static list; they cannot call back for a new one per
|
||||
//! keystroke. So the interaction is a loop: the first menu shows pinned files
|
||||
//! and accepts free text, and any text that matches no entry becomes the next
|
||||
//! search. Escape (an empty selection) ends it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
//! search. Escape (an empty selection) ends it. `--fzf` ([`crate::fzf`]) is the
|
||||
//! front end for users who want results to appear while they type instead.
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||
};
|
||||
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||
use crate::Hit;
|
||||
use crate::query;
|
||||
|
||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||
/// without it a scripted (non-interactive) menu could spin forever.
|
||||
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let mountpoint = match request(&socket, Request::Status)? {
|
||||
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
|
||||
_ => dirs.default_mountpoint(),
|
||||
};
|
||||
let mountpoint = query::mountpoint(&socket, &dirs);
|
||||
|
||||
let mut query = options
|
||||
.query
|
||||
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
.filter(|q| !q.is_empty());
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let hits = match &query {
|
||||
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
|
||||
None => pins(&socket)?,
|
||||
Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
|
||||
None => query::pins(&socket)?,
|
||||
};
|
||||
let items: Vec<MenuItem> = if hits.is_empty() {
|
||||
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
||||
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
query = None;
|
||||
continue;
|
||||
};
|
||||
return open(&socket, &mountpoint, &policy, hit, query.is_none());
|
||||
return query::open(&socket, &mountpoint, &policy, hit, query.is_none());
|
||||
}
|
||||
// No entry matched what was typed: treat it as the next query.
|
||||
MenuChoice::Custom(text) => query = Some(text),
|
||||
@@ -118,57 +112,6 @@ fn placeholder(query: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
||||
/// with, and the only listing the daemon offers without a query.
|
||||
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(pins
|
||||
.into_iter()
|
||||
.map(|pin| {
|
||||
Hit::Drive(SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
||||
let reply = request(
|
||||
socket,
|
||||
Request::SearchV2 {
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
filters: SearchFilters {
|
||||
sources: vec![SearchSource::Drive, SearchSource::Local],
|
||||
kind: SearchKind::All,
|
||||
},
|
||||
},
|
||||
)?;
|
||||
let Response::SearchResultsV2 {
|
||||
drive_hits,
|
||||
local_hits,
|
||||
..
|
||||
} = reply
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
||||
hits.extend(local_hits.into_iter().map(Hit::Local));
|
||||
rank_hits(&mut hits);
|
||||
hits.truncate(limit);
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// One launcher line per hit. Labels must be unique: they are how the selection
|
||||
/// is matched back, and two files of the same name in the same folder listing
|
||||
/// would otherwise be indistinguishable.
|
||||
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
|
||||
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
||||
suffix += 1;
|
||||
}
|
||||
items.push(MenuItem::new(label, Some(icon_name(hit))));
|
||||
items.push(MenuItem::new(label, Some(query::icon_name(hit))));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Icon theme name for a hit.
|
||||
///
|
||||
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
||||
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
||||
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
||||
/// names below exist in every icon theme.
|
||||
fn icon_name(hit: &Hit) -> String {
|
||||
if hit.is_dir() {
|
||||
return "folder".to_string();
|
||||
}
|
||||
let name = hit.name();
|
||||
if is_image(name) {
|
||||
"image-x-generic"
|
||||
} else if is_media(name) {
|
||||
"video-x-generic"
|
||||
} else if is_document(name) {
|
||||
"text-x-generic"
|
||||
} else {
|
||||
"application-x-generic"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
||||
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
||||
/// else is materialised by the daemon first.
|
||||
fn open(
|
||||
socket: &Path,
|
||||
mountpoint: &Path,
|
||||
policy: &OpenWith,
|
||||
hit: &Hit,
|
||||
from_pins: bool,
|
||||
) -> Result<(), String> {
|
||||
match hit {
|
||||
Hit::Local(local) => {
|
||||
opener::open(policy, Path::new(&local.path), local.is_dir);
|
||||
Ok(())
|
||||
}
|
||||
Hit::Drive(drive) => {
|
||||
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
||||
// policy rather than node kind, so both go to the mount unchecked.
|
||||
let unconditional = from_pins
|
||||
|| matches!(
|
||||
drive_activation(&drive.name, drive.is_dir),
|
||||
DriveActivation::Folder | DriveActivation::MountedMedia
|
||||
);
|
||||
if unconditional {
|
||||
opener::open(
|
||||
policy,
|
||||
&mounted_or_relative(mountpoint, drive),
|
||||
drive.is_dir,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Anything else the mount exposes opens there too, so an editor
|
||||
// saves back into Drive instead of into a cache blob.
|
||||
if let Some(path) = mounted_target(mountpoint, drive) {
|
||||
opener::open(policy, &path, false);
|
||||
return Ok(());
|
||||
}
|
||||
match request(
|
||||
socket,
|
||||
Request::OpenFile {
|
||||
path: drive.path.clone(),
|
||||
uid: Some(drive.uid.clone()),
|
||||
},
|
||||
)? {
|
||||
Response::FilePath { path } => {
|
||||
// The reply is a content-hash blob; the rules key off the
|
||||
// Drive name, which is what the user actually opened.
|
||||
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
||||
Ok(())
|
||||
}
|
||||
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
||||
_ => Err("unexpected reply from the daemon".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
||||
/// terminal rather than a status page.
|
||||
fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::LocalHit;
|
||||
use pdfs_core::control::{LocalHit, SearchHit};
|
||||
|
||||
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
||||
Hit::Drive(SearchHit {
|
||||
@@ -331,18 +188,4 @@ mod tests {
|
||||
assert!(placeholder(None).contains("type to search"));
|
||||
assert!(placeholder(Some("md")).contains("md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_are_theme_names_the_launcher_can_look_up() {
|
||||
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
|
||||
assert_eq!(
|
||||
icon_name(&drive("a.png", "a.png", false)),
|
||||
"image-x-generic"
|
||||
);
|
||||
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
|
||||
assert_eq!(
|
||||
icon_name(&drive("a.bin", "a.bin", false)),
|
||||
"application-x-generic"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! `pdfs-prompt --fzf`: launcher search that queries the daemon per keystroke.
|
||||
//!
|
||||
//! A dmenu-protocol launcher (fuzzel, rofi, wofi, …) is handed one static list
|
||||
//! and filters it itself; it cannot ask for a new one as the user types, which
|
||||
//! is why [`crate::dmenu`] has to make the search a two-step. `fzf` can:
|
||||
//! `--bind change:reload(…)` re-runs a command on every keystroke. So this
|
||||
//! front end re-executes *this binary* in `--feed` mode, and the daemon's own
|
||||
//! ranking ([`pdfs_core::control::Request::SearchV2`]) decides the order —
|
||||
//! `--disabled` turns fzf's matcher off so it does not re-sort what came back.
|
||||
//!
|
||||
//! Three roles, all the same binary:
|
||||
//!
|
||||
//! * **outer** — `--fzf` with no tty (a WM keybinding): spawn a terminal that
|
||||
//! runs the inner role, and exit.
|
||||
//! * **inner** — `--fzf` with a tty: run fzf, open what was chosen.
|
||||
//! * **feed** — `--feed <TEXT>`: print one line per hit and exit. fzf spawns
|
||||
//! this per keystroke and kills the one it superseded.
|
||||
//!
|
||||
//! A line is `display\tpayload`, shown through `--with-nth=1` so only the left
|
||||
//! half is visible. The payload is the JSON [`Hit`], which is what makes the
|
||||
//! selection unambiguous: unlike the dmenu path, identity does not have to be
|
||||
//! recovered from the label, so [`crate::dmenu::label_all`]'s uniquifying
|
||||
//! suffix has no counterpart here. JSON escapes tabs and newlines, so no file
|
||||
//! name can split a line.
|
||||
|
||||
use std::io::{BufRead, BufReader, IsTerminal, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::menu::{self, PromptConfig};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
use crate::query;
|
||||
use crate::{Hit, SEARCH_DEBOUNCE};
|
||||
|
||||
/// Separates the visible half of a feed line from its payload.
|
||||
const FIELD: char = '\t';
|
||||
|
||||
/// What the launcher prompt reads, matching the dmenu front end's wording.
|
||||
const PROMPT: &str = "Search Drive › ";
|
||||
|
||||
pub(crate) struct Options {
|
||||
/// Search for this immediately instead of opening on the pinned files.
|
||||
pub query: Option<String>,
|
||||
/// Set by the terminal we spawned: run fzf here even without a tty rather
|
||||
/// than spawning a second terminal, which would recurse forever if the
|
||||
/// terminal does not give its child one.
|
||||
pub inner: bool,
|
||||
}
|
||||
|
||||
/// Run the fzf flow. Returns an error only for conditions the user must act on
|
||||
/// (no fzf, no terminal, no daemon); a plain cancel is `Ok`.
|
||||
pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
if options.inner || std::io::stdin().is_terminal() {
|
||||
return interactive(options.query);
|
||||
}
|
||||
spawn_terminal(options.query)
|
||||
}
|
||||
|
||||
/// Re-launch this binary's inner role inside a terminal.
|
||||
///
|
||||
/// Not waited on: a keybinding should return immediately, and the terminal
|
||||
/// owns the rest of the interaction — including opening the chosen file, which
|
||||
/// `opener` detaches anyway.
|
||||
fn spawn_terminal(query: Option<String>) -> Result<(), String> {
|
||||
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||
let prompt: PromptConfig = dirs.load_config().resolved_prompt();
|
||||
let terminal = menu::resolve_terminal(prompt.terminal.as_ref()).ok_or_else(|| {
|
||||
"no terminal found — install foot/kitty/alacritty, or set prompt.terminal in config.json"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let argv = menu::with_command(&terminal, &inner_command(self_exe()?, query.as_deref()));
|
||||
let (program, args) = argv
|
||||
.split_first()
|
||||
.ok_or_else(|| "no terminal command configured".to_string())?;
|
||||
Command::new(program)
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("cannot run terminal {program}: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The argv the spawned terminal runs.
|
||||
fn inner_command(exe: PathBuf, query: Option<&str>) -> Vec<String> {
|
||||
let mut argv = vec![
|
||||
exe.display().to_string(),
|
||||
"--fzf".to_string(),
|
||||
"--inner".to_string(),
|
||||
];
|
||||
if let Some(text) = query.filter(|text| !text.trim().is_empty()) {
|
||||
argv.push("--query".to_string());
|
||||
argv.push(text.to_string());
|
||||
}
|
||||
argv
|
||||
}
|
||||
|
||||
/// Run fzf here and act on the choice.
|
||||
fn interactive(query: Option<String>) -> Result<(), String> {
|
||||
if !menu::on_path("fzf") {
|
||||
return Err("fzf is not installed — install it, or use --dmenu".to_string());
|
||||
}
|
||||
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||
let config = dirs.load_config();
|
||||
let prompt: PromptConfig = config.resolved_prompt();
|
||||
let policy: OpenWith = config.resolved_open_with();
|
||||
let socket = dirs.control_socket();
|
||||
|
||||
let query = query
|
||||
.map(|q| q.trim().to_string())
|
||||
.filter(|q| !q.is_empty());
|
||||
// fzf only fires `change` for a *keystroke*, so the first screen — pins, or
|
||||
// the results for an initial --query — has to arrive on stdin.
|
||||
let initial = hits(&socket, query.as_deref(), prompt.resolved_menu_limit());
|
||||
|
||||
let argv = fzf_argv(&self_exe()?, query.as_deref());
|
||||
let (program, args) = argv.split_first().expect("fzf_argv is never empty");
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("cannot run fzf: {e}"))?;
|
||||
|
||||
// Written on a worker for the same reason the dmenu path does it: fzf reads
|
||||
// stdin only once its UI is up, and a full pipe buffer would deadlock us.
|
||||
let payload: String = initial.iter().map(encode_line).collect();
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "fzf stdin unavailable".to_string())?;
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(payload.as_bytes());
|
||||
let _ = stdin.flush();
|
||||
});
|
||||
|
||||
let mut selected = String::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).unwrap_or(0) > 0 {
|
||||
if selected.is_empty() {
|
||||
selected = line.trim_end_matches('\n').to_string();
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
let _ = writer.join();
|
||||
|
||||
// Escape, or Enter on the "cannot reach the daemon" row, which carries no
|
||||
// payload — neither is an error.
|
||||
let Some(hit) = decode_line(&selected) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mountpoint = query::mountpoint(&socket, &dirs);
|
||||
// Only the no-query screen is the pin list, and only there does a row's
|
||||
// `is_dir` mean pin policy rather than node kind.
|
||||
query::open(&socket, &mountpoint, &policy, &hit, query.is_none())
|
||||
}
|
||||
|
||||
/// `--feed`: one round-trip, printed for fzf to swallow.
|
||||
///
|
||||
/// Always succeeds. A reload command that exits non-zero leaves fzf showing an
|
||||
/// empty list with no explanation, so a failure becomes a visible row instead.
|
||||
pub(crate) fn feed(query: Option<&str>) {
|
||||
let Ok(dirs) = AppDirs::new() else {
|
||||
println!("(cannot resolve app dirs)");
|
||||
return;
|
||||
};
|
||||
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
|
||||
let socket = dirs.control_socket();
|
||||
let query = query.map(str::trim).filter(|text| !text.is_empty());
|
||||
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
for line in hits(&socket, query, limit).iter().map(encode_line) {
|
||||
let _ = out.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows for one screen: pins when there is nothing typed, search results
|
||||
/// otherwise — the same split the GTK prompt makes on an empty entry.
|
||||
fn hits(socket: &std::path::Path, query: Option<&str>, limit: usize) -> Vec<Hit> {
|
||||
let result = match query {
|
||||
Some(text) => query::search(socket, text, limit),
|
||||
None => query::pins(socket),
|
||||
};
|
||||
match result {
|
||||
Ok(hits) => hits,
|
||||
Err(message) => {
|
||||
// Printed as a row rather than to stderr: fzf's alternate screen
|
||||
// hides stderr, so this is the only place the user would see it.
|
||||
println!("({message})");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
|
||||
let feed = format!("{} --feed {{q}}", shell_quote(&exe.display().to_string()));
|
||||
// The sleep is the debounce: fzf kills a reload child that a later keystroke
|
||||
// superseded, so a burst of typing costs one daemon round-trip, exactly as
|
||||
// the GTK prompt's timeout does.
|
||||
let debounce = SEARCH_DEBOUNCE.as_secs_f32();
|
||||
let mut argv: Vec<String> = [
|
||||
"fzf",
|
||||
// fzf's own fuzzy matcher would re-order and re-filter what the daemon
|
||||
// already ranked, and would hide rows it does not think match.
|
||||
"--disabled",
|
||||
"--no-sort",
|
||||
"--no-multi",
|
||||
"--layout=reverse",
|
||||
"--info=inline",
|
||||
"--delimiter=\t",
|
||||
"--with-nth=1",
|
||||
]
|
||||
.iter()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect();
|
||||
argv.push(format!("--prompt={PROMPT}"));
|
||||
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the
|
||||
// end of the argument, so an install path containing a bracket cannot
|
||||
// truncate the command fzf parses out of it.
|
||||
argv.push(format!("--bind=change:reload:sleep {debounce}; {feed}"));
|
||||
if let Some(text) = query {
|
||||
argv.push(format!("--query={text}"));
|
||||
}
|
||||
argv
|
||||
}
|
||||
|
||||
/// `display\tpayload\n`. The display half is scrubbed of the delimiter and of
|
||||
/// newlines; the payload is JSON, which escapes both.
|
||||
fn encode_line(hit: &Hit) -> String {
|
||||
let display =
|
||||
format!("{} · {}", hit.name(), hit.location()).replace(['\n', '\r', FIELD], " ");
|
||||
let payload = serde_json::to_string(hit).unwrap_or_default();
|
||||
format!("{display}{FIELD}{payload}\n")
|
||||
}
|
||||
|
||||
/// The hit a selected line stands for, or `None` for a line that carries no
|
||||
/// payload — the error/empty row, or an empty selection (Escape).
|
||||
fn decode_line(line: &str) -> Option<Hit> {
|
||||
let (_, payload) = line.trim_end_matches('\n').rsplit_once(FIELD)?;
|
||||
serde_json::from_str(payload).ok()
|
||||
}
|
||||
|
||||
fn self_exe() -> Result<PathBuf, String> {
|
||||
std::env::current_exe().map_err(|e| format!("cannot locate pdfs-prompt: {e}"))
|
||||
}
|
||||
|
||||
/// POSIX single-quoting, for the one string that goes through fzf's `sh -c`.
|
||||
fn shell_quote(text: &str) -> String {
|
||||
format!("'{}'", text.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::{LocalHit, SearchHit};
|
||||
|
||||
fn drive(name: &str, path: &str) -> Hit {
|
||||
Hit::Drive(SearchHit {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
is_dir: false,
|
||||
size: 7,
|
||||
modified: 9,
|
||||
pinned: false,
|
||||
uid: "uid~1".into(),
|
||||
mounted_path: None,
|
||||
score: 42,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_round_trips_through_the_payload() {
|
||||
let hit = drive("notes.md", "Docs/notes.md");
|
||||
let line = encode_line(&hit);
|
||||
assert!(line.ends_with('\n'));
|
||||
let back = decode_line(&line).expect("payload decodes");
|
||||
assert_eq!(back.name(), "notes.md");
|
||||
assert_eq!(back.key(), hit.key());
|
||||
assert_eq!(back.score(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_hit_round_trips_as_itself() {
|
||||
let hit = Hit::Local(LocalHit {
|
||||
name: "notes.md".into(),
|
||||
path: "/home/me/Docs/notes.md".into(),
|
||||
is_dir: false,
|
||||
size: 1,
|
||||
modified: 2,
|
||||
score: 3,
|
||||
});
|
||||
let back = decode_line(&encode_line(&hit)).expect("payload decodes");
|
||||
assert!(matches!(back, Hit::Local(_)));
|
||||
assert_eq!(back.key(), hit.key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_a_tab_or_newline_stays_one_line() {
|
||||
let hit = drive("two\tparts\nfile.md", "Docs/two\tparts\nfile.md");
|
||||
let line = encode_line(&hit);
|
||||
assert_eq!(line.matches('\n').count(), 1, "{line:?}");
|
||||
assert_eq!(line.matches(FIELD).count(), 1, "{line:?}");
|
||||
assert_eq!(
|
||||
decode_line(&line).expect("payload decodes").name(),
|
||||
"two\tparts\nfile.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_without_a_payload_is_not_a_hit() {
|
||||
assert!(decode_line("").is_none());
|
||||
assert!(decode_line("(cannot reach the Proton Drive daemon)").is_none());
|
||||
assert!(decode_line("bogus\tnot json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fzf_is_told_not_to_do_its_own_matching() {
|
||||
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), None);
|
||||
assert!(argv.contains(&"--disabled".to_string()), "{argv:?}");
|
||||
assert!(argv.contains(&"--no-sort".to_string()), "{argv:?}");
|
||||
assert!(
|
||||
argv.iter().any(|arg| arg.starts_with("--with-nth=")),
|
||||
"the payload field must stay hidden: {argv:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_keystroke_re_runs_the_feed_after_the_debounce() {
|
||||
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), Some("md"));
|
||||
let bind = argv
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("--bind=change:"))
|
||||
.expect("a change binding");
|
||||
assert!(bind.contains("reload"), "{bind}");
|
||||
assert!(bind.contains("--feed {q}"), "{bind}");
|
||||
assert!(bind.contains(&format!("sleep {}", SEARCH_DEBOUNCE.as_secs_f32())));
|
||||
assert!(argv.contains(&"--query=md".to_string()), "{argv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_a_quote_cannot_break_out_of_the_reload_command() {
|
||||
let argv = fzf_argv(std::path::Path::new("/home/o'brien/my prompt"), None);
|
||||
let bind = argv
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("--bind=change:"))
|
||||
.expect("a change binding");
|
||||
assert!(bind.contains(r"'/home/o'\''brien/my prompt'"), "{bind}");
|
||||
// The unparenthesised reload form: a bracket in the path must not end
|
||||
// the command as far as fzf's own parser is concerned.
|
||||
assert!(bind.starts_with("--bind=change:reload:"), "{bind}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terminal_runs_the_inner_role_so_it_cannot_recurse() {
|
||||
let argv = inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some("md"));
|
||||
assert_eq!(
|
||||
argv,
|
||||
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner", "--query", "md"]
|
||||
);
|
||||
assert_eq!(
|
||||
inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some(" ")),
|
||||
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner"],
|
||||
"a blank query is no query"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ksni::blocking::TrayMethods;
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray, TrayService};
|
||||
use ksni::{MenuItem, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
@@ -282,13 +283,13 @@ fn main() -> Result<()> {
|
||||
state: poll_state(&socket, &default_mountpoint),
|
||||
};
|
||||
|
||||
let service = TrayService::new(tray);
|
||||
let handle = service.handle();
|
||||
std::thread::spawn(move || {
|
||||
if let Err(e) = service.run() {
|
||||
tracing::error!("tray service stopped: {e:?}");
|
||||
}
|
||||
});
|
||||
// The tray autostarts with the session and can beat the desktop's SNI
|
||||
// watcher onto the bus. Without `assume_sni_available` that race is a hard
|
||||
// spawn error; with it the item waits and registers once the watcher shows.
|
||||
let handle = tray
|
||||
.assume_sni_available(true)
|
||||
.spawn()
|
||||
.context("start tray service")?;
|
||||
|
||||
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
|
||||
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
||||
|
||||
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
mod activation;
|
||||
mod dmenu;
|
||||
mod fzf;
|
||||
mod query;
|
||||
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||
@@ -125,7 +127,7 @@ fn is_media(name: &str) -> bool {
|
||||
/// One row in the unified result list. The two sections hold different payloads
|
||||
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
|
||||
/// local file is already on disk — but they share one keyboard cursor.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
enum Hit {
|
||||
Drive(SearchHit),
|
||||
Local(LocalHit),
|
||||
@@ -341,12 +343,20 @@ Options:
|
||||
rofi, …) instead of the built-in GTK window. Also
|
||||
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
||||
in config.json.
|
||||
--fzf Search in fzf, in a terminal, re-querying the daemon on
|
||||
every keystroke — results appear as you type, unlike
|
||||
--dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }.
|
||||
--gtk Force the built-in window, overriding that setting.
|
||||
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||
\"prompt\": { \"menu\": [...] }.
|
||||
--query <TEXT> Search for TEXT immediately instead of opening on the
|
||||
pinned-files list (--dmenu only).
|
||||
pinned-files list (--dmenu and --fzf only).
|
||||
--feed <TEXT> Print the hits for TEXT, one per line, and exit. This is
|
||||
what --fzf re-runs on each keystroke; it is not meant to
|
||||
be typed.
|
||||
--inner Run fzf here rather than spawning a terminal. Set by the
|
||||
terminal --fzf spawns; not meant to be typed.
|
||||
-h, --help Show this help.
|
||||
";
|
||||
|
||||
@@ -356,6 +366,12 @@ struct Args {
|
||||
mode: Option<PromptMode>,
|
||||
menu: Option<Vec<String>>,
|
||||
query: Option<String>,
|
||||
/// `--feed`: print hits for this text and exit. `Some(None)` is `--feed`
|
||||
/// with an empty argument, which fzf sends for an empty input — a real
|
||||
/// request for the pinned list, not an absent flag.
|
||||
feed: Option<Option<String>>,
|
||||
/// `--inner`: we are the process a spawned terminal is running.
|
||||
inner: bool,
|
||||
}
|
||||
|
||||
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
@@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
mode: None,
|
||||
menu: None,
|
||||
query: None,
|
||||
feed: None,
|
||||
inner: false,
|
||||
};
|
||||
let mut argv = argv.peekable();
|
||||
while let Some(arg) = argv.next() {
|
||||
match arg.as_str() {
|
||||
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
||||
"--fzf" => args.mode = Some(PromptMode::Fzf),
|
||||
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
||||
"--inner" => args.inner = true,
|
||||
// fzf passes an empty argument for an empty input, so a missing
|
||||
// value here is that case rather than a user error.
|
||||
"--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())),
|
||||
// A launcher command is one shell-ish string so it can live in a
|
||||
// keybinding; splitting on whitespace is enough for flags, and a
|
||||
// launcher argument needing spaces belongs in config.json.
|
||||
@@ -416,16 +439,31 @@ fn main() -> glib::ExitCode {
|
||||
}
|
||||
};
|
||||
|
||||
// A feed is fzf's own reload child, not a front end: it must print hits and
|
||||
// nothing else, so it is answered before the stored mode is even read.
|
||||
if let Some(query) = args.feed {
|
||||
fzf::feed(query.as_deref());
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// The stored mode makes an existing keybinding switch front ends without
|
||||
// being re-bound; an explicit flag still wins.
|
||||
let stored = AppDirs::new()
|
||||
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
||||
.unwrap_or_default();
|
||||
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
|
||||
return match dmenu::run(dmenu::Options {
|
||||
let launcher = match args.mode.unwrap_or(stored) {
|
||||
PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
|
||||
menu: args.menu,
|
||||
query: args.query,
|
||||
}) {
|
||||
})),
|
||||
PromptMode::Fzf => Some(fzf::run(fzf::Options {
|
||||
query: args.query,
|
||||
inner: args.inner,
|
||||
})),
|
||||
PromptMode::Gtk => None,
|
||||
};
|
||||
if let Some(result) = launcher {
|
||||
return match result {
|
||||
Ok(()) => glib::ExitCode::SUCCESS,
|
||||
Err(message) => {
|
||||
eprintln!("pdfs-prompt: {message}");
|
||||
@@ -1326,7 +1364,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
if is_dir {
|
||||
return gio::ThemedIcon::new("folder").upcast();
|
||||
}
|
||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]);
|
||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
|
||||
gio::functions::content_type_get_icon(&content_type)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! The daemon side of both launcher front ends: ask for hits, open a chosen one.
|
||||
//!
|
||||
//! `--dmenu` and `--fzf` differ only in how they present a list and collect a
|
||||
//! choice. Everything between the control socket and the chosen file lives here
|
||||
//! so the two cannot drift on what a result set is or what "open" means — the
|
||||
//! same reason both already share [`Hit`] and [`rank_hits`] with the GTK prompt.
|
||||
//!
|
||||
//! Everything here is blocking and GTK-free: the callers are one-shot processes
|
||||
//! with no main loop to keep free.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||
};
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||
|
||||
/// Where the mount lives, for turning a Drive path into something an
|
||||
/// application can open. Falls back to the configured default so a momentarily
|
||||
/// unreachable daemon still produces a plausible path rather than an error.
|
||||
pub(crate) fn mountpoint(socket: &Path, dirs: &AppDirs) -> PathBuf {
|
||||
match request(socket, Request::Status) {
|
||||
Ok(Response::Status { mountpoint, .. }) => PathBuf::from(mountpoint),
|
||||
_ => dirs.default_mountpoint(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
||||
/// with, and the only listing the daemon offers without a query.
|
||||
pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(pins
|
||||
.into_iter()
|
||||
.map(|pin| {
|
||||
Hit::Drive(SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
||||
let reply = request(
|
||||
socket,
|
||||
Request::SearchV2 {
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
filters: SearchFilters {
|
||||
sources: vec![SearchSource::Drive, SearchSource::Local],
|
||||
kind: SearchKind::All,
|
||||
},
|
||||
},
|
||||
)?;
|
||||
let Response::SearchResultsV2 {
|
||||
drive_hits,
|
||||
local_hits,
|
||||
..
|
||||
} = reply
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
||||
hits.extend(local_hits.into_iter().map(Hit::Local));
|
||||
rank_hits(&mut hits);
|
||||
hits.truncate(limit);
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// Icon theme name for a hit.
|
||||
///
|
||||
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
||||
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
||||
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
||||
/// names below exist in every icon theme.
|
||||
pub(crate) fn icon_name(hit: &Hit) -> String {
|
||||
if hit.is_dir() {
|
||||
return "folder".to_string();
|
||||
}
|
||||
let name = hit.name();
|
||||
if is_image(name) {
|
||||
"image-x-generic"
|
||||
} else if is_media(name) {
|
||||
"video-x-generic"
|
||||
} else if is_document(name) {
|
||||
"text-x-generic"
|
||||
} else {
|
||||
"application-x-generic"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
||||
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
||||
/// else is materialised by the daemon first.
|
||||
pub(crate) fn open(
|
||||
socket: &Path,
|
||||
mountpoint: &Path,
|
||||
policy: &OpenWith,
|
||||
hit: &Hit,
|
||||
from_pins: bool,
|
||||
) -> Result<(), String> {
|
||||
match hit {
|
||||
Hit::Local(local) => {
|
||||
opener::open(policy, Path::new(&local.path), local.is_dir);
|
||||
Ok(())
|
||||
}
|
||||
Hit::Drive(drive) => {
|
||||
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
||||
// policy rather than node kind, so both go to the mount unchecked.
|
||||
let unconditional = from_pins
|
||||
|| matches!(
|
||||
drive_activation(&drive.name, drive.is_dir),
|
||||
DriveActivation::Folder | DriveActivation::MountedMedia
|
||||
);
|
||||
if unconditional {
|
||||
opener::open(
|
||||
policy,
|
||||
&mounted_or_relative(mountpoint, drive),
|
||||
drive.is_dir,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Anything else the mount exposes opens there too, so an editor
|
||||
// saves back into Drive instead of into a cache blob.
|
||||
if let Some(path) = mounted_target(mountpoint, drive) {
|
||||
opener::open(policy, &path, false);
|
||||
return Ok(());
|
||||
}
|
||||
match request(
|
||||
socket,
|
||||
Request::OpenFile {
|
||||
path: drive.path.clone(),
|
||||
uid: Some(drive.uid.clone()),
|
||||
},
|
||||
)? {
|
||||
Response::FilePath { path } => {
|
||||
// The reply is a content-hash blob; the rules key off the
|
||||
// Drive name, which is what the user actually opened.
|
||||
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
||||
Ok(())
|
||||
}
|
||||
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
||||
_ => Err("unexpected reply from the daemon".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
||||
/// terminal rather than a status page.
|
||||
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn drive(name: &str, is_dir: bool) -> Hit {
|
||||
Hit::Drive(SearchHit {
|
||||
name: name.into(),
|
||||
path: name.into(),
|
||||
is_dir,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_are_theme_names_the_launcher_can_look_up() {
|
||||
assert_eq!(icon_name(&drive("Photos", true)), "folder");
|
||||
assert_eq!(icon_name(&drive("a.png", false)), "image-x-generic");
|
||||
assert_eq!(icon_name(&drive("a.md", false)), "text-x-generic");
|
||||
assert_eq!(icon_name(&drive("a.bin", false)), "application-x-generic");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,79 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable
|
||||
|
||||
**Status:** Fixed (unverified) — the walker no longer stats a nested mountpoint. The stuck-daemon
|
||||
half cannot be fixed in userspace at all; see "Why there is no second line of defence".
|
||||
**Found:** 2026-09-16, from a user report of `systemctl --user restart` failing in a loop.
|
||||
**Where:** `crates/pdfs-core/src/localindex.rs` (`default_excludes`, `scan`),
|
||||
`crates/pdfs-fuse/src/background.rs` (`scan_local_once`).
|
||||
|
||||
**Symptom.** Stopping the service times out, SIGKILL does not help, and every subsequent start
|
||||
fails with `another Proton Drive daemon is already using .../cache.db`:
|
||||
|
||||
```
|
||||
systemd[2196]: proton-drive.service: State 'final-sigterm' timed out. Killing.
|
||||
systemd[2196]: proton-drive.service: Killing process 2221 (pdfs) with signal SIGKILL.
|
||||
systemd[2196]: proton-drive.service: Processes still around after final SIGKILL. Entering failed mode.
|
||||
systemd[2196]: proton-drive.service: Unit process 2221 (pdfs) remains running after unit stopped.
|
||||
pdfs[73921]: ERROR mount failed; retrying in 5s error="open cache db: another Proton Drive daemon
|
||||
is already using /home/narl/.local/state/proton-drive-linux/cache.db"
|
||||
```
|
||||
|
||||
**Diagnosis.** The surviving process is a zombie that still has a live thread:
|
||||
|
||||
```
|
||||
$ cat /proc/2221/status | grep -E 'State|Threads'
|
||||
State: Z (zombie)
|
||||
Threads: 2
|
||||
$ cat /proc/2221/task/73231/comm; cat /proc/2221/task/73231/stack
|
||||
pdfs-localindex
|
||||
request_wait_answer
|
||||
```
|
||||
|
||||
`pdfs-localindex` is parked in a FUSE request nobody will answer, in uninterruptible `D` state, so
|
||||
it ignores SIGKILL. The thread group leader exits, the thread does not, the process cannot be
|
||||
reaped — and it keeps the `cache.db` lock, which is what turns one stuck scan into a permanent
|
||||
restart loop. The same dead connection also blocked an unrelated `duf` in `fuse_statfs`
|
||||
(`/sys/fs/fuse/connections/69/waiting` was 2).
|
||||
|
||||
**Cause.** `scan_local_once` walks `$HOME`, and `default_excludes` only excluded the Drive
|
||||
mountpoint plus our own state and cache dirs. The reporter's home also held
|
||||
`~/remote/narl.io` (sshfs) and `~/remote/gdrive` (rclone) — third-party FUSE mounts the walk
|
||||
descends into.
|
||||
|
||||
`scan` does set `same_file_system(true)`, which looks like it should already cover this. It does
|
||||
not: in `ignore` 0.4.33 the check is `is_same_file_system` → `device_num(path)` →
|
||||
`path.metadata()` (`walk.rs:2164`), i.e. **a stat of the mountpoint**, performed in `run_one`
|
||||
*after* the directory has been queued. To discover that a mount is foreign, the walker first has
|
||||
to touch it. Against a network FUSE mount whose server is gone, that stat never returns.
|
||||
|
||||
**Fix.** `localindex::nested_mount_points` reads `/proc/self/mounts` and returns every mountpoint
|
||||
strictly below the scan root; `scan_local_once` appends those to the excludes. `filter_entry` runs
|
||||
before the device stat (`walk.rs:1926`, before `send`), so a path-only rejection keeps the walker
|
||||
from ever touching the mount. Mountpoint fields are octal-unescaped, because a `\040` left in
|
||||
place would silently fail to match and put the mount back in the walk. Re-read per scan, not
|
||||
cached at startup: a mount that appeared since the daemon started is the likeliest to be a
|
||||
half-alive network filesystem.
|
||||
|
||||
This changes nothing about what gets indexed. A nested mount is a different device, so
|
||||
`same_file_system(true)` was always going to skip it — the exclude only removes the stat that had
|
||||
to happen first.
|
||||
|
||||
**Why there is no second line of defence.** Once a thread is in uninterruptible FUSE wait, no
|
||||
userspace change rescues it: not a join timeout, not `std::process::exit`, not SIGKILL. The kernel
|
||||
will not reap the task until the syscall returns, which needs someone to answer the request or
|
||||
abort the connection (`echo 1 > /sys/fs/fuse/connections/<id>/abort`, root-only). Avoiding entry is
|
||||
the entire fix. That makes any *new* code path that stats a user-supplied or mount-crossing path
|
||||
from a daemon thread a repeat of this bug — mirror-folder scanning is the obvious next candidate.
|
||||
|
||||
**Recovery for an already-wedged daemon.** As root, abort the connection with waiters
|
||||
(`grep . /sys/fs/fuse/connections/*/waiting` finds it), which releases the thread, lets the zombie
|
||||
be reaped and frees the `cache.db` lock. A reboot also clears it.
|
||||
|
||||
---
|
||||
|
||||
## B89 — The recorded SDK dependency is three minor versions out of date in the working memory
|
||||
|
||||
**Status:** Open — documentation only, but it misdirects every reader of the SDK code.
|
||||
@@ -2650,6 +2723,16 @@ collisions without losing the source. Simple rename-only and move-only calls
|
||||
remain synchronous. The managed live suite subsequently passed the replacement
|
||||
and combined move/rename cases.
|
||||
|
||||
**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the
|
||||
move and the rename as one `move-multiple` request carrying the target name,
|
||||
instead of renaming in the source folder and then moving. That removes the
|
||||
second call the stale requirements could hit, and the SDK retries a remaining
|
||||
`InvalidRequirements` once with the server's current name hash. A destination
|
||||
name collision now moves under a conflict name in one step. Same-folder renames
|
||||
are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames
|
||||
then moves with its bounded retry and has not been converted. Needs a managed
|
||||
live run of the combined move/rename cases.
|
||||
|
||||
---
|
||||
|
||||
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
|
||||
|
||||
@@ -9,6 +9,218 @@ release ships. Migrations are forward-only — a database written by a newer cli
|
||||
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [1.10.2] — 2026-09-16
|
||||
|
||||
Fixes a daemon that could not be stopped or restarted. No schema change.
|
||||
Schema: **28**; no SDK change.
|
||||
|
||||
### Fixed
|
||||
- **A dead network mount in your home directory no longer wedges the daemon (B90).** The
|
||||
"This computer" file indexer walked `$HOME` and descended into other FUSE mounts — an sshfs
|
||||
whose server had gone away, an unresponsive rclone. Stat'ing one of those blocks
|
||||
uninterruptibly, so the indexer thread stopped responding to SIGKILL, the daemon could never
|
||||
exit, `systemctl --user stop` timed out, and the half-dead process kept the cache database lock
|
||||
that the next start needed: every restart then failed with "another Proton Drive daemon is
|
||||
already using cache.db". The indexer now skips mountpoints below your home directory by path,
|
||||
without touching them. Nothing changes about which files are indexed — those mounts were always
|
||||
excluded from the results, just not before they had been stat'ed.
|
||||
|
||||
## [1.10.1] — 2026-09-15
|
||||
|
||||
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
|
||||
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
|
||||
|
||||
### Security
|
||||
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
|
||||
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
|
||||
`dbus-codegen` → `clap` 2 → `atty`.
|
||||
|
||||
### Changed
|
||||
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
|
||||
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
|
||||
found, so no re-login is needed.
|
||||
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
|
||||
the desktop's tray host onto the session bus.
|
||||
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
|
||||
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
|
||||
|
||||
## [1.10.0] — 2026-09-15
|
||||
|
||||
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
|
||||
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
|
||||
|
||||
### Added
|
||||
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
|
||||
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
|
||||
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
|
||||
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
|
||||
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
|
||||
|
||||
### Changed
|
||||
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
|
||||
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
|
||||
now calls `move-multiple` with a target name, so there is no half-applied state between the two
|
||||
halves and no second call for the encrypted-name requirements to go stale against (B46). A
|
||||
rename that stays in the same folder still uses the rename endpoint.
|
||||
|
||||
### Fixed
|
||||
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
|
||||
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
|
||||
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
|
||||
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
|
||||
that is already in the trash.
|
||||
|
||||
## [1.9.3] — 2026-09-01
|
||||
|
||||
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
|
||||
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||
|
||||
### Fixed
|
||||
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
|
||||
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
|
||||
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
|
||||
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
|
||||
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
|
||||
of identical lines (`#21`).
|
||||
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
|
||||
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
|
||||
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
|
||||
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
|
||||
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
|
||||
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
|
||||
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
|
||||
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
|
||||
|
||||
### Changed
|
||||
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
|
||||
so the schedule is testable without a keyring and without real time passing.
|
||||
|
||||
## [1.9.2] — 2026-08-28
|
||||
|
||||
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
|
||||
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
|
||||
`proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||
|
||||
### Added
|
||||
- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose
|
||||
metadata sidecars did not match imports with no capture time at all, and the SDK then seals
|
||||
"now" into every revision — a decade of photos lands on one date. The date is still in the file
|
||||
name, so this finds photos whose name disagrees with their stored capture time by more than a
|
||||
day and rewrites them: download, re-upload at the right time, carry favourites and album
|
||||
memberships across, then **trash** (never delete) the original — Proton has no API for editing a
|
||||
sealed capture time. `--dry-run` reports without touching anything, `--from`/`--to` aim the run
|
||||
at the days an import landed on, and `--limit` caps it. Followed with `pdfs redate-status`,
|
||||
stopped with `pdfs cancel-redate`.
|
||||
|
||||
- **`pdfs-prompt --fzf`: launcher search that updates as you type.** A dmenu-style launcher
|
||||
(fuzzel, rofi, wofi, …) is handed one static list and filters it itself, which is why `--dmenu`
|
||||
has to make searching two steps — type, Enter, *then* results. `fzf` can re-run a command on
|
||||
every keystroke, so the new mode re-queries the daemon per keystroke (debounced to match the GTK
|
||||
prompt) and keeps `--disabled` on so the daemon's ranking, not fzf's matcher, decides the order.
|
||||
Abbreviation and typo tolerance therefore work here exactly as they do in the built-in window.
|
||||
- Started without a tty — from a WM keybinding — `--fzf` spawns a terminal for itself (foot,
|
||||
ghostty, kitty, alacritty, wezterm or xterm, whichever is installed), each with the app-id/class
|
||||
`pdfs-prompt` so a single window rule floats it. Override with `"prompt": { "terminal": [...] }`,
|
||||
where a `{cmd}` token places the command and its absence appends it. `"prompt": { "mode": "fzf" }`
|
||||
makes it the default front end for an existing keybinding.
|
||||
|
||||
### Fixed
|
||||
- **Google Photos Takeout: sidecars with a localized suffix are matched again.** Google localizes
|
||||
the `.supplemental-metadata` it appends to a photo's JSON sidecar — a German export writes
|
||||
`.ergänzende-Metadaten` — and the scanner only knew the English spelling. No sidecar matched, so
|
||||
no photo had a capture time, so every photo in the export was stamped with the moment it was
|
||||
uploaded. Matching now hangs on the photo name in front of the suffix rather than on the suffix
|
||||
itself, which is locale-agnostic, and covers the misspelt variants Google has also shipped.
|
||||
- **A photo with no usable sidecar now takes its date from its file name.** `PXL_20260818_171030868.jpg`,
|
||||
`IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`, `IMG-20230219-WA0001.jpg` and
|
||||
`Screenshot from 2023-01-01 10-29-50.png` all carry the date the photo was taken. Read as UTC, so
|
||||
it can be hours out — but never the years out that "the moment it was imported" is. A sidecar
|
||||
still wins.
|
||||
|
||||
- **An opened file or folder no longer dies with the window that opened it.** Everything launched
|
||||
through `open_with` was started in the caller's process group with the caller's stdio, which was
|
||||
invisible from a resident GUI but fatal from `--fzf`: the prompt exits the moment you choose
|
||||
something, its terminal closes, and the pty hangup killed the application it had just started —
|
||||
so an `@dir` rule like `alacritty --working-directory {}` opened nothing at all. Opened children
|
||||
now get their own process group and no inherited stdio. Affects every front end; only `--fzf`
|
||||
could observe the bug.
|
||||
|
||||
### Changed
|
||||
- **The gallery virtualises at the row, not the day.** The Photos ListView had one item per day,
|
||||
and a bind built every one of that day's tiles — fine for a normal day, ruinous for the ~1,600
|
||||
photos a Takeout import drops onto a single date, which was 1,600 widgets constructed on the main
|
||||
thread before the section could be shown. Days are now flattened into heading rows and grid rows,
|
||||
so a bind is a handful of widgets whatever the day holds. A resize or a zoom step rebuilds the row
|
||||
model (the column count decides which photos share a row) and scrolls back to the photo that was
|
||||
at the top.
|
||||
- Updated to `proton-sdk` / `proton-drive-rs` 0.6.2 (photos save-to-timeline, `enumerate_events`).
|
||||
|
||||
- The daemon side of both launcher front ends — search, pins, mountpoint lookup and the
|
||||
open-a-result policy — moved out of `pdfs-gui/src/dmenu.rs` into a shared `pdfs-gui/src/query.rs`,
|
||||
so `--dmenu` and `--fzf` cannot drift on what a result set is or what "open" means. No behaviour
|
||||
change to `--dmenu`.
|
||||
|
||||
## [1.9.1] — 2026-08-21
|
||||
|
||||
Thumbnails everywhere the GUI lists files, a status bar under the Files browser, and the
|
||||
repository's contribution/branding files. No schema change. Schema: **28**; SDK unchanged at
|
||||
`proton-sdk` / `proton-drive-rs` **0.6.1**.
|
||||
|
||||
`exiftool` (`libimage-exiftool-perl` / `perl-image-exiftool` / `perl-Image-ExifTool`) is a new
|
||||
runtime dependency, declared in the `.deb`, `.rpm` and `PKGBUILD` packaging. It is only used to
|
||||
pull the embedded preview out of a camera RAW; without it every other image format still gets a
|
||||
thumbnail.
|
||||
|
||||
### Added
|
||||
- **Thumbnails for ordinary Drive files**, not just the Photos timeline — painted in Files (both
|
||||
the grid and the Name column of the list), Shared, Shared by me, and Trash. Standard raster
|
||||
formats (`bmp`, `gif`, `jpeg`, `png`, `tiff`, `webp`) are decoded in the daemon; camera RAWs
|
||||
(`arw`, `cr2`, `cr3`, `dng`, `nef`, `orf`, `raf`, `rw2`, and the rest of the concrete list) are
|
||||
served from their embedded preview via `exiftool`. Previews are cached beside the existing photo
|
||||
thumbnails, outside the cache-budget scan, and tagged with the node's modification time — so a
|
||||
new revision of an image can never be shown with its predecessor's thumbnail.
|
||||
- Thumbnails for visible rows are requested opportunistically as tiles scroll in, and the daemon
|
||||
drops work for listings the user has already navigated away from. The listing generation is
|
||||
issued by the daemon (`ReserveFileThumbGeneration`), not seeded from the front-end's clock.
|
||||
- **Build a whole folder tree ahead of time** from the Files toolbar: one recursive local-thumbnail
|
||||
job per daemon, with live scan/found/completed/failed progress and a Cancel that appears only
|
||||
once the daemon has acknowledged the start. The job outlives navigation — it is deliberate work,
|
||||
separate from the opportunistic per-tile requests.
|
||||
- **A Dolphin-style status bar under the Files browser**: the current listing's counts, a Zoom
|
||||
slider for the icon grid, and account storage usage. Zoom resizes the realised grid cells rather
|
||||
than rebuilding the model, so dragging the slider does not tear down the selection; the default
|
||||
size reproduces the previous fixed 72 px tiles. Capacity stays out of the bar until a real
|
||||
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of
|
||||
only in Settings.
|
||||
- Repository housekeeping: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull
|
||||
request templates, a project logo, and workspace crate metadata (`repository`, `homepage`,
|
||||
`readme`, `keywords`, `categories`).
|
||||
|
||||
### Fixed
|
||||
- A remote thumbnail miss no longer suppresses local generation. The two negative results are now
|
||||
held in separate bounded caches: "Proton has no thumbnail for this node" and "this exact revision
|
||||
was decoded locally and could not be read" mean different things, and only the second is a
|
||||
permanent verdict for that revision.
|
||||
- A RAW whose preview could not be extracted because of the *environment* — `exiftool` missing, a
|
||||
failed download — is retried instead of being remembered as undecodable. Only bytes conclusively
|
||||
outside the supported formats are cached as a negative.
|
||||
- A recursive build that finds a uid an opportunistic tile job already owns waits briefly for it
|
||||
rather than either duplicating the work or blocking on it forever, and a cancellation racing a
|
||||
waiter's registration is caught by a backstop instead of leaving the waiter parked.
|
||||
- Starting a build for a root that is already building attaches to that job; starting one for a
|
||||
*different* root is refused explicitly rather than silently reporting the running job's progress
|
||||
as the caller's.
|
||||
- Decrypted camera RAWs are staged for `exiftool` in an owner-only (`0700`) directory of their own,
|
||||
removed as soon as the preview is out and swept at cache open after an unclean shutdown.
|
||||
- The RAW extension list had `rw` (not a format) and was missing `raw`; both classification helpers
|
||||
now read from one shared table instead of two hand-maintained `matches!` arms.
|
||||
- The tray and desktop-entry icons were wrong/missing; `.desktop` files and the packaged SVG were
|
||||
corrected.
|
||||
|
||||
## [1.9.0] — 2026-08-18
|
||||
|
||||
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
|
||||
@@ -501,6 +713,10 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
|
||||
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
|
||||
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
|
||||
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
|
||||
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
||||
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
||||
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
||||
|
||||
+4
-2
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.9.1
|
||||
pkgver=1.10.2
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
@@ -20,7 +20,9 @@ makedepends=('cargo' 'git')
|
||||
provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'xdg-utils: "Open folder" action from the tray')
|
||||
'xdg-utils: "Open folder" action from the tray'
|
||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||
options=('!lto' '!debug')
|
||||
|
||||
# Built in place from the checkout; nothing to fetch or checksum.
|
||||
|
||||
@@ -41,6 +41,10 @@ Recommends: gnome-keyring
|
||||
Recommends: gnome-shell-extension-appindicator
|
||||
Recommends: kwallet
|
||||
|
||||
# Only for `pdfs-prompt --fzf`; every other front end works without them.
|
||||
Suggests: fzf
|
||||
Suggests: foot
|
||||
|
||||
Provides: pdfs = %{version}-%{release}
|
||||
|
||||
%description
|
||||
|
||||
Reference in new issue
Block a user