Compare commits

...
19 Commits
Author SHA1 Message Date
nvrl dcb2ab7fac chore(version) bumped to 1.10.0 2026-09-15 13:59:24 +02:00
nvrl eb56f4a1e0 feat(fuse,core,gui,cli): one-request move+rename drain, show who shared an item
- A queued rename that also changes folder now lands as one `move-multiple`
  request carrying the target name instead of rename-then-move, so there is
  no half-applied state and no second call for stale name requirements to
  hit (B46 follow-up). A destination name collision moves under a conflict
  name in one step; same-folder renames still use the rename endpoint.
- Shared with me shows the inviter and invite date (GUI subtitle, `pdfs
  shared-with-me`), with a warning when the invitation signature does not
  verify against the claimed inviter's keys. `DirEntry` gains `shared_by`,
  `shared_at` and `shared_by_unverified`, all serde-defaulted.
- Bump `proton-sdk` / `proton-drive-rs` to 0.6.3 (anonymous-node moves,
  `TrashTime` trashed detection).
2026-09-15 13:59:13 +02:00
nvrl c28803b037 chore(changelog) updated changelog for 1.9.2 and 1.9.3 2026-09-08 14:58:48 +02:00
nvrl cd8474954b chore(version) bumped to 1.9.3 2026-09-01 17:00:43 +02:00
nvrl d7e07d042d Merge pull request #22 from kacperpaczos/fix/login-poll-backoff
fix(cli): back off while waiting for a stored session
2026-09-01 16:38:29 +02:00
nvrl 45852082c0 fix(cli,core): wake the parked login-wait daemon on sign-in
The login backoff had no wake-up path. A unit started at boot sits in
wait_for_session and reaches the ceiling within ~90s; the user then signs
in and `enable --now` is a no-op on an already-active unit, so the daemon
kept sleeping and the mount appeared up to a full interval late.

- service::enable_start now runs `enable` + `restart` instead of
  `enable --now`, so the GUI login and the tray's Connect interrupt the
  sleep immediately and still start a unit that is down.
- `pdfs login` restarts the unit when it is already active. It stays out
  of `enable` on purpose, so a headless setup driving `pdfs daemon` by
  hand is left alone.
- Lower the backoff ceiling from 60s to 15s. It is still an 80% cut in
  keyring/D-Bus wakeups (28,800/day -> 5,760/day) and it bounds the
  worst-case latency for paths that never touch systemd.

Also corrects the flat-3s-poll figure in the doc comment: 86400/3 is
28,800 checks a day, not 48,000.
2026-09-01 16:28:25 +02:00
nvrl 99ad3838e1 fix(GALLERY) fixed takeout date conversion + repair command 2026-08-28 13:06:50 +02:00
Kacper Paczos 637b512f6a fix(cli): back off while waiting for a stored session
The daemon's wait-for-login loop slept a flat 3 seconds between checks and
never gave up, so an enabled service with no saved session polled the keyring
28,800 times a day. Each check is more than a sleep: auth::load() builds a
fresh keyring::Entry, so every iteration opens a new D-Bus connection to the
Secret Service and negotiates a session on it.

Back off from 3s to a 60s ceiling and say "not logged in" once at INFO rather
than on every iteration, which also stops the journal filling with tens of
thousands of identical lines.

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.

Refs #21
2026-08-25 13:24:46 +02:00
nvrl 16ad6548c8 chore(DOCS) updated changelog 2026-08-21 23:32:06 +02:00
nvrl 847ece17d7 version bumped to 1.9.1 2026-08-21 22:48:28 +02:00
nvrl 50d2acdaba Merge pull request #20 from emptyname-org/feature/files-thumbnails-statusbar
feat(gui): add file thumbnails and a browser status bar
2026-08-21 22:44:58 +02:00
emptyname 53caf63ad4 Fix thumbnail lifecycle failures 2026-08-21 12:32:31 +01:00
nvrl 5176dd91e1 fixed icon 2026-08-21 04:13:11 +02:00
nvrl e88903d5ff chore(branding) added branding, best practice to repo 2026-08-21 04:09:58 +02:00
emptyname b3e6af1ae5 docs: document thumbnail runtime support 2026-08-18 13:06:40 +01:00
emptyname b0f9513915 feat(gui): show file thumbnails and browser status bar 2026-08-18 13:06:36 +01:00
emptyname 91b0864fce feat(thumbnails): generate cached previews for Drive files 2026-08-18 13:06:27 +01:00
nvrl 8bd9481902 fix(ui) improved ux 2026-08-18 12:50:49 +02:00
nvrl b2d5870834 fix(ui) improved ux 2026-08-18 12:06:28 +02:00
67 changed files with 8975 additions and 629 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Bug report
description: Something in the client misbehaves
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
tracked there with context.
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
- type: textarea
id: what
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. …
2. …
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
validations:
required: true
- type: input
id: environment
attributes:
label: Distribution and desktop
placeholder: "Fedora 44, GNOME 48 (Wayland)"
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where does it happen
multiple: true
options:
- FUSE mount / file operations
- Sync or backed-up folders (Computers)
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Login / keyring
- Photos
- Packaging / installation
validations:
required: true
- type: textarea
id: diagnose
attributes:
label: "`pdfs diagnose` output"
description: Redact paths or the account address if you prefer.
render: console
- type: textarea
id: logs
attributes:
label: Daemon logs
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
render: console
- type: checkboxes
id: dataloss
attributes:
label: Data
options:
- label: This bug involved missing, truncated, or overwritten file content.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
about: Report privately — never in a public issue.
- name: Known issues
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
about: The authoritative ledger of tracked bugs and their status.
- name: Proton Drive service problems
url: https://proton.me/support
about: This is an unofficial client. Account or service issues go to Proton AG.
@@ -0,0 +1,37 @@
name: Feature request
description: Suggest a capability or an improvement
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: The problem
description: What are you trying to do that the client makes hard or impossible today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: What you'd like to see
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Which part of the client
multiple: true
options:
- FUSE mount
- Sync / Computers
- CLI (pdfs)
- Desktop app (pdfs-app)
- Tray (pdfs-tray)
- Search launcher (pdfs-prompt)
- Photos
- Packaging
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives you considered
+26
View File
@@ -0,0 +1,26 @@
## What this changes
<!-- The user-visible effect, in a sentence or two. -->
**Crates touched:**
**Related `docs/BUGS.md` entries:**
## Verification
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
```
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
## Implications
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
- [ ] Changes packaging, the systemd unit, or desktop entries
- [ ] Changes the control protocol (daemon and front ends updated together)
- [ ] None of the above
<!-- Screenshots for GTK changes. -->
+7 -6
View File
@@ -122,13 +122,14 @@ jobs:
Priority: optional Priority: optional
Architecture: amd64 Architecture: amd64
Maintainer: Nils Pukropp <contact@narl.io> Maintainer: Nils Pukropp <contact@narl.io>
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4 Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
Installed-Size: ${INSTALLED_SIZE} Installed-Size: ${INSTALLED_SIZE}
Description: Proton Drive client for Linux Homepage: https://github.com/narrrl/proton-drive-linux
Proton Drive client for Linux featuring: Description: Unofficial Proton Drive client for Linux
- FUSE files-on-demand mount Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
- Command-line interface (CLI) - FUSE virtual mount with block-level caching
- GTK4 system tray, desktop application, and search launcher - Scriptable command-line interface (pdfs)
- GTK4 desktop application, system tray, and search launcher
EOF EOF
# Build Debian package # Build Debian package
+32
View File
@@ -0,0 +1,32 @@
# Code of Conduct
## Our pledge
We want participation in this project to be a harassment-free experience for everyone, regardless
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socioeconomic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Standards
Examples of behavior that helps:
- being respectful of differing viewpoints and experience levels,
- giving and accepting constructive feedback gracefully,
- focusing on what is best for the project and its users.
Examples of unacceptable behavior:
- sexualized language or imagery, and unwelcome sexual attention,
- trolling, insulting or derogatory comments, and personal or political attacks,
- public or private harassment,
- publishing others' private information without explicit permission.
## Scope and enforcement
This applies in all project spaces — issues, pull requests, discussions — and when representing the
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+70
View File
@@ -0,0 +1,70 @@
# Contributing
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
deliberately high — everything else is ordinary Rust review.
## Before you start
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
together.
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
already be tracked there with context.
- For anything larger than a fix, open an issue first so the design can be discussed before you
spend the time.
## Development setup
```bash
git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux
cargo build --workspace
```
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
version is 1.96.
## Quality gates
CI runs exactly these, and they must pass with zero warnings:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --locked
scripts/fuse-acceptance.sh --offline-only
```
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
use a dedicated test account, never one holding data you cannot lose.
## Making changes
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
they speak the control protocol to the daemon.
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
control-socket work stays off GTK's main thread.
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
them opportunistically.
## Commits and pull requests
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
`fix(fuse,core): keep truncate composable with queued revisions`.
A pull request should state:
- the user-visible effect and which crates it touches,
- the `docs/BUGS.md` entries it fixes or affects,
- the commands you ran to verify it,
- screenshots for GTK changes,
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
## Security
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
Generated
+46 -8
View File
@@ -3538,7 +3538,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "1.8.2" version = "1.10.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap 4.6.4", "clap 4.6.4",
@@ -3553,7 +3553,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "1.8.2" version = "1.10.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"directories", "directories",
@@ -3566,16 +3566,19 @@ dependencies = [
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.10.7",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror 2.0.19", "thiserror 2.0.19",
"tokio", "tokio",
"tracing", "tracing",
"zip",
] ]
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "1.8.2" version = "1.10.0"
dependencies = [ dependencies = [
"base64",
"fuser", "fuser",
"futures", "futures",
"image", "image",
@@ -3585,13 +3588,15 @@ dependencies = [
"pdfs-core", "pdfs-core",
"proton-drive-rs", "proton-drive-rs",
"serde_json", "serde_json",
"sha1 0.10.7",
"tokio", "tokio",
"tracing", "tracing",
"zip",
] ]
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "1.8.2" version = "1.10.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3600,6 +3605,7 @@ dependencies = [
"ksni", "ksni",
"libadwaita", "libadwaita",
"pdfs-core", "pdfs-core",
"serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tracing", "tracing",
@@ -3852,9 +3858,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.6.0" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe0e39da5b61ceb862af2f4406870b9c1d9a1554889e8d40a32fff13882ec690" checksum = "11faa95cf40bd87d4d48435cdf9b16fff5f14caf45b3a565a941fd4a6e18cefe"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64", "base64",
@@ -3879,9 +3885,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.6.0" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97cde8cf8e3099d1dbb56302462a632a12e0ff818c012b9c9b0a7a5200544fd6" checksum = "7de99acbd776f7bad5e1e1f3c40d7df3e4ef7411984d02370008ba1ff270a047"
dependencies = [ dependencies = [
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
"async-trait", "async-trait",
@@ -5313,6 +5319,12 @@ dependencies = [
"cipher 0.4.4", "cipher 0.4.4",
] ]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -6254,6 +6266,20 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.6" version = "0.6.6"
@@ -6266,6 +6292,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"
+15 -3
View File
@@ -3,15 +3,20 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "1.8.2" version = "1.10.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96" rust-version = "1.96"
repository = "https://github.com/narrrl/proton-drive-linux"
homepage = "https://github.com/narrrl/proton-drive-linux"
readme = "README.md"
keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.6.0" } proton-sdk = { version = "0.6.3" }
proton-drive-rs = { version = "0.6.0" } proton-drive-rs = { version = "0.6.3" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -20,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not # Non-poisoning mutexes. A handler that panics while holding a lock must not
@@ -32,6 +38,12 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5" directories = "5"
ignore = "0.4" ignore = "0.4"
sha2 = "0.10" sha2 = "0.10"
# 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"
# Thumbnail generation for photos the server has none for. Decoders only for the # 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 # formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon. # would be dead weight in the daemon.
+122 -8
View File
@@ -1,17 +1,75 @@
# Proton Drive client for Linux (unofficial) <div align="center">
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration. <img src="images/logo.svg" alt="" width="112" height="112">
# Proton Drive for Linux
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
[![CI](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml/badge.svg)](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/narrrl/proton-drive-linux?sort=semver)](https://github.com/narrrl/proton-drive-linux/releases/latest)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](https://www.rust-lang.org)
[![Platform: Linux](https://img.shields.io/badge/platform-linux-lightgrey.svg)](#prerequisites)
<img src="images/files.png" alt="The GTK4 file browser" width="820">
</div>
> [!IMPORTANT]
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
> to describe what the software talks to. Use a test account first, and read
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
## Install
```bash
# Debian / Ubuntu
sudo apt install ./proton-drive-linux_*.deb
# Fedora
sudo dnf install ./proton-drive-linux-*.rpm
# Arch Linux
cd packaging && makepkg -fi
```
Packages for each release are attached to the [latest
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
[Installation & Packages](#installation--packages) for details and
[Building from Source](#building-from-source) to compile it yourself.
```bash
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
pdfs status # mount state, queued writes, cache usage
pdfs-app # the desktop app
```
## Contents
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
## Features ## Features
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes. - **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal. - **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop. - **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI. - **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account. - **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. - **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. - **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. - **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). - **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. - **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.
@@ -66,6 +124,51 @@ launcher command, set them in `config.json`:
anywhere in `menu` is replaced by the prompt text; without one, the launcher's anywhere in `menu` is replaced by the prompt text; without one, the launcher's
own prompt flag is appended. 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 ## Choosing How Files Open
By default every result is handed to `xdg-open`. An `open_with` block in By default every result is handed to `xdg-open`. An `open_with` block in
@@ -270,19 +373,21 @@ sudo apt-get install -y \
libadwaita-1-dev \ libadwaita-1-dev \
libwebkitgtk-6.0-dev \ libwebkitgtk-6.0-dev \
libsecret-1-dev \ libsecret-1-dev \
libdbus-1-dev libdbus-1-dev \
libimage-exiftool-perl
``` ```
### Arch Linux ### Arch Linux
```bash ```bash
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
``` ```
### Fedora (44+) ### Fedora (44+)
```bash ```bash
sudo dnf install -y \ sudo dnf install -y \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \ pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
perl-Image-ExifTool cargo rust
``` ```
Runtime extras (pick your desktop): Runtime extras (pick your desktop):
@@ -302,7 +407,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
1. Clone the repository and navigate into the project directory: 1. Clone the repository and navigate into the project directory:
```bash ```bash
git clone https://github.com/narl/proton-drive-linux.git git clone https://github.com/narrrl/proton-drive-linux.git
cd proton-drive-linux cd proton-drive-linux
``` ```
2. Build the workspace in release mode: 2. Build the workspace in release mode:
@@ -372,6 +477,15 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
--- ---
## Contributing
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
vulnerability privately.
## License ## License
This project is licensed under the [MIT License](LICENSE). Released under the [MIT License](LICENSE).
Proton Drive is a service of Proton AG. This project is an independent client and carries no
affiliation with or endorsement from Proton AG.
+34
View File
@@ -0,0 +1,34 @@
# Security Policy
## Supported versions
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
or by email to <contact@narl.io>.
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
days; a fix ships in the next release, and you are credited in the advisory unless you ask
otherwise.
## Scope
This client holds Proton account credentials and decrypted file content on the local machine, so
the interesting boundaries are:
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
those checks are in scope.
- **Local cache and staging.** Decrypted block content and staged writes on disk.
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
or the wrong key material to Proton.
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
require an attacker who already has your user account on the machine.
+2
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "CLI for the Proton Drive Linux client" description = "CLI for the Proton Drive Linux client"
[[bin]] [[bin]]
+477 -10
View File
@@ -17,6 +17,7 @@ use pdfs_core::control::{
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
}; };
use pdfs_core::db::Db; use pdfs_core::db::Db;
use pdfs_core::service;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
@@ -158,6 +159,65 @@ enum Command {
}, },
/// List the photo albums (including ones shared with this account). /// List the photo albums (including ones shared with this account).
Albums, Albums,
/// Import a Google Photos Takeout export into Proton Photos.
///
/// Pass every zip of the export at once — Google splits one export across
/// numbered parts, and a photo's metadata often sits in a different part
/// than the photo. Albums in the export are recreated as Proton albums, and
/// photos already on the account (same name and same content) are skipped,
/// so an interrupted import can simply be run again.
ImportGooglePhotos {
/// The export's `.zip` files (e.g. `takeout-*.zip`).
#[arg(required = true)]
archives: Vec<PathBuf>,
/// Report what would be imported without uploading anything.
#[arg(long)]
dry_run: bool,
/// Stay attached and print progress until the import finishes.
#[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.
CancelImport,
/// List the photos in one album (newest capture first). /// List the photos in one album (newest capture first).
Album { Album {
/// Album node uid in `volume~link` form (from `pdfs albums`). /// Album node uid in `volume~link` form (from `pdfs albums`).
@@ -589,6 +649,22 @@ fn main() -> Result<()> {
} => cmd_photos(limit, offset, favorites), } => cmd_photos(limit, offset, favorites),
Command::Favorite { uid, remove } => cmd_favorite(uid, remove), Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
Command::Albums => cmd_albums(), Command::Albums => cmd_albums(),
Command::ImportGooglePhotos {
archives,
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), Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
Command::OpenPhoto { uid } => cmd_open_photo(uid), Command::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search { query, limit } => cmd_search(query, limit), Command::Search { query, limit } => cmd_search(query, limit),
@@ -1053,6 +1129,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
format!(" ({})", e.role) format!(" ({})", e.role)
}; };
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid); 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)), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
@@ -1061,6 +1140,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
Ok(()) 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 /// 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 /// 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. /// gets a file it owns rather than one the cache may later evict.
@@ -1280,6 +1392,15 @@ fn cmd_login(username: Option<String>) -> Result<()> {
other => anyhow::Error::new(other).context("login failed"), 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."); println!("Logged in as {username}. Session stored in the system keyring.");
Ok(()) Ok(())
} }
@@ -1419,6 +1540,59 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
Ok(()) 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 /// 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; /// 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 /// an external unmount triggers a remount; errors back off and retry. This is
@@ -1427,16 +1601,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
loop { loop {
// Wait until a session is stored. The GUI enables this service on login, // 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. // but the service may also start at boot before the user has logged in.
loop { wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
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()),
}
}
match mount_once(mountpoint.clone()) { match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => { Ok(pdfs_fuse::MountOutcome::Shutdown) => {
@@ -1711,6 +1876,213 @@ fn cmd_albums() -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
// The daemon opens the archives itself off the shared filesystem, so it gets
// absolute paths — the same arrangement `pdfs upload` uses.
let mut absolute = Vec::with_capacity(archives.len());
for archive in &archives {
let path = std::fs::canonicalize(archive)
.with_context(|| format!("resolve {}", archive.display()))?;
absolute.push(
path.to_str()
.ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))?
.to_owned(),
);
}
match control_request(CtlRequest::ImportTakeout {
archives: absolute,
dry_run,
})? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
if !wait {
println!("Run `pdfs import-status` to follow it.");
return Ok(());
}
// Poll rather than stream: the control protocol is request/response, and an
// import runs for hours — a held-open socket would be the only long-lived
// connection the daemon has.
loop {
std::thread::sleep(std::time::Duration::from_secs(2));
match control_request(CtlRequest::ImportStatus)? {
CtlResponse::ImportStatus {
running: true,
summary: _,
} => continue,
CtlResponse::ImportStatus { summary, .. } => {
print_import_summary(summary.as_ref());
return Ok(());
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
}
}
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 } => {
if running {
println!("Import running. `pdfs transfers` shows its progress.");
}
print_import_summary(summary.as_ref());
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_cancel_import() -> Result<()> {
match control_request(CtlRequest::CancelImport)? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
let Some(summary) = summary else {
println!("No import has run yet.");
return;
};
println!("Photos in export : {}", summary.found);
println!("Uploaded : {}", summary.uploaded);
println!("Already on Proton: {}", summary.duplicates);
if summary.skipped_trashed > 0 {
println!("Skipped (trash) : {}", summary.skipped_trashed);
}
if summary.albums_created > 0 || summary.album_links > 0 {
println!(
"Albums : {} created, {} photos filed",
summary.albums_created, summary.album_links
);
}
if summary.failed > 0 {
println!("Failed : {}", summary.failed);
}
if summary.bytes > 0 {
println!("Uploaded bytes : {}", summary.bytes);
}
if summary.cancelled {
println!("Import was cancelled before it finished.");
}
}
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> { fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? { match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."), CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
@@ -2410,3 +2782,98 @@ mod diagnose_tests {
std::fs::remove_dir(dir).unwrap(); 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"));
}
}
+4
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Core auth/session/config for the Proton Drive Linux client" description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies] [dependencies]
@@ -18,6 +20,8 @@ tracing.workspace = true
directories.workspace = true directories.workspace = true
ignore.workspace = true ignore.workspace = true
sha2.workspace = true sha2.workspace = true
sha1.workspace = true
zip.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
keyring.workspace = true keyring.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
+25
View File
@@ -19,6 +19,7 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt; use std::fmt;
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -415,6 +416,10 @@ pub struct ContentCache {
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out /// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files. /// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf, thumb_dir: PathBuf,
/// Owner-protected scratch space for decrypted camera RAWs passed to
/// exiftool. Files are removed on drop and swept on the next cache open after
/// an unclean shutdown.
raw_thumb_dir: PathBuf,
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` + /// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never /// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget. /// sees them; blocks carry their own LRU budget.
@@ -507,6 +512,19 @@ impl ContentCache {
std::fs::create_dir_all(&content_dir)?; std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs"); let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?; std::fs::create_dir_all(&thumb_dir)?;
let raw_thumb_dir = content_dir.join("raw-thumbnails");
std::fs::create_dir_all(&raw_thumb_dir)?;
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("pdfs-raw-")
&& entry.file_type().is_ok_and(|kind| kind.is_file())
{
let _ = std::fs::remove_file(entry.path());
}
}
}
let block_dir = content_dir.join("blocks"); let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?; std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are // Scratch holds disk-backed write buffers. A previous run's leftovers are
@@ -530,6 +548,7 @@ impl ContentCache {
let cache = Self { let cache = Self {
content_dir, content_dir,
thumb_dir, thumb_dir,
raw_thumb_dir,
block_dir, block_dir,
scratch_dir, scratch_dir,
staging_dir, staging_dir,
@@ -1666,6 +1685,12 @@ impl ContentCache {
.join(format!("{}.t{ttype}.meta", Self::key(uid))) .join(format!("{}.t{ttype}.meta", Self::key(uid)))
} }
/// Owner-protected transient directory used to stage decrypted camera RAWs
/// for metadata helpers. Callers still remove individual files promptly.
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
&self.raw_thumb_dir
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale. /// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
/// Validated by node `mtime` alone: a new revision (which is what changes the /// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails /// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
+344 -1
View File
@@ -93,6 +93,32 @@ pub enum Request {
/// already cached (one batched round-trip) and replying with their on-disk /// already cached (one batched round-trip) and replying with their on-disk
/// paths. Keep the batch small — it is served on demand, as tiles scroll in. /// paths. Keep the batch small — it is served on demand, as tiles scroll in.
PhotoThumbs { uids: Vec<String> }, PhotoThumbs { uids: Vec<String> },
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
/// timeline. The modification time is the cache validity tag, so replacing
/// an image can never reuse its previous revision's thumbnail.
FileThumbs {
items: Vec<FileThumbRequest>,
/// Identifies the front-end's current listing. Moving to another page or
/// folder advances it, allowing the daemon to stop work for rows that
/// are no longer visible.
#[serde(default)]
generation: u64,
},
/// Reserve a daemon-issued generation for ordinary-file thumbnail work.
/// The daemon is the ordering authority, so GUI restarts and wall-clock
/// corrections cannot make a new listing look older than an earlier one.
ReserveFileThumbGeneration,
/// Stop ordinary-file thumbnail work belonging to older listings. Photos
/// timeline work and an explicit recursive build are deliberately separate.
CancelFileThumbs { generation: u64 },
/// Start building local thumbnails for every supported image below `path`.
/// The request returns immediately; progress is read with
/// [`Request::ThumbnailBuildStatus`].
StartThumbnailBuild { path: String },
/// Stop the current recursive ordinary-file thumbnail build, if any.
CancelThumbnailBuild,
/// Read progress for the recursive ordinary-file thumbnail build.
ThumbnailBuildStatus,
/// Download a photo's full content into the cache; replies with its path. /// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String }, OpenPhoto { uid: String },
/// Add or remove Proton's `Favorite` tag on a photo. Replies with /// Add or remove Proton's `Favorite` tag on a photo. Replies with
@@ -103,6 +129,70 @@ pub enum Request {
/// re-encrypted for our timeline, which the SDK does not implement yet; the /// re-encrypted for our timeline, which the SDK does not implement yet; the
/// daemon reports that as an error rather than silently doing nothing. /// daemon reports that as an error rather than silently doing nothing.
SetPhotoFavorite { uid: String, favorite: bool }, SetPhotoFavorite { uid: String, favorite: bool },
/// Import Google Photos **Takeout** archives into Proton Photos.
///
/// `archives` are absolute paths to the export's `.zip` parts on the
/// daemon's own filesystem — the whole set at once, because a photo and its
/// metadata sidecar routinely land in different parts. Photos already on the
/// account (matched by name *and* content digest) are skipped, so a library
/// partly synced by Proton Photos elsewhere does not import twice, and an
/// interrupted import resumes by simply being run again.
///
/// With `dry_run` the daemon scans and reports what it *would* do without
/// uploading anything. Otherwise it acks immediately with
/// [`Response::Ok`] and works in the background — an export is hours of
/// upload, far past any socket timeout. Progress shows up in
/// [`Request::GetQueueStatus`] as a job, and the final counts in
/// [`Request::ImportStatus`].
ImportTakeout {
archives: Vec<String>,
#[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,
/// Ask the running Takeout import to stop. It finishes the photo on the wire
/// and files what it has uploaded into its albums, then reports
/// `cancelled`. Replies with [`Response::Ok`].
CancelImport,
/// Upload the photo at `source_path` under the given name and media type. /// Upload the photo at `source_path` under the given name and media type.
/// ///
/// A path, not the bytes: this protocol is line-delimited JSON, and /// A path, not the bytes: this protocol is line-delimited JSON, and
@@ -652,6 +742,10 @@ pub struct SharedItem {
/// The shared node's decrypted name. /// The shared node's decrypted name.
pub name: String, pub name: String,
pub is_dir: bool, pub is_dir: bool,
/// Modification time used to validate an image thumbnail. Older daemons
/// omit it, in which case clients use `0` as a conservative fallback tag.
#[serde(default)]
pub modified: i64,
/// Mountpoint-relative path, when the daemon can resolve it (the node lives in /// Mountpoint-relative path, when the daemon can resolve it (the node lives in
/// my own tree). Empty when the path is unknown. /// my own tree). Empty when the path is unknown.
#[serde(default)] #[serde(default)]
@@ -812,6 +906,22 @@ pub struct DirEntry {
/// report. Defaulted for wire-compat with clients/daemons predating it. /// report. Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)] #[serde(default)]
pub role: String, 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 /// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
@@ -1052,6 +1162,64 @@ pub struct PhotoMonth {
pub count: usize, 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`]).
///
/// The counts are cumulative for the run, so the same shape serves progress and
/// the final report. `found` counts *distinct* photos: Google stores a separate
/// copy of a photo in each album folder it appears in, and those fold into one
/// upload that joins several albums.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct ImportSummary {
/// Distinct photos the export holds.
pub found: usize,
/// Photos uploaded by this run.
pub uploaded: usize,
/// Photos already on the account, matched by name *and* content digest.
pub duplicates: usize,
/// Photos that could not be read or uploaded.
pub failed: usize,
/// Photos in the export's trash bucket, which are never imported.
pub skipped_trashed: usize,
/// Albums created by this run.
pub albums_created: usize,
/// Album memberships added by this run.
pub album_links: usize,
/// Bytes uploaded.
pub bytes: u64,
/// True when the run stopped early because it was cancelled.
pub cancelled: bool,
}
/// One album in a [`Response::Albums`] listing. /// One album in a [`Response::Albums`] listing.
/// ///
/// An album is a folder node on the photos volume carrying album properties, so /// An album is a folder node on the photos volume carrying album properties, so
@@ -1131,6 +1299,76 @@ pub struct PhotoThumb {
pub pending: bool, pub pending: bool,
} }
/// One ordinary Drive file whose thumbnail a front-end wants to paint.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FileThumbRequest {
/// Node uid in `volume~link` form.
pub uid: String,
/// File modification time, used as the thumbnail cache validity tag.
pub modified: i64,
/// Original Drive name. Local RAW-preview extraction uses the extension as a
/// format hint; defaulted for wire compatibility with older front-ends.
#[serde(default)]
pub name: String,
}
/// Whether a Drive name is a raster image that the built-in decoder can read,
/// or a concrete camera RAW format whose embedded preview exiftool can extract.
/// Keep this list shared by the daemon and every GUI surface so a visible tile
/// is never advertised without a matching generation path.
pub fn is_thumbnail_image_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else {
return false;
};
let extension = extension.to_ascii_lowercase();
STANDARD_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
}
/// Whether `name` is one of the concrete RAW formats handled by the exiftool
/// embedded-preview path. Deliberately excludes generic/legacy grab-bags whose
/// preview availability is not dependable.
pub fn is_raw_image_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else {
return false;
};
let extension = extension.to_ascii_lowercase();
RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
}
const STANDARD_THUMBNAIL_EXTENSIONS: &[&str] =
&["bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "webp"];
/// Concrete camera RAW formats with embedded previews handled by exiftool.
/// This is the single source used by both RAW classification and the broader
/// thumbnail capability check.
const RAW_THUMBNAIL_EXTENSIONS: &[&str] = &[
"arw", "cr2", "cr3", "crw", "dng", "k25", "kdc", "mrw", "nef", "nrw", "orf", "pef", "raf",
"raw", "rw2", "sr2", "srf", "x3f",
];
/// Progress of the one recursive ordinary-file thumbnail build the daemon may
/// run at a time.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct ThumbnailBuildStatus {
pub running: bool,
/// True while folders are still being discovered, before the final image
/// count is known and the progress bar can become determinate.
pub scanning: bool,
/// Mountpoint-relative root selected when the build started.
pub path: String,
pub folders_scanned: u64,
pub images_found: u64,
/// Images already cached or processed during this build. Includes failures,
/// so `completed == images_found` still means the job has finished.
pub completed: u64,
/// Images that could not be downloaded or decoded.
pub failed: u64,
/// A traversal-level problem, such as a folder that could not be listed.
#[serde(default)]
pub message: Option<String>,
}
/// A daemon too old to report connectivity is assumed online — it could not /// A daemon too old to report connectivity is assumed online — it could not
/// have mounted at all otherwise. /// have mounted at all otherwise.
fn default_online() -> bool { fn default_online() -> bool {
@@ -1270,11 +1508,36 @@ pub enum Response {
available: bool, available: bool,
items: Vec<AlbumInfo>, items: Vec<AlbumInfo>,
}, },
/// How the Takeout import is doing (reply to [`Request::ImportStatus`]).
/// `running` is false once it has finished, when `summary` is the final
/// report; `summary` is `None` when no import has run this session.
ImportStatus {
running: bool,
#[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`]), /// The months the timeline spans (reply to [`Request::PhotoMonths`]),
/// newest first. /// newest first.
PhotoMonths { months: Vec<PhotoMonth> }, PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch. /// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> }, Thumbs { items: Vec<PhotoThumb> },
/// A [`Request::FileThumbs`] generation is no longer current. This is
/// retryable after reserving a fresh generation and must never be interpreted
/// as a permanent "no thumbnail" verdict.
FileThumbsStale,
/// Daemon-issued generation reserved by
/// [`Request::ReserveFileThumbGeneration`].
FileThumbGeneration { generation: u64 },
/// Current recursive thumbnail-build progress.
ThumbnailBuild { status: ThumbnailBuildStatus },
/// An on-disk path the front-end can open (e.g. a downloaded photo). /// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String }, FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]). /// Full-text search results (reply to [`Request::Search`]).
@@ -1409,7 +1672,7 @@ impl ErrorKind {
} }
/// Send one [`Request`] to the daemon listening on `socket` and read its /// Send one [`Request`] to the daemon listening on `socket` and read its
/// [`Response`]. Errors (e.g. [`Error::Io`]) if no daemon is listening. /// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
/// ///
/// Shared by the CLI and GUI so both speak the wire format identically. /// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> { pub fn send(socket: &Path, req: &Request) -> Result<Response> {
@@ -1568,6 +1831,86 @@ mod tests {
} }
} }
#[test]
fn file_thumbnail_requests_keep_their_revision_tags() {
let request = Request::FileThumbs {
items: vec![
FileThumbRequest {
uid: "volume~first".into(),
modified: 1_700_000_001,
name: "first.jpg".into(),
},
FileThumbRequest {
uid: "volume~second".into(),
modified: 1_700_000_002,
name: "second.nef".into(),
},
],
generation: 42,
};
let line = serde_json::to_string(&request).unwrap();
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
#[test]
fn legacy_file_thumbnail_requests_default_the_name() {
let decoded: Request = serde_json::from_str(
r#"{"FileThumbs":{"items":[{"uid":"volume~first","modified":1700000001}],"generation":42}}"#,
)
.unwrap();
let Request::FileThumbs { items, generation } = decoded else {
panic!("decoded the wrong request variant");
};
assert_eq!(generation, 42);
assert_eq!(items.len(), 1);
assert!(items[0].name.is_empty());
}
#[test]
fn thumbnail_build_requests_and_progress_roundtrip() {
let requests = [
Request::ReserveFileThumbGeneration,
Request::CancelFileThumbs { generation: 43 },
Request::StartThumbnailBuild {
path: "pictures/events".into(),
},
Request::CancelThumbnailBuild,
Request::ThumbnailBuildStatus,
];
for request in requests {
let line = serde_json::to_string(&request).unwrap();
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
let response = Response::ThumbnailBuild {
status: ThumbnailBuildStatus {
running: true,
scanning: false,
path: "pictures/events".into(),
folders_scanned: 12,
images_found: 80,
completed: 25,
failed: 1,
message: None,
},
};
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let response = Response::FileThumbGeneration { generation: 44 };
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let response = Response::FileThumbsStale;
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
#[test] #[test]
fn unified_search_request_and_response_roundtrip() { fn unified_search_request_and_response_roundtrip() {
let request = Request::SearchV2 { let request = Request::SearchV2 {
+13
View File
@@ -188,6 +188,19 @@ impl Db {
Ok(rows) 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. /// How many photos of `album_uid` are persisted.
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> { pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
let conn = self.read(); let conn = self.read();
+25
View File
@@ -165,6 +165,31 @@ impl Db {
Ok(rows) 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 /// The months the timeline spans, newest first, each with how many photos it
/// holds — the data behind the date scrubber. Buckets are local-time /// holds — the data behind the date scrubber. Buckets are local-time
/// `(year, month)` so they line up with the day headings the gallery draws /// `(year, month)` so they line up with the day headings the gallery draws
+4
View File
@@ -3201,6 +3201,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&parent_node).unwrap(); db.upsert_node(&parent_node).unwrap();
@@ -3227,6 +3229,8 @@ fn test_db_has_children_and_trashed_filtering() {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
}; };
db.upsert_node(&child_node).unwrap(); db.upsert_node(&child_node).unwrap();
+1
View File
@@ -20,6 +20,7 @@ pub mod search;
pub mod service; pub mod service;
pub mod shell; pub mod shell;
pub mod syncignore; pub mod syncignore;
pub mod takeout;
pub use access::{Access, access_for, perm_bits}; pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result}; pub use error::{CoreError, CoreResult, Error, Result};
+108 -1
View File
@@ -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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PromptFlag { enum PromptFlag {
/// `--prompt=TEXT` /// `--prompt=TEXT`
@@ -105,6 +124,13 @@ pub struct PromptConfig {
/// the prompt text; without one the launcher's own prompt flag is appended. /// the prompt text; without one the launcher's own prompt flag is appended.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub menu: Option<Vec<String>>, 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 /// How many results to feed the launcher. Unset means
/// [`DEFAULT_MENU_LIMIT`]. /// [`DEFAULT_MENU_LIMIT`].
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -124,6 +150,8 @@ pub enum PromptMode {
Gtk, Gtk,
/// The user's own launcher, driven over stdin/stdout. /// The user's own launcher, driven over stdin/stdout.
Dmenu, 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 /// 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) 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. /// 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 /// 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) 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 { let Some(path) = std::env::var_os("PATH") else {
return false; 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] #[test]
fn a_configured_launcher_is_used_verbatim() { fn a_configured_launcher_is_used_verbatim() {
let configured = vec!["my-menu".to_string(), "--pick".to_string()]; let configured = vec!["my-menu".to_string(), "--pick".to_string()];
+21 -2
View File
@@ -23,8 +23,9 @@
//! ``` //! ```
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::process::CommandExt;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
spawn(&argv, path); 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) { fn spawn(argv: &[String], path: &Path) {
let Some((program, args)) = argv.split_first() else { let Some((program, args)) = argv.split_first() else {
return; return;
}; };
tracing::info!(path = %path.display(), command = ?argv, "opening"); 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}"); tracing::error!(command = ?argv, "open failed: {e}");
} }
} }
+11 -3
View File
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Enable the service so it survives reboots/logins and start it now. Called on /// Enable the service so it survives reboots/logins, and bring it up now.
/// successful login. /// 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 { 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 /// Stop the running mount without disabling it. Called by the tray's
File diff suppressed because it is too large. Load diff
+5
View File
@@ -5,10 +5,14 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "FUSE on-demand filesystem for Proton Drive" description = "FUSE on-demand filesystem for Proton Drive"
[dependencies] [dependencies]
pdfs-core.workspace = true pdfs-core.workspace = true
zip.workspace = true
sha1.workspace = true
proton-drive-rs.workspace = true proton-drive-rs.workspace = true
futures.workspace = true futures.workspace = true
tokio.workspace = true tokio.workspace = true
@@ -16,6 +20,7 @@ tracing.workspace = true
fuser.workspace = true fuser.workspace = true
libc.workspace = true libc.workspace = true
serde_json.workspace = true serde_json.workspace = true
base64.workspace = true
image.workspace = true image.workspace = true
notify = "8" notify = "8"
parking_lot.workspace = true parking_lot.workspace = true
+2
View File
@@ -541,6 +541,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+139 -2
View File
@@ -11,7 +11,7 @@
use std::io::{BufRead, BufReader, Write as _}; use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -134,12 +134,21 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// the mount root. /// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> { fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path); let p = Path::new(path);
if p.is_absolute() { let relative = if p.is_absolute() {
p.strip_prefix(mountpoint) p.strip_prefix(mountpoint)
.map(Path::to_path_buf) .map(Path::to_path_buf)
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else { } else {
Ok(p.to_path_buf()) Ok(p.to_path_buf())
}?;
if relative
.components()
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
{
Ok(relative)
} else {
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
} }
} }
@@ -320,6 +329,34 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed), items: core.photo_thumbs(&parsed),
} }
} }
Ok(CtlRequest::FileThumbs { items, generation }) => {
match core.file_thumbs(&items, generation) {
Some(items) => CtlResponse::Thumbs { items },
None => CtlResponse::FileThumbsStale,
}
}
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
generation: core.reserve_file_thumb_generation(),
},
Ok(CtlRequest::CancelFileThumbs { generation }) => {
core.cancel_file_thumbs(generation);
CtlResponse::Ok {
message: "thumbnail requests cancelled".to_string(),
}
}
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.start_thumbnail_build(rel) {
Ok(status) => CtlResponse::ThumbnailBuild { status },
Err(error) => CtlResponse::error(error),
},
Err(error) => CtlResponse::error(error),
},
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
status: core.cancel_thumbnail_build(),
},
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
status: core.thumbnail_build_status(),
},
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) { Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) { Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath { Ok(p) => CtlResponse::FilePath {
@@ -329,6 +366,81 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))), None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
}, },
// The scan alone walks every archive's central directory, so even the
// dry run goes to a background thread and is read back through
// `ImportStatus` rather than answered inline.
Ok(CtlRequest::ImportTakeout { archives, dry_run }) => {
if archives.is_empty() {
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
} else {
let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
let missing: Vec<String> = paths
.iter()
.filter(|path| !path.is_file())
.map(|path| path.display().to_string())
.collect();
if !missing.is_empty() {
CtlResponse::error(CoreError::not_found(format!(
"cannot read: {}",
missing.join(", ")
)))
} else {
let count = paths.len();
match core.spawn_takeout_import(paths, dry_run) {
Ok(()) => CtlResponse::Ok {
message: format!(
"{} {count} archive(s)",
if dry_run { "scanning" } else { "importing" }
),
},
Err(e) => CtlResponse::error(e),
}
}
}
}
Ok(CtlRequest::ImportStatus) => {
let (running, summary) = crate::takeout::import_status();
CtlResponse::ImportStatus { running, summary }
}
Ok(CtlRequest::CancelImport) => {
if crate::takeout::cancel_import() {
CtlResponse::Ok {
message: "stopping the import".to_string(),
}
} else {
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 { Ok(CtlRequest::UploadPhoto {
name, name,
media_type, media_type,
@@ -1176,4 +1288,29 @@ mod request_limit_tests {
std::io::ErrorKind::InvalidData std::io::ErrorKind::InvalidData
); );
} }
#[test]
fn accepts_paths_inside_the_mountpoint() {
let mountpoint = Path::new("/mnt/proton");
assert_eq!(
rel_to_mount(mountpoint, "Photos/2026").unwrap(),
Path::new("Photos/2026")
);
assert_eq!(
rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(),
Path::new("Photos/2026")
);
assert_eq!(
rel_to_mount(mountpoint, "/mnt/proton").unwrap(),
Path::new("")
);
}
#[test]
fn rejects_paths_that_escape_the_mountpoint() {
let mountpoint = Path::new("/mnt/proton");
assert!(rel_to_mount(mountpoint, "../escape").is_err());
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
}
} }
+90 -42
View File
@@ -29,12 +29,14 @@ use std::time::{Duration, Instant};
use parking_lot::Mutex; use parking_lot::Mutex;
use futures::StreamExt;
use pdfs_core::batch; use pdfs_core::batch;
use pdfs_core::cache::{Baseline, StagedWrite}; use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta}; 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::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeMoveItem};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use super::state::Intervals; use super::state::Intervals;
@@ -642,61 +644,46 @@ impl Core {
return Ok(()); return Ok(());
} }
}; };
// The name half goes first, so that a collision on the move half below is let landed = if node.parent_uid.as_ref() == Some(&parent) {
// about the name the node will actually land under rather than the one it self.drain_rename_in_place(&uid, &node.name, &name)?
// is about to lose. } else {
let mut landed = node.name.clone(); // Move and rename land as one request (`move-multiple` takes a
if landed != name { // target name), so there is no half-applied state between them and
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) { // no window for the requirements to go stale between two calls —
Ok(()) => landed = name.clone(), // the race B46 queued this op to get away from.
// Someone took the name while we were offline. Renaming to a 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 // *different* name is the non-destructive resolution: it neither
// clobbers their file nor drops ours, and it is visible. // clobbers their file nor drops ours, and it is visible.
Err(e) if is_already_exists(&e) => { Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&name, now_secs()); let alt = conflict_name(&name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name"); warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
self.rt self.move_renaming(&uid, &parent, Some(&alt))?;
.block_on(self.client.rename_node(&uid, &alt, None))?;
landed = alt.clone();
self.adopt_drained_name(&uid, &alt); self.adopt_drained_name(&uid, &alt);
self.log_activity( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&name, &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}"), format!("destination already had that name; moved as {alt}"),
false, false,
); );
alt
} }
// The destination folder is gone. Leaving the node in its current // The destination folder is gone. Leaving the node in its current
// parent is the honest outcome: it is not where the user asked for // 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 // it, but it exists and it is where it has always been. The name
// rename half above still applied. Retrying could only fail again // the user chose still applies there. Retrying the move could
// — the folder is not coming back — and would wedge the queue. // only fail again — the folder is not coming back — and would
// wedge the queue.
Err(e) if is_gone(&e) => { 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( self.log_activity(
ActivityKind::Rename, ActivityKind::Rename,
&landed, &landed,
@@ -704,16 +691,73 @@ impl Core {
.to_string(), .to_string(),
false, false,
); );
landed
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
} }
} };
self.db.delete_op(op.id)?; self.db.delete_op(op.id)?;
self.note_self_change(&uid); self.note_self_change(&uid);
info!(%uid, name = %landed, "pending rename landed"); info!(%uid, name = %landed, "pending rename landed");
Ok(()) 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. /// Apply a queued trash to the remote.
/// ///
/// A node that is already gone is a success, not a failure: the outcome the /// A node that is already gone is a success, not a failure: the outcome the
@@ -1587,6 +1631,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1879,6 +1925,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+109 -15
View File
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
TransferDirection, ThumbnailBuildStatus, TransferDirection,
}; };
use pdfs_core::db::{ use pdfs_core::db::{
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp, Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
@@ -90,12 +90,14 @@ mod mount;
mod photos; mod photos;
mod profile; mod profile;
mod reads; mod reads;
mod redate;
mod revisions; mod revisions;
mod sharing; mod sharing;
mod shutdown; mod shutdown;
mod state; mod state;
mod sweep; mod sweep;
mod sync; mod sync;
mod takeout;
mod transfers; mod transfers;
mod upload; mod upload;
mod r#virtual; mod r#virtual;
@@ -373,6 +375,45 @@ impl SearchRoots {
} }
} }
/// Negative thumbnail knowledge has two different authorities. A remote miss
/// only says Proton has no thumbnail to download; it must never suppress local
/// generation. A local miss says this exact file revision was successfully
/// inspected and could not be decoded. Both maps are bounded independently.
#[derive(Default)]
struct ThumbnailMissCaches {
remote: HashMap<(NodeUid, i32), i64>,
local: HashMap<(NodeUid, i32), i64>,
}
impl ThumbnailMissCaches {
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
self.remote.get(key) == Some(&tag)
}
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
self.local.get(key) == Some(&tag)
}
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
Self::remember(&mut self.remote, key, tag);
}
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
Self::remember(&mut self.local, key, tag);
}
fn forget_local(&mut self, key: &(NodeUid, i32)) {
self.local.remove(key);
}
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
map.clear();
}
map.insert(key, tag);
}
}
/// `path` expressed relative to `root`, or `None` if it is not below it. The /// `path` expressed relative to `root`, or `None` if it is not below it. The
/// root itself resolves to the empty path; an empty root is the mount itself /// root itself resolves to the empty path; an empty root is the mount itself
/// and covers everything. /// and covers everything.
@@ -510,8 +551,22 @@ struct Core {
/// those downloads is a full-size photo — so an in-flight uid is never started /// those downloads is a full-size photo — so an in-flight uid is never started
/// twice. /// twice.
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>, thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by /// Global permit pool for locally generated thumbnails. The old per-request
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at. /// limit multiplied when the GUI sent several batches, allowing dozens of
/// full-size images to download at once.
thumb_gen_budget: Arc<tokio::sync::Semaphore>,
/// Current ordinary-file listing generation and a wake-up for tasks waiting
/// on a permit or a download when that listing is abandoned.
file_thumb_generation: Arc<AtomicU64>,
file_thumb_cancel: Arc<tokio::sync::Notify>,
/// Progress of the explicit recursive build started from the Files toolbar.
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
/// Cancellation for the explicit recursive build. Separate from ordinary
/// listing cancellation because the two jobs have independent lifetimes.
thumbnail_build_cancelled: Arc<AtomicBool>,
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
/// Negative thumbnail knowledge, separated by whether the remote lacked a
/// thumbnail or local decoding proved this revision unsupported.
/// ///
/// Absence has to be cached or it costs a round trip every time it is asked /// Absence has to be cached or it costs a round trip every time it is asked
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister /// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
@@ -519,7 +574,7 @@ struct Core {
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime /// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
/// is the validity tag — a new revision may well have a thumbnail — matching /// is the validity tag — a new revision may well have a thumbnail — matching
/// how [`ContentCache::read_thumbnail`] validates the positive side. /// how [`ContentCache::read_thumbnail`] validates the positive side.
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>, thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
/// Last account quota answer and when it was learned, for `statfs(2)`. /// Last account quota answer and when it was learned, for `statfs(2)`.
/// ///
/// `df` and the free-space preflight of every file manager and installer ask /// `df` and the free-space preflight of every file manager and installer ask
@@ -3579,7 +3634,7 @@ impl Core {
// "This file has no thumbnail" is an answer worth remembering: without // "This file has no thumbnail" is an answer worth remembering: without
// it every listing pays a round trip per file to be told nothing (B5). // it every listing pays a round trip per file to be told nothing (B5).
let key = (uid.clone(), ttype.as_i32()); let key = (uid.clone(), ttype.as_i32());
if self.no_thumbnail.lock().get(&key) == Some(&mtime) { if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
return Ok(None); return Ok(None);
} }
let bytes = self let bytes = self
@@ -3596,15 +3651,7 @@ impl Core {
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes); .store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
} }
None => { None => {
let mut misses = self.no_thumbnail.lock(); self.thumbnail_misses.lock().remember_remote(key, mtime);
// Bounded by clearing rather than by LRU: the entries are two
// words each, the cap is far above any plausible working set,
// and re-learning a miss costs one round trip. Not worth a
// second data structure to order them.
if misses.len() >= MAX_THUMBNAIL_MISSES {
misses.clear();
}
misses.insert(key, mtime);
} }
} }
Ok(bytes) Ok(bytes)
@@ -3691,6 +3738,9 @@ impl Core {
// My own tree: a role only means something under a share, and // My own tree: a role only means something under a share, and
// this listing never crosses into one. // this listing never crosses into one.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4272,6 +4322,9 @@ impl Core {
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}) })
.collect()) .collect())
} }
@@ -4810,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 /// 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. /// 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('~')?; let (vol, link) = s.split_once('~')?;
if vol.is_empty() || link.is_empty() || link.contains('~') { if vol.is_empty() || link.is_empty() || link.contains('~') {
return None; return None;
@@ -4947,6 +5000,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
album: None, album: None,
// Nothing signed it: it has never been near the crypto layer. // Nothing signed it: it has never been near the crypto layer.
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -5871,6 +5926,8 @@ mod pending_size_tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -7081,6 +7138,9 @@ mod tests {
share_id, share_id,
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}); });
node node
}; };
@@ -7541,10 +7601,44 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
} }
#[cfg(test)]
mod thumbnail_miss_tests {
use super::*;
#[test]
fn remote_misses_never_suppress_local_generation() {
let uid = parse_uid("volume~node").expect("uid");
let key = (uid, ThumbnailType::Thumbnail.as_i32());
let mut misses = ThumbnailMissCaches::default();
misses.remember_remote(key.clone(), 42);
assert!(misses.remote_contains(&key, 42));
assert!(!misses.local_contains(&key, 42));
misses.remember_local(key.clone(), 42);
assert!(misses.local_contains(&key, 42));
misses.forget_local(&key);
assert!(!misses.local_contains(&key, 42));
assert!(misses.remote_contains(&key, 42));
}
#[test]
fn local_miss_cache_is_bounded_on_gui_only_paths() {
let mut misses = ThumbnailMissCaches::default();
for index in 0..=MAX_THUMBNAIL_MISSES {
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
}
assert_eq!(misses.local.len(), 1);
}
}
#[cfg(test)] #[cfg(test)]
mod search_root_tests { mod search_root_tests {
use super::*; use super::*;
+9 -1
View File
@@ -389,7 +389,15 @@ pub fn mount(
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())), own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::new())), self_changes: Arc::new(Mutex::new(HashMap::new())),
thumb_gen: Arc::new(Mutex::new(HashSet::new())), thumb_gen: Arc::new(Mutex::new(HashSet::new())),
no_thumbnail: Arc::new(Mutex::new(HashMap::new())), thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
super::photos::THUMB_GEN_CONCURRENCY,
)),
file_thumb_generation: Arc::new(AtomicU64::new(0)),
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_build: Arc::new(Mutex::new(Default::default())),
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
quota: Arc::new(Mutex::new(None)), quota: Arc::new(Mutex::new(None)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())), size_upgrades: Arc::new(Mutex::new(HashMap::new())),
size_waiters: Arc::new(Default::default()), size_waiters: Arc::new(Default::default()),
File diff suppressed because it is too large. Load diff
+327
View File
@@ -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);
}
}
+17
View File
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
use tracing::debug; use tracing::debug;
use crate::QUOTA_TTL; use crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind}; use proton_drive_rs::{MemberRole, NodeKind};
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.map(role_to_str) .map(role_to_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(); .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 { DirEntry {
name: n.name, name: n.name,
is_dir, 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(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
role, role,
shared_by,
shared_at,
shared_by_unverified,
} }
} }
@@ -609,6 +625,7 @@ impl Core {
items.push(SharedItem { items.push(SharedItem {
uid: uid.to_string(), uid: uid.to_string(),
is_dir: n.is_folder(), is_dir: n.is_folder(),
modified: n.modification_time,
name: n.name, name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(), path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members, member_count: members,
+5
View File
@@ -1120,6 +1120,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
@@ -1128,6 +1130,9 @@ mod tests {
share_id: ShareId::from("share"), share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"), membership_id: ShareMembershipId::from("membership"),
permissions, permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
} }
} }
+2
View File
@@ -368,6 +368,8 @@ mod tests {
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+632
View File
@@ -0,0 +1,632 @@
//! Importing a Google Photos Takeout export into Proton Photos.
//!
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
//! talks to Proton. One import runs on its own thread and moves through four
//! phases, reporting into the transfer registry as a job so the GUI's transfer
//! list shows it without a bespoke widget:
//!
//! 1. **Scan** — read the archives' central directories and sidecars.
//! 2. **Deduplicate against the account** — ask the photos volume which of the
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
//! those files, then confirm with the content digest. A library that is
//! already half-synced by Proton Photos on a phone therefore costs one cheap
//! request per 150 names and reads no media at all for the misses.
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
//! no extraction to disk, so the import needs no scratch space.
//! 4. **Albums** — create the albums the export names that the account lacks,
//! then add every photo of each album, the ones that were already on the
//! account included.
//!
//! Re-running an interrupted import is the resume story: phase 2 finds
//! everything the previous run uploaded and skips it. Nothing is persisted
//! between runs.
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
use sha1::{Digest, Sha1};
use tracing::{info, warn};
use super::Core;
use crate::transfers::CountingReader;
/// The one import this daemon may be running, and how the last one ended.
///
/// Process-global rather than a [`Core`] field because it must be *one* import
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
/// hold one), and because a front-end asking for the result after the import
/// finished must still find it.
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
#[derive(Default)]
struct ImportState {
running: bool,
cancel: Option<Arc<AtomicBool>>,
last: Option<ImportSummary>,
}
/// Whether an import is running, and the last one's report.
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
let state = IMPORT.lock();
(state.running, state.last.clone())
}
/// Ask the running import to stop. False when none is running.
pub(crate) fn cancel_import() -> bool {
match &IMPORT.lock().cancel {
Some(flag) => {
flag.store(true, Ordering::Relaxed);
true
}
None => false,
}
}
/// One photo to import, after the copies Google keeps per album are folded into
/// a single upload that joins several albums.
struct Planned {
photo: TakeoutPhoto,
/// Every album this photo belongs to, by export title.
albums: Vec<String>,
/// Filled in phase 2/3: where the photo ended up on the account.
uid: Option<NodeUid>,
}
/// Fold the scan into one entry per distinct photo.
///
/// Google stores a full copy of a photo in every album folder it appears in, so
/// the same file is in the export two or three times over. They are folded on
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
/// and the album memberships of the copies are unioned onto the survivor. Two
/// genuinely different photos sharing both a name and an exact size would be
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
/// filed under an extra album rather than lost data.
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
let mut order: Vec<(String, u64)> = Vec::new();
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
for photo in &scan.photos {
let key = (photo.name.clone(), photo.size);
let entry = by_key.entry(key.clone()).or_insert_with(|| {
order.push(key.clone());
Planned {
photo: photo.clone(),
albums: Vec::new(),
uid: None,
}
});
if let Bucket::Album(title) = &photo.bucket
&& !entry.albums.iter().any(|existing| existing == title)
{
entry.albums.push(title.clone());
}
// A copy that carries a capture time wins over one that does not: the
// album copy and the year-folder copy do not always both have a sidecar.
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
entry.photo.capture_time = photo.capture_time;
}
entry.photo.favorite |= photo.favorite;
}
order
.into_iter()
.filter_map(|key| by_key.remove(&key))
.collect()
}
/// Open one archive and stream `entry` through `sink`.
fn read_entry<R>(
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
entry: &str,
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
) -> CoreResult<R> {
let mut file = archive
.by_name(entry)
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
Ok(sink(&mut file))
}
impl Core {
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
///
/// `cancel` stops the run at the next photo boundary — an upload already on
/// the wire is allowed to finish, so a cancelled import never leaves a
/// half-written revision behind.
pub(crate) fn import_takeout(
&self,
archives: Vec<PathBuf>,
dry_run: bool,
cancel: Arc<AtomicBool>,
) -> CoreResult<ImportSummary> {
let job = self.transfers.begin_job("Importing Google Photos");
job.detail("reading archives");
let scan = scan_archives(&archives)?;
let mut planned = plan(&scan);
let mut summary = ImportSummary {
found: planned.len(),
skipped_trashed: scan.skipped_trashed,
..Default::default()
};
job.set_total(planned.len() as u64);
info!(
archives = archives.len(),
photos = summary.found,
albums = scan.albums.len(),
trashed = summary.skipped_trashed,
"takeout scan complete"
);
let photos = self.photos();
if self
.rt
.block_on(photos.get_photos_root())
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
.is_none()
{
return Err(CoreError::invalid(
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
));
}
// Phase 2: which of these are already on the account.
job.detail("checking for photos you already have");
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
if dry_run {
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
return Ok(summary);
}
// Phase 3: upload what is left, one archive at a time so each zip's
// central directory is parsed once.
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
// Phase 4: albums. Attempted even after a cancel — the photos that did
// upload should still land in their albums.
if !scan.albums.is_empty() {
job.detail("sorting photos into albums");
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
warn!(error = %e, "takeout album linking failed");
}
}
self.spawn_timeline_refresh();
summary.cancelled = cancel.load(Ordering::Relaxed);
Ok(summary)
}
/// Fill in `uid` for every planned photo the account already holds.
///
/// Two passes on purpose: the name-only query is one request per 150 photos
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
/// handful of names that actually collide.
fn mark_existing(
&self,
planned: &mut [Planned],
archives: &[PathBuf],
summary: &mut ImportSummary,
cancel: &AtomicBool,
) -> CoreResult<()> {
let photos = self.photos();
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
let collisions = self
.rt
.block_on(photos.name_collisions(&names))
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
// Hash the colliding files, grouped by archive so each zip opens once.
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (index, planned) in planned.iter().enumerate() {
if collisions.get(index).copied().unwrap_or(false) {
by_archive
.entry(planned.photo.archive)
.or_default()
.push(index);
}
}
// Kept as a pair list, not a map: the duplicate lookup answers in input
// order, so the order these were built in is what maps answers back to
// planned photos.
let mut digests: Vec<(usize, String)> = Vec::new();
for (archive_index, indices) in by_archive {
if cancel.load(Ordering::Relaxed) {
break;
}
let mut archive = open_archive(archives, archive_index)?;
for index in indices {
let entry = planned[index].photo.entry.clone();
match read_entry(&mut archive, &entry, sha1_of) {
Ok(Ok(digest)) => digests.push((index, digest)),
Ok(Err(e)) | Err(e) => {
warn!(entry, error = %e, "cannot hash takeout entry");
}
}
}
}
if digests.is_empty() {
return Ok(());
}
let items: Vec<(String, String)> = digests
.iter()
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
.collect();
let matches = self
.rt
.block_on(photos.find_duplicates_many(&items))
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
for (slot, (index, _)) in digests.iter().enumerate() {
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
planned[*index].uid = Some(uid.clone());
summary.duplicates += 1;
}
}
Ok(())
}
/// Upload every planned photo that is not already on the account.
fn upload_planned(
&self,
planned: &mut [Planned],
archives: &[PathBuf],
summary: &mut ImportSummary,
cancel: &AtomicBool,
job: &crate::transfers::JobGuard,
) -> CoreResult<()> {
let photos = self.photos();
let mut favorites: Vec<NodeUid> = Vec::new();
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for (index, item) in planned.iter().enumerate() {
if item.uid.is_none() {
by_archive
.entry(item.photo.archive)
.or_default()
.push(index);
}
}
for (archive_index, indices) in by_archive {
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
break;
}
let mut archive = open_archive(archives, archive_index)?;
for index in indices {
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
break;
}
let photo = planned[index].photo.clone();
job.detail(photo.name.clone());
let guard = self.transfers.begin(
photo.name.clone(),
"",
TransferDirection::Upload,
photo.size,
);
let metadata = PhotoUploadMetadata {
capture_time: photo.capture_time,
..Default::default()
};
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
let counting = CountingReader::new(reader, &guard);
self.rt.block_on(photos.upload_photo_from(
&photo.name,
&photo.media_type,
counting,
photo.size as i64,
Vec::new(),
metadata,
false,
))
});
drop(guard);
job.step();
match uploaded {
Ok(Ok(uid)) => {
summary.uploaded += 1;
summary.bytes += photo.size;
if photo.favorite {
favorites.push(uid.clone());
}
planned[index].uid = Some(uid);
}
Ok(Err(e)) => {
summary.failed += 1;
warn!(name = photo.name, error = %e, "takeout photo upload failed");
}
Err(e) => {
summary.failed += 1;
warn!(name = photo.name, error = %e, "cannot read takeout entry");
}
}
}
}
// Favourites are a separate call: the upload seal takes classification
// tags, but `Favorite` is not one of them — it goes through the photos
// volume's dedicated favourite endpoint.
if !favorites.is_empty() {
let updates: Vec<PhotoTagsUpdate> = favorites
.into_iter()
.map(|node_uid| PhotoTagsUpdate {
node_uid,
tags_to_add: vec![PhotoTag::Favorite],
tags_to_remove: Vec::new(),
})
.collect();
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
warn!(error = %e, "marking imported favourites failed");
}
}
Ok(())
}
/// Create the albums the export names that the account lacks, then add each
/// album's photos to it.
fn link_albums(
&self,
planned: &[Planned],
albums: &[String],
summary: &mut ImportSummary,
) -> CoreResult<()> {
let photos = self.photos();
// Existing albums by decrypted name, so a second import of the same
// export adds to the album it made the first time instead of a twin.
let mut existing: HashMap<String, NodeUid> = HashMap::new();
let uids = self
.rt
.block_on(photos.enumerate_album_node_uids())
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
if !uids.is_empty() {
let albums_nodes = self
.rt
.block_on(photos.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
for node in albums_nodes {
existing.entry(node.name.clone()).or_insert(node.uid);
}
}
for title in albums {
let members: Vec<NodeUid> = planned
.iter()
.filter(|item| item.albums.iter().any(|album| album == title))
.filter_map(|item| item.uid.clone())
.collect();
if members.is_empty() {
continue;
}
let album_uid = match existing.get(title) {
Some(uid) => uid.clone(),
None => match self.rt.block_on(photos.create_album(title)) {
Ok(uid) => {
summary.albums_created += 1;
existing.insert(title.clone(), uid.clone());
uid
}
Err(e) => {
warn!(album = title, error = %e, "cannot create album");
continue;
}
},
};
match self
.rt
.block_on(photos.add_photos_to_album(&album_uid, &members))
{
Ok(outcomes) => {
for (uid, outcome) in outcomes {
match outcome {
Ok(()) => summary.album_links += 1,
// A photo already in the album is the expected
// outcome of a re-run, not a failure worth counting.
Err(e) => {
warn!(album = title, %uid, error = %e, "cannot add photo to album")
}
}
}
}
Err(e) => warn!(album = title, error = %e, "album addition failed"),
}
}
Ok(())
}
/// Run an import on a background thread, logging its outcome to the activity
/// feed. Returns immediately.
///
/// Refuses while one is already running: two imports of the same export
/// would race each other's duplicate detection and upload everything twice.
pub(crate) fn spawn_takeout_import(
&self,
archives: Vec<PathBuf>,
dry_run: bool,
) -> CoreResult<()> {
let cancel = Arc::new(AtomicBool::new(false));
{
let mut state = IMPORT.lock();
if state.running {
return Err(CoreError::conflict(
"an import 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.import_takeout(archives, dry_run, cancel);
{
let mut state = IMPORT.lock();
state.running = false;
state.cancel = None;
state.last = outcome.as_ref().ok().cloned();
}
match outcome {
Ok(summary) => {
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
if summary.duplicates > 0 {
parts.push(format!("{} already there", summary.duplicates));
}
if summary.albums_created > 0 {
parts.push(format!("{} albums", summary.albums_created));
}
if summary.failed > 0 {
parts.push(format!("{} failed", summary.failed));
}
if summary.cancelled {
parts.push("cancelled".to_string());
}
core.log_activity(
ActivityKind::Upload,
"Google Photos import",
parts.join(" · "),
summary.failed == 0 && !summary.cancelled,
);
}
Err(e) => {
warn!(error = %e, "takeout import failed");
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
}
}
});
Ok(())
}
}
/// Open one of the import's archives by index.
fn open_archive(
archives: &[PathBuf],
index: usize,
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
let path = archives
.get(index)
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
let file = std::fs::File::open(path)
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
zip::ZipArchive::new(std::io::BufReader::new(file))
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
}
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
/// photo duplicate detection is keyed on.
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
let mut hasher = Sha1::new();
let mut buffer = vec![0u8; 256 * 1024];
loop {
let read = reader
.read(&mut buffer)
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(hex_lower(&hasher.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::takeout::TakeoutPhoto;
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
TakeoutPhoto {
archive: 0,
entry: format!("Takeout/Google Photos/x/{name}"),
name: name.to_string(),
size,
media_type: "image/jpeg".to_string(),
capture_time: None,
favorite: false,
bucket,
}
}
#[test]
fn album_copies_of_one_photo_fold_into_a_single_upload() {
let scan = TakeoutScan {
photos: vec![
photo("IMG_1.jpg", 100, Bucket::Timeline),
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
photo("IMG_2.jpg", 200, Bucket::Timeline),
],
albums: vec!["Iceland".into(), "Best of".into()],
..Default::default()
};
let planned = plan(&scan);
assert_eq!(planned.len(), 2, "the three copies are one upload");
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
assert!(planned[1].albums.is_empty());
}
#[test]
fn same_name_different_size_stays_two_photos() {
let scan = TakeoutScan {
photos: vec![
photo("IMG_1.jpg", 100, Bucket::Timeline),
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
],
..Default::default()
};
assert_eq!(plan(&scan).len(), 2);
}
#[test]
fn metadata_from_any_copy_survives_the_fold() {
// Only the album copy carries a sidecar; the fold must not lose it.
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
bare.capture_time = None;
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
rich.capture_time = Some(1_560_000_000);
rich.favorite = true;
let planned = plan(&TakeoutScan {
photos: vec![bare, rich],
..Default::default()
});
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
assert!(planned[0].photo.favorite);
}
#[test]
fn sha1_matches_the_known_digest_of_abc() {
let mut reader: &[u8] = b"abc";
assert_eq!(
sha1_of(&mut reader).unwrap(),
"a9993e364706816aba3e25717850c26c9cd0d89d"
);
}
}
+2
View File
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
photo: None, photo: None,
album: None, album: None,
verification: Default::default(), verification: Default::default(),
direct_role: None,
share_id: None,
} }
} }
+4
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
rust-version.workspace = true rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
[[bin]] [[bin]]
name = "pdfs-tray" name = "pdfs-tray"
@@ -24,6 +27,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
anyhow.workspace = true anyhow.workspace = true
tokio.workspace = true tokio.workspace = true
serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
ksni = "0.2" ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] } gtk4 = { version = "0.9", features = ["v4_12"] }
+3
View File
@@ -22,6 +22,9 @@ fn main() {
} }
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml"); println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
);
println!( println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg" "cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
); );
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
symbolic fill lets GTK recolour it with the surrounding control. -->
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 598 B

@@ -4,6 +4,7 @@
icon-theme resource path at startup, so they resolve by name (and recolour icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. --> with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons"> <gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file> <file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource> </gresource>
</gresources> </gresources>
+154 -17
View File
@@ -14,15 +14,17 @@ use pages::photos::*;
use pages::shared::*; use pages::shared::*;
use pages::shared_by_me::*; use pages::shared_by_me::*;
use pages::status::*; use pages::status::*;
use pages::takeout::*;
use pages::trash::*; use pages::trash::*;
use pages::verify::*; use pages::verify::*;
use widgets::details::*; use widgets::details::*;
use widgets::share_dialog::*; use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*; use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -46,10 +48,10 @@ use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem, Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
send, TransferItem, pending_summary, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -106,6 +108,10 @@ struct Ui {
/// The sidebar/content split. Collapsed while signed out, so the login page /// The sidebar/content split. Collapsed while signed out, so the login page
/// owns the whole window and no destination is reachable without a session. /// owns the whole window and no destination is reachable without a session.
nav: adw::NavigationSplitView, nav: adw::NavigationSplitView,
/// Shared thumbnails for ordinary image files outside the Photos gallery.
/// One cache and request queue serves Files, search, Shared and Trash, so
/// the same image is downloaded and decoded only once.
pub(crate) file_thumbs: FileThumbnailState,
// Per-page state. Each page module owns its own struct; `Ui` keeps only // Per-page state. Each page module owns its own struct; `Ui` keeps only
// what more than one page genuinely shares. // what more than one page genuinely shares.
@@ -120,6 +126,7 @@ struct Ui {
pub(crate) devices: DevicesState, pub(crate) devices: DevicesState,
pub(crate) locations: LocationsState, pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState, pub(crate) activity: ActivityState,
pub(crate) takeout: TakeoutState,
} }
impl Ui { impl Ui {
@@ -205,10 +212,15 @@ fn load_proton_theme() {
.file-grid {{ padding: 6px; }}\n\ .file-grid {{ padding: 6px; }}\n\
.file-tile {{ padding: 8px; border-radius: 10px; }}\n\ .file-tile {{ padding: 8px; border-radius: 10px; }}\n\
.file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\ .file-tile:hover {{ background: alpha({PROTON_PURPLE}, 0.10); }}\n\
.file-thumbnail {{ border-radius: 7px; }}\n\
.file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\ .file-badge {{ -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }}\n\
.badge-pinned {{ color: #f5c211; }}\n\ .badge-pinned {{ color: #f5c211; }}\n\
.badge-cached {{ color: #2ec27e; }}\n\ .badge-cached {{ color: #2ec27e; }}\n\
.badge-cloud {{ color: #9aa0a6; }}\n\ .badge-cloud {{ color: #9aa0a6; }}\n\
.browser-statusbar {{ background-color: alpha(currentColor, 0.025); }}\n\
scale.browser-status-meter trough, progressbar.browser-status-meter trough {{ min-width: 104px; }}\n\
scale.browser-status-meter trough {{ min-height: 6px; }}\n\
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress {{ min-height: 6px; }}\n\
.photo-viewer-window {{ background-color: #111014; }}\n\ .photo-viewer-window {{ background-color: #111014; }}\n\
.viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\ .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\
.viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ .viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\
@@ -246,7 +258,10 @@ fn load_proton_theme() {
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\ .navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
.navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\ .navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\
.navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\ .navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\
.file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n" .file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n\
.bulk-bar {{ padding: 6px 8px; border-radius: 12px; background: alpha({PROTON_PURPLE}, 0.12); }}\n\
.dropzone {{ border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }}\n\
.dropzone-active {{ border-color: {PROTON_PURPLE}; background: alpha({PROTON_PURPLE}, 0.10); }}\n"
); );
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_string(&css); provider.load_from_string(&css);
@@ -285,6 +300,7 @@ fn build_window(app: &adw::Application) {
let (locations_page, locations_widgets) = build_locations_page(); let (locations_page, locations_widgets) = build_locations_page();
let (activity_page, activity_widgets) = build_activity_page(); let (activity_page, activity_widgets) = build_activity_page();
let (trash_page, trash_widgets) = build_trash_page(); let (trash_page, trash_widgets) = build_trash_page();
let (takeout_page, takeout_widgets) = build_takeout_page();
stack.add_named(&login_page, Some("login")); stack.add_named(&login_page, Some("login"));
stack.add_named(&main_page, Some("main")); stack.add_named(&main_page, Some("main"));
stack.add_named(&browser_page, Some("browser")); stack.add_named(&browser_page, Some("browser"));
@@ -295,6 +311,7 @@ fn build_window(app: &adw::Application) {
stack.add_named(&locations_page, Some("locations")); stack.add_named(&locations_page, Some("locations"));
stack.add_named(&activity_page, Some("activity")); stack.add_named(&activity_page, Some("activity"));
stack.add_named(&trash_page, Some("trash")); stack.add_named(&trash_page, Some("trash"));
stack.add_named(&takeout_page, Some("takeout"));
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
@@ -338,6 +355,7 @@ fn build_window(app: &adw::Application) {
mounted: RefCell::new(false), mounted: RefCell::new(false),
sidebar: sidebar_list.clone(), sidebar: sidebar_list.clone(),
nav: split.clone(), nav: split.clone(),
file_thumbs: FileThumbnailState::new(),
login: LoginState { login: LoginState {
email: login_widgets.0, email: login_widgets.0,
password: login_widgets.1, password: login_widgets.1,
@@ -362,6 +380,8 @@ fn build_window(app: &adw::Application) {
budget_row: main_widgets.budget_row.clone(), budget_row: main_widgets.budget_row.clone(),
mountpoint_row: main_widgets.mountpoint_row.clone(), mountpoint_row: main_widgets.mountpoint_row.clone(),
settings_suppress: Cell::new(false), settings_suppress: Cell::new(false),
budget_source: RefCell::new(None),
pins_expanded: Cell::new(false),
pins_group: main_widgets.pins_group.clone(), pins_group: main_widgets.pins_group.clone(),
pin_rows: RefCell::new(Vec::new()), pin_rows: RefCell::new(Vec::new()),
pins_state: RefCell::new(None), pins_state: RefCell::new(None),
@@ -381,7 +401,29 @@ fn build_window(app: &adw::Application) {
new_folder: browser_widgets.new_folder.clone(), new_folder: browser_widgets.new_folder.clone(),
upload: browser_widgets.upload.clone(), upload: browser_widgets.upload.clone(),
upload_folder: browser_widgets.upload_folder.clone(), upload_folder: browser_widgets.upload_folder.clone(),
build_thumbnails: browser_widgets.build_thumbnails.clone(),
thumbnail_build_row: browser_widgets.thumbnail_build_row.clone(),
thumbnail_progress: browser_widgets.thumbnail_progress.clone(),
thumbnail_status: browser_widgets.thumbnail_status.clone(),
thumbnail_poll: RefCell::new(None),
thumbnail_build_running: Cell::new(false),
thumbnail_cancel_pending: Cell::new(false),
search_source: RefCell::new(None), search_source: RefCell::new(None),
load_generation: Cell::new(0),
views: browser_widgets.views.clone(),
bulk: browser_widgets.bulk.clone(),
bulk_label: browser_widgets.bulk_label.clone(),
bulk_trash: browser_widgets.bulk_trash.clone(),
bulk_pin: browser_widgets.bulk_pin.clone(),
bulk_unpin: browser_widgets.bulk_unpin.clone(),
empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(),
grid_thumbnail_size: Cell::new(GRID_THUMB_DEFAULT),
grid_tiles: RefCell::new(Vec::new()),
quota_box: browser_widgets.quota_box.clone(),
quota: browser_widgets.quota.clone(),
quota_text: browser_widgets.quota_text.clone(),
}, },
details: DetailsState { details: DetailsState {
details: browser_widgets.details, details: browser_widgets.details,
@@ -407,6 +449,8 @@ fn build_window(app: &adw::Application) {
retry: gallery_widgets.retry.clone(), retry: gallery_widgets.retry.clone(),
more: gallery_widgets.more.clone(), more: gallery_widgets.more.clone(),
upload: gallery_widgets.upload.clone(), upload: gallery_widgets.upload.clone(),
import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(), title: gallery_widgets.title.clone(),
subtitle: gallery_widgets.subtitle.clone(), subtitle: gallery_widgets.subtitle.clone(),
albums: gallery_widgets.albums.clone(), albums: gallery_widgets.albums.clone(),
@@ -440,7 +484,8 @@ fn build_window(app: &adw::Application) {
decode_idle: Cell::new(false), decode_idle: Cell::new(false),
thumb_source: RefCell::new(None), thumb_source: RefCell::new(None),
relayout_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 { shared: SharedState {
content: shared_widgets.content.clone(), content: shared_widgets.content.clone(),
@@ -494,6 +539,24 @@ fn build_window(app: &adw::Application) {
inflight: Cell::new(false), inflight: Cell::new(false),
key: RefCell::new(None), key: RefCell::new(None),
}, },
takeout: TakeoutState {
archives: RefCell::new(Vec::new()),
list_group: takeout_widgets.list_group.clone(),
rows: RefCell::new(Vec::new()),
dropzone: takeout_widgets.dropzone.clone(),
scan_button: takeout_widgets.scan_button.clone(),
import_button: takeout_widgets.import_button.clone(),
cancel_button: takeout_widgets.cancel_button.clone(),
clear_button: takeout_widgets.clear_button.clone(),
progress_group: takeout_widgets.progress_group.clone(),
progress_label: takeout_widgets.progress_label.clone(),
progress_bar: takeout_widgets.progress_bar.clone(),
summary_group: takeout_widgets.summary_group.clone(),
summary_rows: RefCell::new(Vec::new()),
inflight: Cell::new(false),
running: Cell::new(false),
dry_run: Cell::new(false),
},
}); });
wire_login(&ui); wire_login(&ui);
wire_logout(&ui, &main_widgets.logout_button); wire_logout(&ui, &main_widgets.logout_button);
@@ -501,18 +564,31 @@ fn build_window(app: &adw::Application) {
&ui, &ui,
&main_widgets.purge_button, &main_widgets.purge_button,
&main_widgets.mountpoint_button, &main_widgets.mountpoint_button,
&main_widgets.import_row,
); );
wire_sidebar(&ui); wire_sidebar(&ui);
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
wire_bulk(
&ui,
&browser_widgets.bulk_clear,
&browser_widgets.empty_upload,
&browser_widgets.empty_new_folder,
);
wire_browser_actions( wire_browser_actions(
&ui, &ui,
&browser_widgets.new_folder, &browser_widgets.new_folder,
&browser_widgets.upload, &browser_widgets.upload,
&browser_widgets.upload_folder, &browser_widgets.upload_folder,
&browser_widgets.build_thumbnails,
); );
wire_details(&ui); wire_details(&ui);
wire_search(&ui); wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
wire_gallery_empty(
&ui,
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
wire_albums(&ui); wire_albums(&ui);
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
@@ -520,6 +596,7 @@ fn build_window(app: &adw::Application) {
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore); wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder); wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry); wire_activity(&ui, &activity_widgets.retry);
wire_takeout(&ui, &takeout_widgets);
wire_refresh( wire_refresh(
&ui, &ui,
&[ &[
@@ -539,6 +616,10 @@ fn build_window(app: &adw::Application) {
// network round-trip only happens on demand rather than on every refresh. // network round-trip only happens on demand rather than on every refresh.
let ui_nav = ui.clone(); let ui_nav = ui.clone();
stack.connect_visible_child_name_notify(move |st| { stack.connect_visible_child_name_notify(move |st| {
// Rows from the page being left must not keep full-size image downloads
// alive in the daemon. The page being entered establishes a fresh
// thumbnail generation as it paints.
cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav); sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() { match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav), Some("browser") => load_browser(&ui_nav),
@@ -559,6 +640,9 @@ fn build_window(app: &adw::Application) {
// every visit to stay live. // every visit to stay live.
Some("activity") => load_activity(&ui_nav), Some("activity") => load_activity(&ui_nav),
Some("trash") => load_trash(&ui_nav), Some("trash") => load_trash(&ui_nav),
// The import runs in the daemon and outlives this page, so arriving
// here asks straight away whether one is in flight.
Some("takeout") => refresh_takeout(&ui_nav),
_ => {} _ => {}
} }
}); });
@@ -644,6 +728,17 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
(page, list) (page, list)
} }
/// The sidebar destination a page belongs under. Most pages are their own
/// destination; a sub-page reached from one (Import, off Settings) has no row of
/// its own and answers with its parent, so the sidebar highlights where the user
/// came from rather than clearing — which would read as "nowhere".
fn destination_of(page: &str) -> &str {
match page {
"takeout" => "main",
other => other,
}
}
/// Selecting a sidebar row navigates the page stack. The reverse direction (stack /// Selecting a sidebar row navigates the page stack. The reverse direction (stack
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler /// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
/// only ever writes the stack. /// only ever writes the stack.
@@ -654,7 +749,11 @@ fn wire_sidebar(ui: &Rc<Ui>) {
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else { let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
return; return;
}; };
if ui_row.stack.visible_child_name().as_deref() != Some(*page) { // Compared by *destination*, not by page: while a sub-page is open its
// parent's row is the selected one, and re-asserting it here would kick
// the user off the sub-page the moment they arrived.
let current = ui_row.stack.visible_child_name();
if current.as_deref().map(destination_of) != Some(*page) {
ui_row.stack.set_visible_child_name(page); ui_row.stack.set_visible_child_name(page);
} }
}); });
@@ -755,6 +854,7 @@ fn sync_sidebar(ui: &Rc<Ui>) {
let Some(current) = ui.stack.visible_child_name() else { let Some(current) = ui.stack.visible_child_name() else {
return; return;
}; };
let current = destination_of(&current);
let index = DESTINATIONS let index = DESTINATIONS
.iter() .iter()
.position(|(page, _, _)| *page == current); .position(|(page, _, _)| *page == current);
@@ -776,6 +876,22 @@ fn toast(ui: &Rc<Ui>, message: &str) {
ui.toasts.add_toast(adw::Toast::new(message)); ui.toasts.add_toast(adw::Toast::new(message));
} }
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
/// the outcome landed on.
///
/// Given a longer timeout than a plain toast: an action the user has to notice,
/// read and reach is not the same ask as a line of confirmation they can ignore.
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
let toast = adw::Toast::builder()
.title(message)
.button_label(label)
.timeout(8)
.build();
let ui_action = ui.clone();
toast.connect_button_clicked(move |_| action(&ui_action));
ui.toasts.add_toast(toast);
}
/// Show a toast for a failure. Same surface as [`toast`], but the message is /// Show a toast for a failure. Same surface as [`toast`], but the message is
/// prefixed with what was being attempted, since a bare daemon error ("no such /// prefixed with what was being attempted, since a bare daemon error ("no such
/// file") reads as noise without it. /// file") reads as noise without it.
@@ -830,7 +946,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
fn build_primary_menu() -> gtk4::MenuButton { fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new(); let menu = gio::Menu::new();
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
menu.append(Some("About Proton Drive"), Some("win.about")); menu.append(Some("About Proton Drive for Linux"), Some("win.about"));
gtk4::MenuButton::builder() gtk4::MenuButton::builder()
.icon_name("open-menu-symbolic") .icon_name("open-menu-symbolic")
.tooltip_text("Main menu") .tooltip_text("Main menu")
@@ -849,11 +965,17 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
let win = window.clone(); let win = window.clone();
about.connect_activate(move |_, _| { about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder() let dialog = adw::AboutDialog::builder()
.application_name("Proton Drive") .application_name("Proton Drive for Linux")
.application_icon("folder-remote-symbolic") .application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION) .version(pdfs_core::config::APP_VERSION)
.developer_name("proton-drive-linux") .developer_name("Nils Pukropp")
.comments("On-demand Proton Drive sync for Linux.") .website("https://github.com/narrrl/proton-drive-linux")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
.license_type(gtk4::License::MitX11)
.comments(
"Files-on-demand Proton Drive for the Linux desktop.\n\n\
Unofficial client — not affiliated with, endorsed by, or supported by Proton AG.",
)
.build(); .build();
dialog.present(Some(&win)); dialog.present(Some(&win));
}); });
@@ -913,7 +1035,13 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// browser-only bindings. // browser-only bindings.
gtk4::gdk::Key::F5 => reload_current_page(&ui), gtk4::gdk::Key::F5 => reload_current_page(&ui),
gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui), gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui),
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl && on_browser => { // Search lives on Files, but the shortcut works from anywhere:
// wanting to find a file is not a reason to first have to remember
// which page owns the search box.
gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl => {
if !on_browser {
ui.stack.set_visible_child_name("browser");
}
ui.browser.search.grab_focus(); ui.browser.search.grab_focus();
} }
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
@@ -923,12 +1051,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
prompt_rename(&ui, &entry); prompt_rename(&ui, &entry);
} }
} }
// Acts on the whole selection: Delete with five files highlighted
// means those five, not whichever one the details pane happens to
// be describing.
gtk4::gdk::Key::Delete if on_browser => { gtk4::gdk::Key::Delete if on_browser => {
if let Some(entry) = selected_entry(&ui) { let entries = selected_entries(&ui);
prompt_delete(&ui, &entry); if !entries.is_empty() {
prompt_delete_many(&ui, entries);
} }
} }
gtk4::gdk::Key::Escape if on_browser && ui.browser.split.shows_sidebar() => { gtk4::gdk::Key::Escape
if on_browser
&& (ui.browser.split.shows_sidebar() || ui.browser.bulk.reveals_child()) =>
{
clear_selection(&ui);
sync_bulk_bar(&ui);
hide_details(&ui); hide_details(&ui);
} }
_ => return glib::Propagation::Proceed, _ => return glib::Propagation::Proceed,
File diff suppressed because it is too large. Load diff
+1
View File
@@ -9,5 +9,6 @@ pub(crate) mod photos;
pub(crate) mod shared; pub(crate) mod shared;
pub(crate) mod shared_by_me; pub(crate) mod shared_by_me;
pub(crate) mod status; pub(crate) mod status;
pub(crate) mod takeout;
pub(crate) mod trash; pub(crate) mod trash;
pub(crate) mod verify; pub(crate) mod verify;
+269 -100
View File
@@ -5,8 +5,12 @@ pub(crate) struct GalleryState {
/// Every photo loaded so far, newest first — the order the lightbox's /// Every photo loaded so far, newest first — the order the lightbox's
/// prev/next walks. The visible day sections are derived from this. /// prev/next walks. The visible day sections are derived from this.
pub(crate) model: gio::ListStore, pub(crate) model: gio::ListStore,
/// The day sections rendered by the Photos ListView, rebuilt from /// The Photos ListView's own model: one item per *rendered row* — a day
/// [`Self::model`] by [`repaint_gallery`]. /// 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, pub(crate) groups: gio::ListStore,
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see /// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size /// [`zoom_gallery`]). The grid fits as many square tiles of about this size
@@ -19,6 +23,12 @@ pub(crate) struct GalleryState {
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) more: gtk4::Button, pub(crate) more: gtk4::Button,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
/// Opens the Google Photos Takeout import chooser.
pub(crate) import: gtk4::Button,
/// Upload / Import offered on the *empty timeline* status page, so a fresh
/// account is a place to start rather than a dead end. Hidden on every other
/// status: a load error is not the moment to offer an upload.
pub(crate) empty_actions: gtk4::Box,
/// "Photos", or the album's name while one is open. /// "Photos", or the album's name while one is open.
pub(crate) title: gtk4::Label, pub(crate) title: gtk4::Label,
/// "1,204 photos" under the page title. /// "1,204 photos" under the page title.
@@ -104,11 +114,14 @@ pub(crate) struct GalleryState {
/// re-flow, replaced on each new trigger so only the last one fires. /// re-flow, replaced on each new trigger so only the last one fires.
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>, pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
pub(crate) relayout_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 /// The rows currently realised by the ListView, as row index -> the uid of
/// [`Self::groups`]. A resize or a zoom step re-flows these in place — /// its first photo (absent for a heading row). A resize or a zoom step
/// rebuilding the ListStore instead would reset the scroll position out from /// changes how many tiles fit per row, so the row model has to be rebuilt —
/// under the user. /// this is what lets the rebuild put the user back where they were.
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>, 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. /// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -152,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 /// 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 /// 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 { pub(crate) struct PhotoGroup {
/// "Today", "Yesterday", or e.g. "3 June 2026". /// "Today", "Yesterday", or e.g. "3 June 2026".
pub(crate) heading: String, pub(crate) heading: String,
pub(crate) photos: Vec<PhotoItem>, 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`]. /// The widgets [`build_gallery_page`] hands back to [`build_window`].
pub(crate) struct GalleryWidgets { pub(crate) struct GalleryWidgets {
/// Flat, newest-first list of every loaded photo. Backs the lightbox's /// Flat, newest-first list of every loaded photo. Backs the lightbox's
@@ -177,6 +248,10 @@ pub(crate) struct GalleryWidgets {
pub(crate) scroll: gtk4::ScrolledWindow, pub(crate) scroll: gtk4::ScrolledWindow,
pub(crate) retry: gtk4::Button, pub(crate) retry: gtk4::Button,
pub(crate) upload: gtk4::Button, pub(crate) upload: gtk4::Button,
pub(crate) import: gtk4::Button,
pub(crate) empty_actions: gtk4::Box,
pub(crate) empty_upload: gtk4::Button,
pub(crate) empty_import: gtk4::Button,
pub(crate) refresh: gtk4::Button, pub(crate) refresh: gtk4::Button,
/// The Albums grid, its own status page and the stack between them, plus the /// The Albums grid, its own status page and the stack between them, plus the
/// Photos/Albums switcher and the back button out of an album. /// Photos/Albums switcher and the back button out of an album.
@@ -227,10 +302,29 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
retry.add_css_class("suggested-action"); retry.add_css_class("suggested-action");
retry.set_visible(false); retry.set_visible(false);
// The empty-timeline state's two ways forward. `StatusPage` takes one child,
// so Retry and these share a box and each is shown only for its own state.
let empty_upload = gtk4::Button::builder().label("Upload photos").build();
empty_upload.add_css_class("pill");
empty_upload.add_css_class("suggested-action");
let empty_import = gtk4::Button::builder()
.label("Import from Google Photos")
.build();
empty_import.add_css_class("pill");
let empty_actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
empty_actions.set_halign(gtk4::Align::Center);
empty_actions.set_visible(false);
empty_actions.append(&empty_upload);
empty_actions.append(&empty_import);
let status_child = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
status_child.append(&retry);
status_child.append(&empty_actions);
let status = adw::StatusPage::builder() let status = adw::StatusPage::builder()
.icon_name("image-x-generic-symbolic") .icon_name("image-x-generic-symbolic")
.vexpand(true) .vexpand(true)
.child(&retry) .child(&status_child)
.build(); .build();
status.add_css_class("compact"); status.add_css_class("compact");
@@ -288,12 +382,23 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
.build(); .build();
upload.add_css_class("pill"); upload.add_css_class("pill");
upload.add_css_class("suggested-action"); upload.add_css_class("suggested-action");
// Importing an export is a rarer, heavier action than adding one photo, so
// it sits beside Upload as a plain button rather than a second accent one.
let import = gtk4::Button::builder()
.icon_name("folder-download-symbolic")
.tooltip_text("Import a Google Photos Takeout export")
.valign(gtk4::Align::Center)
.build();
import.add_css_class("flat");
import.add_css_class("circular");
let refresh = refresh_button(); let refresh = refresh_button();
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header_box.append(&back); header_box.append(&back);
header_box.append(&titles); header_box.append(&titles);
header_box.append(&refresh); header_box.append(&refresh);
header_box.append(&import);
header_box.append(&upload); header_box.append(&upload);
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
@@ -428,6 +533,10 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
scroll, scroll,
retry, retry,
upload, upload,
import,
empty_actions,
empty_upload,
empty_import,
refresh, refresh,
tabs, tabs,
favorites_btn, favorites_btn,
@@ -554,44 +663,77 @@ pub(crate) fn thousands(n: usize) -> String {
/// Wire the gallery: install the section factory, the zoom gestures, the pager /// Wire the gallery: install the section factory, the zoom gestures, the pager
/// and the upload button. Activating a thumbnail downloads the photo and opens it /// and the upload button. Activating a thumbnail downloads the photo and opens it
/// in the in-app lightbox. /// in the in-app lightbox.
/// Wire the empty-timeline buttons. Upload re-fires the header's own Upload
/// rather than duplicating its file-chooser, so the two can't drift apart.
pub(crate) fn wire_gallery_empty(ui: &Rc<Ui>, upload: &gtk4::Button, import: &gtk4::Button) {
let ui_upload = ui.clone();
upload.connect_clicked(move |_| ui_upload.gallery.upload.emit_clicked());
let ui_import = ui.clone();
import.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
}
pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::ScrolledWindow) { pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::ScrolledWindow) {
let factory = gtk4::SignalListItemFactory::new(); let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| { factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8); // One container for both row kinds: the bind fills it with either a
section.set_margin_bottom(16); // heading label or a strip of tiles. The alternative — two factories, or
let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build(); // a Stack per row — buys nothing, since a row's children are rebuilt on
heading.add_css_class("heading"); // bind either way.
heading.add_css_class("gallery-day"); let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
section.append(&heading); row.set_halign(gtk4::Align::Start);
item.set_child(Some(&section)); item.set_child(Some(&row));
item.set_activatable(false); item.set_activatable(false);
}); });
let ui_bind = ui.clone(); let ui_bind = ui.clone();
factory.connect_bind(move |_, item| { factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); 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 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(); while let Some(child) = row_box.first_child() {
heading.set_label(&group.heading); row_box.remove(&child);
}
fill_section(&ui_bind, &section, &group.photos); match &*row {
// Remember the realised section so a learned aspect ratio or a resize can GalleryRow::Heading(heading) => {
// re-justify it in place, without rebuilding the ListStore (which would row_box.set_margin_top(16);
// yank the scroll position back to the top). 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 ui_bind
.gallery .gallery
.bound .bound
.borrow_mut() .borrow_mut()
.insert(item.position(), section); .insert(item.position(), row.anchor());
}); });
// ListView recycles section widgets, so a scrolled-away day must give up its // ListView recycles row widgets, so a scrolled-away row must give up its
// claim on the widgets — otherwise a thumbnail landing late would paint into // claim on them — otherwise a thumbnail landing late would paint into a tile
// a tile that now shows a different day. // that now shows a different photo.
let ui_unbind = ui.clone(); let ui_unbind = ui.clone();
factory.connect_unbind(move |_, item| { factory.connect_unbind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
@@ -600,10 +742,11 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
.bound .bound
.borrow_mut() .borrow_mut()
.remove(&item.position()); .remove(&item.position());
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() { if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
let group = obj.borrow::<PhotoGroup>(); && let GalleryRow::Tiles { photos, .. } = &*obj.borrow::<GalleryRow>()
{
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut(); let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
for photo in &group.photos { for photo in photos {
wanted.remove(&photo.uid); wanted.remove(&photo.uid);
} }
} }
@@ -729,6 +872,14 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
load_gallery(&ui_dates, false); load_gallery(&ui_dates, false);
}); });
// The import is a staged, hours-long migration, so the button hands over to
// the Import page rather than opening a file chooser here: the archives need
// reviewing before anything is sent, and the run needs somewhere to report.
let ui_import = ui.clone();
ui.gallery
.import
.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
let ui_upload = ui.clone(); let ui_upload = ui.clone();
ui.gallery.upload.connect_clicked(move |_| { ui.gallery.upload.connect_clicked(move |_| {
let dialog = gtk4::FileDialog::builder() let dialog = gtk4::FileDialog::builder()
@@ -856,34 +1007,6 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
(columns, edge) (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: &gtk4::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 /// 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. /// px so a rounding error can't push a row into a horizontal overflow.
/// Falls back to a sane guess before the first allocation. /// Falls back to a sane guess before the first allocation.
@@ -1177,27 +1300,35 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
*ui.gallery.relayout_source.borrow_mut() = Some(source); *ui.gallery.relayout_source.borrow_mut() = Some(source);
} }
/// Rebuild the tiles of the day sections currently on screen, at the current /// Re-flow the timeline at the current width and zoom.
/// width and zoom. Sections that are *not* realised need no work: they lay ///
/// themselves out against the current state when the ListView binds them. /// 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>) { pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
let bound: Vec<(u32, gtk4::Box)> = ui let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
.gallery repaint_gallery(ui);
.bound let Some(anchor) = anchor else { return };
.borrow() let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
.iter() return;
.map(|(pos, section)| (*pos, section.clone())) };
.collect(); ui.gallery
for (pos, section) in bound { .list
let Some(obj) = ui.gallery.groups.item(pos) else { .scroll_to(row, gtk4::ListScrollFlags::empty(), None);
continue; }
};
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else { /// Which row of the rendered model holds `uid`, if any.
continue; fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
}; (0..store.n_items()).find(|i| {
let photos = boxed.borrow::<PhotoGroup>().photos.clone(); store
fill_section(ui, &section, &photos); .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. /// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
@@ -1215,46 +1346,75 @@ pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
schedule_relayout(ui); schedule_relayout(ui);
} }
/// Rebuild the day sections from the flat photo model. The timeline arrives /// Rebuild the ListView's row model from the flat photo model: each day becomes
/// newest-first, so photos of the same day are already contiguous — one pass /// a heading row followed by its grid rows, laid out to the current width and
/// splits them. /// zoom.
/// ///
/// The groups are diffed into the existing store rather than replacing it: a /// The rows are diffed into the existing store rather than replacing it: a "load
/// "load more" only really changes the last day (the one the new page continues) /// more" only really changes the last day (the one the new page continues) and
/// and appends after it, and clearing the store instead would scroll the user /// appends after it, and clearing the store instead would scroll the user back
/// back to the top of the timeline at the exact moment they asked for more. /// to the top of the timeline at the exact moment they asked for more.
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) { 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; 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 i = i as u32;
let unchanged = store let unchanged = store
.item(i) .item(i)
.and_downcast::<BoxedAnyObject>() .and_downcast::<BoxedAnyObject>()
.is_some_and(|old| { .is_some_and(|old| row.same_as(&old.borrow::<GalleryRow>()));
let old = old.borrow::<PhotoGroup>();
old.heading == group.heading && old.photos.len() == group.photos.len()
});
if unchanged { if unchanged {
continue; continue;
} }
let boxed = BoxedAnyObject::new(PhotoGroup { let boxed = BoxedAnyObject::new(clone_row(row));
heading: group.heading.clone(),
photos: group.photos.clone(),
});
if i < store.n_items() { if i < store.n_items() {
store.splice(i, 1, &[boxed]); store.splice(i, 1, &[boxed]);
} else { } else {
store.append(&boxed); store.append(&boxed);
} }
} }
// Photos only ever get appended, so a shorter model means a fresh load. if store.n_items() > rows.len() as u32 {
if store.n_items() > groups.len() as u32 { let len = rows.len() as u32;
let len = groups.len() as u32;
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]); 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; let loaded = ui.gallery.model.n_items() as usize;
ui.gallery.subtitle.set_visible(loaded > 0); ui.gallery.subtitle.set_visible(loaded > 0);
// An album counts what the server says it holds, not how much of it has been // An album counts what the server says it holds, not how much of it has been
@@ -1460,6 +1620,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
) )
}; };
gallery_status(&ui, "image-x-generic-symbolic", title, description, false); gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
// Only the whole-timeline empty state: an empty *album* is
// filled by adding existing photos to it, not by uploading
// into it, so the buttons would point the wrong way.
ui.gallery
.empty_actions
.set_visible(ui.gallery.album.borrow().is_none());
return; return;
} }
ui.gallery.content.set_visible_child_name("timeline"); ui.gallery.content.set_visible_child_name("timeline");
@@ -1502,6 +1668,9 @@ pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.gallery.status.set_title(title); ui.gallery.status.set_title(title);
ui.gallery.status.set_description(Some(description)); ui.gallery.status.set_description(Some(description));
ui.gallery.retry.set_visible(retry); ui.gallery.retry.set_visible(retry);
// Only the empty timeline offers a way to fill it; every other status turns
// those buttons back off.
ui.gallery.empty_actions.set_visible(false);
ui.gallery.more.set_visible(false); ui.gallery.more.set_visible(false);
ui.gallery.content.set_visible_child_name("status"); ui.gallery.content.set_visible_child_name("status");
} }
+50 -9
View File
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() { if ui.shared.inflight.get() {
return; return;
} }
cancel_file_thumbnails(ui);
let current = ui.shared.nav.borrow().last().cloned(); let current = ui.shared.nav.borrow().last().cloned();
if let Some((uid, _)) = current { if let Some((uid, _)) = current {
load_shared_folder(ui, uid); load_shared_folder(ui, uid);
@@ -308,11 +309,18 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
if let Some(badge) = role_badge(&entry.role) { if let Some(badge) = role_badge(&entry.role) {
row.add_suffix(&badge); row.add_suffix(&badge);
} }
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir { // The "shared by" in the subtitle is what the invitation claims. When its
"folder-symbolic" // signature does not check out against that person's keys, the claim must
} else { // not read as fact — someone may be impersonating a contact to get a file
"text-x-generic-symbolic" // 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 ui_act = ui.clone();
let uid = entry.uid.clone(); let uid = entry.uid.clone();
let name = entry.name.clone(); let name = entry.name.clone();
@@ -332,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row row
} }
/// A shared row's subtitle: its size (files only), and where it can be reached /// Tooltip on the warning shown next to a share whose invitation did not verify.
/// in the local mount once the synthetic `Shared with me/` directory has interned const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
/// it. An unlisted share has no local path yet, and says nothing rather than It may not really be from them.";
/// pointing at a directory that is not there.
/// 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 { pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new(); 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 { if !entry.is_dir {
parts.push(human_bytes(entry.size)); parts.push(human_bytes(entry.size));
} }
@@ -726,9 +751,25 @@ mod tests {
uid: "vol~link".into(), uid: "vol~link".into(),
path: path.into(), path: path.into(),
role: role.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] #[test]
fn a_share_with_no_local_path_says_nothing_about_one() { fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot // Before the synthetic directory has been listed, the daemon cannot
@@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
if ui.shared_by_me.inflight.get() { if ui.shared_by_me.inflight.get() {
return; return;
} }
cancel_file_thumbnails(ui);
ui.shared_by_me.inflight.set(true); ui.shared_by_me.inflight.set(true);
shared_by_me_status( shared_by_me_status(
ui, ui,
@@ -200,15 +201,12 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
ui.shared_by_me.content.set_visible_child_name("list"); ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new(); let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items { for item in items {
let entry = shared_item_as_entry(item);
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&item.name) .title(&item.name)
.subtitle(shared_item_summary(item)) .subtitle(shared_item_summary(item))
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name(if item.is_dir { row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
// Copy / open the public link, when there is one with a recovered URL. // Copy / open the public link, when there is one with a recovered URL.
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
@@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
.build(); .build();
manage.add_css_class("flat"); manage.add_css_class("flat");
let ui_manage = ui.clone(); let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage); row.add_suffix(&manage);
@@ -289,12 +286,15 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
name: item.name.clone(), name: item.name.clone(),
is_dir: item.is_dir, is_dir: item.is_dir,
size: 0, size: 0,
modified: 0, modified: item.modified,
pinned: false, pinned: false,
cached: false, cached: false,
uid: item.uid.clone(), uid: item.uid.clone(),
path: item.path.clone(), path: item.path.clone(),
// Shared *by* me: I own it, so there is no role of mine to report. // Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(), role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
} }
} }
+220 -35
View File
@@ -37,7 +37,16 @@ pub(crate) struct StatusState {
/// Set while a settings widget is being populated programmatically, so its /// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect. /// change handler skips the IPC/systemd side effect.
pub(crate) settings_suppress: Cell<bool>, pub(crate) settings_suppress: Cell<bool>,
/// Pending debounce for the cache-budget editor. Each `+` click is a value
/// change, and firing a `SetCacheBudget` (and a toast) per click means a
/// stack of toasts and a series of caps the user never asked to apply — so
/// only the value they settle on is sent.
pub(crate) budget_source: RefCell<Option<glib::SourceId>>,
pub(crate) pins_group: adw::PreferencesGroup, pub(crate) pins_group: adw::PreferencesGroup,
/// Whether the pin list is showing every pin or only the first
/// [`PINS_COLLAPSED`]. A long pin list would otherwise push everything below
/// it — including the Developer group — off the end of the page.
pub(crate) pins_expanded: Cell<bool>,
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can /// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// diff against them and only rebuild when the pin set actually changes. /// diff against them and only rebuild when the pin set actually changes.
pub(crate) pin_rows: RefCell<Vec<PinRow>>, pub(crate) pin_rows: RefCell<Vec<PinRow>>,
@@ -104,6 +113,8 @@ pub(crate) struct MainWidgets {
/// which is where the folder is changed. /// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow, pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button, pub(crate) mountpoint_button: gtk4::Button,
/// Opens the Google Photos Takeout import page.
pub(crate) import_row: adw::ActionRow,
} }
/// The main (logged-in) page: a libadwaita settings surface — account header, /// The main (logged-in) page: a libadwaita settings surface — account header,
@@ -233,6 +244,22 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
mountpoint_row.add_suffix(&mountpoint_button); mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row); system_group.add(&mountpoint_row);
// Import: a one-off migration rather than a setting, so it is a doorway to
// its own page rather than controls inlined here — staging a set of archives
// and watching an hours-long upload needs the room.
let import_group = adw::PreferencesGroup::builder()
.title("Import")
.description("Bring a photo library from another service into Proton Photos.")
.build();
let import_row = adw::ActionRow::builder()
.title("Import from Google Photos")
.subtitle("Add a Google Takeout export to your timeline.")
.activatable(true)
.build();
import_row.add_prefix(&gtk4::Image::from_icon_name("folder-download-symbolic"));
import_row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
import_group.add(&import_row);
// Pins group: filled in by refresh. // Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder() let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files") .title("Pinned files")
@@ -265,6 +292,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
inner.append(&transfers_group); inner.append(&transfers_group);
inner.append(&storage_group); inner.append(&storage_group);
inner.append(&system_group); inner.append(&system_group);
inner.append(&import_group);
inner.append(&pins_group); inner.append(&pins_group);
inner.append(&dev_group); inner.append(&dev_group);
@@ -292,10 +320,20 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
purge_button, purge_button,
mountpoint_row, mountpoint_row,
mountpoint_button, mountpoint_button,
import_row,
}, },
) )
} }
/// How many pins the Settings list shows before collapsing the rest behind a
/// "Show all" row. Enough to recognise the list at a glance; short enough that
/// the groups below it stay reachable.
pub(crate) const PINS_COLLAPSED: usize = 6;
/// How long the cache-budget editor waits after the last change before applying
/// it, so a run of `+` clicks is one request rather than one per click.
pub(crate) const BUDGET_DEBOUNCE: Duration = Duration::from_millis(600);
/// Bytes per GiB, for the cache-budget editor's unit conversion. /// Bytes per GiB, for the cache-budget editor's unit conversion.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
@@ -308,6 +346,7 @@ pub(crate) fn wire_settings(
ui: &Rc<Ui>, ui: &Rc<Ui>,
purge_button: &gtk4::Button, purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button, mountpoint_button: &gtk4::Button,
import_row: &adw::ActionRow,
) { ) {
let config = ui.dirs.load_config(); let config = ui.dirs.load_config();
@@ -329,13 +368,22 @@ pub(crate) fn wire_settings(
if ui_budget.status.settings_suppress.get() { if ui_budget.status.settings_suppress.get() {
return; return;
} }
// Replace any pending apply, so only the value the user stops on is sent.
if let Some(src) = ui_budget.status.budget_source.borrow_mut().take() {
src.remove();
}
let bytes = (row.value() * GIB).round() as u64; let bytes = (row.value() * GIB).round() as u64;
settings_request( let ui_fire = ui_budget.clone();
&ui_budget, let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
Request::SetCacheBudget { bytes }, ui_fire.status.budget_source.borrow_mut().take();
"Cache budget updated", settings_request(
"Couldn't set cache budget", &ui_fire,
); Request::SetCacheBudget { bytes },
"Cache budget updated",
"Couldn't set cache budget",
);
});
*ui_budget.status.budget_source.borrow_mut() = Some(src);
}); });
// Purge: confirm, then drop all unpinned cached content via the daemon. // Purge: confirm, then drop all unpinned cached content via the daemon.
@@ -382,6 +430,10 @@ pub(crate) fn wire_settings(
// local path; this button is the way there. // local path; this button is the way there.
let ui_mp = ui.clone(); let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations")); mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
// Import: a doorway, not a setting — everything about it lives on its page.
let ui_import = ui.clone();
import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
} }
/// Run a settings control-socket round-trip (budget / purge) on a worker thread, /// Run a settings control-socket round-trip (budget / purge) on a worker thread,
@@ -513,8 +565,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
refresh_transfers(ui); refresh_transfers(ui);
// Both of these pages show work as it happens, so they follow the tick while // Both of these pages show work as it happens, so they follow the tick while
// they are on screen. Every other page loads on navigation only. // they are on screen. Every other page loads on navigation only.
// A Takeout import outlives the page that started it, so it is polled
// wherever the user has navigated to — otherwise leaving the page means
// never being told it finished.
if ui.stack.visible_child_name().as_deref() == Some("takeout") || ui.takeout.running.get() {
refresh_takeout(ui);
}
match ui.stack.visible_child_name().as_deref() { match ui.stack.visible_child_name().as_deref() {
Some("main") => refresh_quota(ui), Some("main" | "browser") => refresh_quota(ui),
Some("locations") => refresh_locations(ui), Some("locations") => refresh_locations(ui),
Some("activity") => refresh_activity(ui), Some("activity") => refresh_activity(ui),
_ => {} _ => {}
@@ -522,13 +580,11 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
} }
/// How long a quota reading stays fresh. Account storage barely moves, so the /// How long a quota reading stays fresh. Account storage barely moves, so the
/// Settings tick refetches it only this often rather than every 2s. /// active-page tick refetches it only this often rather than every 2s.
const QUOTA_TTL: Duration = Duration::from_secs(60); const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint the Account /// Fetch the account quota (if the last reading is stale) and paint both the
/// storage group. Runs only while Settings is on screen. The group stays hidden /// Settings storage group and Files status bar. A failed fetch leaves the last
/// until a reading lands, so an account the API can't report — or a still-starting
/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last
/// good reading in place. /// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) { pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() { if ui.status.quota_inflight.get() {
@@ -551,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
})) = result })) = result
{ {
ui.status.quota_checked_at.set(Some(Instant::now())); ui.status.quota_checked_at.set(Some(Instant::now()));
let used = used_space.max(0) as u64; paint_account_quota(&ui, max_space, used_space);
if max_space > 0 {
let total = max_space as u64;
let fraction = (used as f64 / total as f64).min(1.0);
ui.status.quota_bar.set_fraction(fraction);
let pct = (fraction * 100.0).round() as u64;
ui.status.quota_label.set_text(&format!(
"{} of {} used ({pct}%)",
human_bytes(used),
human_bytes(total)
));
} else {
ui.status.quota_bar.set_fraction(0.0);
ui.status
.quota_label
.set_text(&format!("{} used", human_bytes(used)));
}
ui.status.quota_group.set_visible(true); ui.status.quota_group.set_visible(true);
} else if ui.status.quota_checked_at.get().is_none() {
// Match Dolphin: capacity information does not occupy the bar until
// the backing observer has real figures.
ui.browser.quota_box.set_visible(false);
} }
}); });
} }
fn paint_account_quota(ui: &Rc<Ui>, max_space: i64, used_space: i64) {
let (fraction, text) = quota_display(max_space, used_space);
ui.status.quota_bar.set_fraction(fraction);
ui.status.quota_label.set_text(&text);
if let Some((fraction, free_text, tooltip)) = quota_status_display(max_space, used_space) {
ui.browser.quota.set_fraction(fraction);
ui.browser.quota.set_tooltip_text(Some(&tooltip));
ui.browser.quota_text.set_label(&free_text);
ui.browser.quota_text.set_tooltip_text(Some(&tooltip));
ui.browser.quota_box.set_visible(true);
} else {
ui.browser.quota_box.set_visible(false);
}
}
fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
let used = used_space.max(0) as u64;
if max_space <= 0 {
return (0.0, format!("{} used", human_bytes(used)));
}
let total = max_space as u64;
let fraction = (used as f64 / total as f64).clamp(0.0, 1.0);
let pct = (fraction * 100.0).round() as u64;
(
fraction,
format!(
"{} of {} used ({pct}%)",
human_bytes(used),
human_bytes(total)
),
)
}
/// Dolphin's status bar shows a bare capacity bar followed by “X free”; the
/// full free/total/percentage sentence is a tooltip rather than inline bar text.
fn quota_status_display(max_space: i64, used_space: i64) -> Option<(f64, String, String)> {
if max_space <= 0 {
return None;
}
let total = max_space as u64;
let used = (used_space.max(0) as u64).min(total);
let free = total.saturating_sub(used);
let fraction = used as f64 / total as f64;
let pct = (fraction * 100.0).round() as u64;
Some((
fraction,
format!("{} free", human_bytes(free)),
format!(
"{} free out of {} ({pct}% used)",
human_bytes(free),
human_bytes(total)
),
))
}
/// Record the mount state seen by the last status poll: gate every control that /// Record the mount state seen by the last status poll: gate every control that
/// needs a live daemon, and notify the desktop when the state actually flips. /// needs a live daemon, and notify the desktop when the state actually flips.
/// ///
@@ -584,6 +683,11 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
ui.browser.new_folder.set_sensitive(mounted); ui.browser.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted); ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted); ui.browser.upload_folder.set_sensitive(mounted);
ui.browser.build_thumbnails.set_sensitive(
mounted
&& (!ui.browser.thumbnail_build_running.get()
|| !ui.browser.thumbnail_cancel_pending.get()),
);
ui.gallery.upload.set_sensitive(mounted); ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted);
@@ -653,6 +757,10 @@ pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[Job
// count falling to zero is what "the sync finished" looks like from here. // count falling to zero is what "the sync finished" looks like from here.
// Jobs are deliberately not counted — a bulk upload retires its scan job and // Jobs are deliberately not counted — a bulk upload retires its scan job and
// starts its upload job mid-flight, which is not a thing finishing. // starts its upload job mid-flight, which is not a thing finishing.
// The Import page reads its progress off the same snapshot rather than
// polling the daemon a second time for numbers already on the wire.
takeout_progress(ui, jobs);
let previous = ui.status.active_transfers.replace(items.len()); let previous = ui.status.active_transfers.replace(items.len());
if items.is_empty() && previous > 0 { if items.is_empty() && previous > 0 {
let files = if previous == 1 { let files = if previous == 1 {
@@ -808,11 +916,14 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
(used as f64 / budget as f64).min(1.0) (used as f64 / budget as f64).min(1.0)
}; };
ui.status.cache_bar.set_fraction(fraction); ui.status.cache_bar.set_fraction(fraction);
ui.status.cache_label.set_text(&format!( // A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
"{} of {} used", // used" reads as a broken read-out, and there is no fraction to
human_bytes(used), // draw against no limit.
human_bytes(budget) ui.status.cache_label.set_text(&if budget == 0 {
)); format!("{} cached — no limit set", human_bytes(used))
} else {
format!("{} of {} used", human_bytes(used), human_bytes(budget))
});
repaint_pins(&ui, &pins, true); repaint_pins(&ui, &pins, true);
} }
// Daemon unreachable (still starting, or down): report not-mounted and // Daemon unreachable (still starting, or down): report not-mounted and
@@ -865,7 +976,15 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
return; return;
} }
for pin in pins { // Only the first page of pins is rendered; the rest sit behind the row
// below, so a device with hundreds of pins doesn't bury the groups after it.
let expanded = ui.status.pins_expanded.get();
let shown = if expanded {
pins.len()
} else {
pins.len().min(PINS_COLLAPSED)
};
for pin in &pins[..shown] {
let name = Path::new(&pin.path) let name = Path::new(&pin.path)
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
@@ -903,4 +1022,70 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
unpin: Some(unpin), unpin: Some(unpin),
}); });
} }
if pins.len() > PINS_COLLAPSED {
let row = adw::ActionRow::builder()
.title(if expanded {
"Show fewer".to_string()
} else {
format!("Show all {} pinned files", pins.len())
})
.activatable(true)
.build();
row.add_suffix(&gtk4::Image::from_icon_name(if expanded {
"go-up-symbolic"
} else {
"go-down-symbolic"
}));
let ui_more = ui.clone();
row.connect_activated(move |_| {
ui_more
.status
.pins_expanded
.set(!ui_more.status.pins_expanded.get());
// The diff guard compares pin *paths*; the same paths render
// differently now, so the baseline has to be dropped for the next
// repaint to actually rebuild.
*ui_more.status.pins_state.borrow_mut() = None;
refresh(&ui_more);
});
ui.status.pins_group.add(&row);
ui.status
.pin_rows
.borrow_mut()
.push(PinRow { row, unpin: None });
}
}
#[cfg(test)]
mod tests {
use super::{quota_display, quota_status_display};
#[test]
fn quota_display_reports_used_total_and_percentage() {
let gib = 1024_i64.pow(3);
let (fraction, text) = quota_display(4 * gib, gib);
assert!((fraction - 0.25).abs() < f64::EPSILON);
assert_eq!(text, "1.0 GiB of 4.0 GiB used (25%)");
}
#[test]
fn quota_display_clamps_bad_api_values() {
assert_eq!(quota_display(0, -1), (0.0, "0 B used".to_string()));
assert_eq!(quota_display(100, 150).0, 1.0);
}
#[test]
fn quota_status_display_matches_dolphin_wording() {
let gib = 1024_i64.pow(3);
assert_eq!(
quota_status_display(4 * gib, gib),
Some((
0.25,
"3.0 GiB free".to_string(),
"3.0 GiB free out of 4.0 GiB (25% used)".to_string()
))
);
assert_eq!(quota_status_display(0, 0), None);
}
} }
+779
View File
@@ -0,0 +1,779 @@
//! The Import page: bringing a Google Photos **Takeout** export into Proton
//! Photos.
//!
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
//! import button), because it is a one-off migration rather than something the
//! photo timeline does every day. It gets a page of its own instead of a dialog
//! for two reasons: an export is a *set* of archives that has to be staged and
//! checked before anything is sent, and the import that follows runs for hours,
//! so its progress needs somewhere to live that the user can navigate away from
//! and come back to.
//!
//! Archives arrive either by drag-and-drop onto the page or through the file
//! chooser; both funnel into [`add_archives`], which is where the `.zip` filter
//! and de-duplication live. Nothing is sent until the user presses Scan or
//! Import — staging is local, so a mis-drop costs nothing.
//!
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
//! and reads live progress off the import's job in the transfer snapshot the
//! Settings tick already fetches (see [`takeout_progress`]).
use crate::*;
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
/// Matching on it is how this page borrows the progress the Settings tick is
/// already polling instead of adding a second poll of its own.
const IMPORT_JOB: &str = "Importing Google Photos";
/// What the archive list says before anything is staged. `repaint_archives`
/// swaps in the count and total once there is something to report, and swaps
/// this back when the list is emptied again.
/// Where the staged set is remembered between runs, under the app's state
/// directory. Staging an export means picking dozens of files out of a file
/// chooser; losing that to a window close (or a crash mid-import) and having to
/// redo it is the kind of small cruelty that makes a migration not get finished.
fn staged_path(ui: &Rc<Ui>) -> PathBuf {
ui.dirs.state_dir().join("takeout-staged.json")
}
/// Read the remembered set, dropping any archive that has since been moved or
/// deleted — a stale path would only fail at launch, and silently is worse.
fn load_staged(ui: &Rc<Ui>) -> Vec<PathBuf> {
let Ok(text) = std::fs::read_to_string(staged_path(ui)) else {
return Vec::new();
};
let paths: Vec<String> = serde_json::from_str(&text).unwrap_or_default();
paths
.into_iter()
.map(PathBuf::from)
.filter(|p| p.is_file())
.collect()
}
/// Remember the staged set. Best-effort: failing to write it costs the user a
/// re-pick next time, which is not worth interrupting them over now.
fn save_staged(ui: &Rc<Ui>) {
let paths: Vec<String> = ui
.takeout
.archives
.borrow()
.iter()
.map(|p| p.display().to_string())
.collect();
let path = staged_path(ui);
let written = serde_json::to_string(&paths)
.map_err(|e| e.to_string())
.and_then(|text| std::fs::write(&path, text).map_err(|e| e.to_string()));
if let Err(e) = written {
tracing::warn!("cannot remember staged Takeout archives: {e}");
}
}
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \
re-running an interrupted import is safe.";
pub(crate) struct TakeoutState {
/// Archives staged for this import, in the order they were added. Sent as
/// one set: Google splits an export across numbered zips and routinely puts
/// a photo in one part and its metadata sidecar in another, so a per-file
/// import would lose album titles and capture times.
pub(crate) archives: RefCell<Vec<PathBuf>>,
/// The staged-archive list and the rows currently rendered in it.
pub(crate) list_group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<adw::PreferencesRow>>,
/// The drop zone, whose `dropzone-active` class follows the drag.
pub(crate) dropzone: gtk4::Box,
pub(crate) scan_button: gtk4::Button,
pub(crate) import_button: gtk4::Button,
pub(crate) cancel_button: gtk4::Button,
pub(crate) clear_button: gtk4::Button,
/// Progress group, hidden unless an import is running.
pub(crate) progress_group: adw::PreferencesGroup,
pub(crate) progress_label: gtk4::Label,
pub(crate) progress_bar: gtk4::ProgressBar,
/// Report group for the last finished run, hidden until there is one.
pub(crate) summary_group: adw::PreferencesGroup,
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
/// can't stack worker threads on the 2s tick.
pub(crate) inflight: Cell<bool>,
/// Whether the daemon reported an import running on the last poll.
pub(crate) running: Cell<bool>,
/// Whether the run in flight was launched as a dry run, so the report can
/// say "would upload" rather than claiming photos were sent. The daemon's
/// summary carries no such flag — it is the same shape either way.
pub(crate) dry_run: Cell<bool>,
}
/// Widgets the Import page hands back for wiring.
pub(crate) struct TakeoutWidgets {
pub(crate) list_group: adw::PreferencesGroup,
pub(crate) dropzone: gtk4::Box,
/// The whole page, which is the drop target: a drag released anywhere on it
/// counts, not only inside the dashed rectangle.
pub(crate) page: gtk4::Widget,
pub(crate) choose_button: gtk4::Button,
pub(crate) scan_button: gtk4::Button,
pub(crate) import_button: gtk4::Button,
pub(crate) cancel_button: gtk4::Button,
pub(crate) clear_button: gtk4::Button,
pub(crate) back_button: gtk4::Button,
pub(crate) progress_group: adw::PreferencesGroup,
pub(crate) progress_label: gtk4::Label,
pub(crate) progress_bar: gtk4::ProgressBar,
pub(crate) summary_group: adw::PreferencesGroup,
}
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
// Header: a back button out to Settings, since this page is reached from
// there and has no sidebar row of its own to navigate back with.
let back_button = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Back to Settings")
.valign(gtk4::Align::Center)
.build();
back_button.add_css_class("flat");
let title = gtk4::Label::builder()
.label("Import from Google Photos")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let subtitle = gtk4::Label::builder()
.label("Add a Google Takeout export to your Proton Photos timeline.")
.halign(gtk4::Align::Start)
.wrap(true)
.xalign(0.0)
.build();
subtitle.add_css_class("dim-label");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
titles.append(&subtitle);
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&back_button);
header.append(&titles);
let header_clamp = adw::Clamp::builder().child(&header).build();
// Drop zone. It is a hint, not the only way in — the button under it does
// the same thing for anyone not dragging, and the drop target covers the
// whole page so a drag released just outside the rectangle still lands.
let drop_icon = gtk4::Image::from_icon_name("folder-download-symbolic");
drop_icon.set_pixel_size(48);
drop_icon.add_css_class("dim-label");
let drop_title = gtk4::Label::builder()
.label("Drop your Takeout .zip files here")
.build();
drop_title.add_css_class("title-4");
let drop_hint = gtk4::Label::builder()
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
.wrap(true)
.justify(gtk4::Justification::Center)
.max_width_chars(48)
.build();
drop_hint.add_css_class("dim-label");
let choose_button = gtk4::Button::builder()
.label("Choose files…")
.halign(gtk4::Align::Center)
.build();
choose_button.add_css_class("pill");
let dropzone = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
dropzone.set_halign(gtk4::Align::Fill);
dropzone.add_css_class("dropzone");
dropzone.append(&drop_icon);
dropzone.append(&drop_title);
dropzone.append(&drop_hint);
dropzone.append(&choose_button);
// Staged archives. Filled by `repaint_archives`, which owns the description
// too (it becomes the count + total once there is something to count); the
// group carries a placeholder row while empty so it never renders as a bare
// title.
let list_group = adw::PreferencesGroup::builder()
.title("Archives to import")
.description(EMPTY_LIST_DESCRIPTION)
.build();
let clear_button = gtk4::Button::builder()
.label("Clear")
.tooltip_text("Remove every staged archive")
.build();
clear_button.add_css_class("flat");
// Actions. Scan is a dry run: it reads the archives' directories and reports
// what an import *would* do, without uploading a byte. It is offered first
// because the import behind it is hours long and hard to take back.
let scan_button = gtk4::Button::builder()
.label("Scan")
.tooltip_text("Check the archives and report what would be imported, without uploading")
.build();
scan_button.add_css_class("pill");
let import_button = gtk4::Button::builder().label("Import").build();
import_button.add_css_class("pill");
import_button.add_css_class("suggested-action");
let cancel_button = gtk4::Button::builder()
.label("Stop import")
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
.build();
cancel_button.add_css_class("pill");
cancel_button.add_css_class("destructive-action");
cancel_button.set_visible(false);
// Clear sits apart from the launch buttons, on the other end of the row: it
// undoes staging, and putting it next to Import invites the wrong click.
let actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
actions.append(&clear_button);
actions.append(&spacer);
actions.append(&cancel_button);
actions.append(&scan_button);
actions.append(&import_button);
// Progress: the import's job from the transfer snapshot, so this page shows
// the same numbers the Settings Activity list does.
let progress_group = adw::PreferencesGroup::builder()
.title("Import in progress")
.visible(false)
.build();
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
progress_box.set_margin_top(8);
progress_box.set_margin_bottom(8);
let progress_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
progress_label.add_css_class("dim-label");
let progress_bar = gtk4::ProgressBar::new();
progress_box.append(&progress_label);
progress_box.append(&progress_bar);
progress_group.add(
&adw::PreferencesRow::builder()
.activatable(false)
.child(&progress_box)
.build(),
);
// Report for the last finished run, kept on screen after it ends: an import
// outlives the window being on this page, and "what happened" is the whole
// reason to come back to it.
let summary_group = adw::PreferencesGroup::builder()
.title("Last import")
.visible(false)
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&dropzone);
groups.append(&list_group);
groups.append(&actions);
groups.append(&progress_group);
groups.append(&summary_group);
let clamp = adw::Clamp::builder()
.maximum_size(640)
.child(&groups)
.build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
page.set_margin_top(18);
page.set_margin_bottom(18);
page.set_margin_start(18);
page.set_margin_end(18);
page.append(&header_clamp);
page.append(&scroll);
let widgets = TakeoutWidgets {
list_group: list_group.clone(),
dropzone: dropzone.clone(),
page: page.clone().upcast(),
choose_button,
scan_button: scan_button.clone(),
import_button: import_button.clone(),
cancel_button: cancel_button.clone(),
clear_button,
back_button,
progress_group: progress_group.clone(),
progress_label: progress_label.clone(),
progress_bar: progress_bar.clone(),
summary_group: summary_group.clone(),
};
(page.upcast(), widgets)
}
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
// Drag-and-drop. The target sits on the whole page rather than the dashed
// rectangle, so a drag released anywhere counts; the rectangle only lights
// up to say where the drop is understood.
let target = gtk4::DropTarget::new(
gtk4::gdk::FileList::static_type(),
gtk4::gdk::DragAction::COPY,
);
let ui_enter = ui.clone();
target.connect_enter(move |_, _, _| {
ui_enter.takeout.dropzone.add_css_class("dropzone-active");
gtk4::gdk::DragAction::COPY
});
let ui_leave = ui.clone();
target.connect_leave(move |_| {
ui_leave
.takeout
.dropzone
.remove_css_class("dropzone-active");
});
let ui_drop = ui.clone();
target.connect_drop(move |_, value, _, _| {
ui_drop.takeout.dropzone.remove_css_class("dropzone-active");
let Ok(files) = value.get::<gtk4::gdk::FileList>() else {
return false;
};
let paths: Vec<PathBuf> = files.files().iter().filter_map(|f| f.path()).collect();
add_archives(&ui_drop, paths);
true
});
widgets.page.add_controller(target);
let ui_choose = ui.clone();
widgets.choose_button.connect_clicked(move |_| {
// Multi-select on purpose: the whole export goes in one request.
let dialog = gtk4::FileDialog::builder()
.title("Select your Google Takeout archives")
.build();
let filter = gtk4::FileFilter::new();
filter.set_name(Some("Takeout archives"));
filter.add_pattern("*.zip");
let filters = gio::ListStore::new::<gtk4::FileFilter>();
filters.append(&filter);
dialog.set_filters(Some(&filters));
let ui = ui_choose.clone();
dialog.open_multiple(
ui_window(&ui_choose).as_ref(),
gio::Cancellable::NONE,
move |res| {
let Ok(files) = res else { return };
let paths: Vec<PathBuf> = (0..files.n_items())
.filter_map(|index| files.item(index))
.filter_map(|object| object.downcast::<gio::File>().ok())
.filter_map(|file| file.path())
.collect();
add_archives(&ui, paths);
},
);
});
let ui_clear = ui.clone();
widgets.clear_button.connect_clicked(move |_| {
ui_clear.takeout.archives.borrow_mut().clear();
repaint_archives(&ui_clear);
});
let ui_scan = ui.clone();
widgets
.scan_button
.connect_clicked(move |_| start_import(&ui_scan, true));
// The import itself is confirmed: it uploads a whole photo library, which is
// hours of bandwidth and a large change to the account's timeline.
let ui_import = ui.clone();
widgets.import_button.connect_clicked(move |_| {
let count = ui_import.takeout.archives.borrow().len();
let ui = ui_import.clone();
let dialog = adw::AlertDialog::builder()
.heading("Import this export?")
.body(format!(
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
appear in the export. This can take hours; you can stop it at any point and \
what has been uploaded stays. Photos you already have are skipped.",
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("import", "Import");
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(None, move |_, resp| {
if resp == "import" {
start_import(&ui, false);
}
});
dialog.present(ui_window(&ui_import).as_ref());
});
let ui_cancel = ui.clone();
widgets.cancel_button.connect_clicked(move |_| {
let ui = ui_cancel.clone();
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::CancelImport);
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't stop the import", &message, kind);
}
_ => toast_error(
&ui,
"Couldn't stop the import",
"The mount service didn't respond.",
),
}
});
});
let ui_back = ui.clone();
widgets
.back_button
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
// Restore whatever was staged when the window last closed, before the first
// paint, so the page comes up as the user left it.
*ui.takeout.archives.borrow_mut() = load_staged(ui);
repaint_archives(ui);
}
/// Stage `paths`, keeping only `.zip` files and skipping ones already staged.
///
/// Both the chooser and the drop target land here, so the filter is stated once.
/// A dropped folder (an already-extracted export) is rejected rather than walked:
/// the scan reads the archives' central directories, and an extracted tree has
/// none — silently importing half of it would be worse than saying no.
pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
let mut added = 0usize;
let mut rejected = 0usize;
{
let mut staged = ui.takeout.archives.borrow_mut();
for path in paths {
let is_zip = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
if !is_zip || !path.is_file() {
rejected += 1;
continue;
}
if staged.contains(&path) {
continue;
}
staged.push(path);
added += 1;
}
// Numbered parts drop in whatever order the file manager hands them
// over; sorting keeps the list readable and the request stable.
staged.sort();
}
repaint_archives(ui);
if rejected > 0 {
toast(
ui,
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
);
} else if added > 0 {
toast(ui, &format!("Added {added} archive(s)"));
}
}
/// Rebuild the staged-archive list and re-derive which actions are available.
pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
for row in ui.takeout.rows.borrow_mut().drain(..) {
ui.takeout.list_group.remove(&row);
}
let staged = ui.takeout.archives.borrow().clone();
let mut rows: Vec<adw::PreferencesRow> = Vec::new();
if staged.is_empty() {
ui.takeout
.list_group
.set_description(Some(EMPTY_LIST_DESCRIPTION));
let row = dim_row("No archives added yet.");
ui.takeout.list_group.add(&row);
rows.push(row.upcast());
} else {
let mut total = 0u64;
for path in &staged {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
total += size;
let row = adw::ActionRow::builder()
.title(
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string()),
)
.subtitle(human_bytes(size))
.build();
let remove = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Remove from this import")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_remove = ui.clone();
let target = path.clone();
remove.connect_clicked(move |_| {
ui_remove
.takeout
.archives
.borrow_mut()
.retain(|p| *p != target);
repaint_archives(&ui_remove);
});
row.add_suffix(&remove);
ui.takeout.list_group.add(&row);
rows.push(row.upcast());
}
ui.takeout.list_group.set_description(Some(&format!(
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
staged.len(),
human_bytes(total)
)));
}
*ui.takeout.rows.borrow_mut() = rows;
save_staged(ui);
sync_takeout_actions(ui);
}
/// Enable exactly the actions that make sense right now: nothing to launch
/// without archives, nothing to launch while a run is in flight, and Stop only
/// while there is something to stop.
fn sync_takeout_actions(ui: &Rc<Ui>) {
let has_files = !ui.takeout.archives.borrow().is_empty();
let running = ui.takeout.running.get();
ui.takeout.scan_button.set_sensitive(has_files && !running);
ui.takeout
.import_button
.set_sensitive(has_files && !running);
ui.takeout.clear_button.set_sensitive(has_files && !running);
ui.takeout.cancel_button.set_visible(running);
}
/// Hand the staged set to the daemon. It acks immediately and works in the
/// background — an export is far past any socket timeout — so success here means
/// "started", and the page switches over to reporting progress.
fn start_import(ui: &Rc<Ui>, dry_run: bool) {
let archives: Vec<String> = ui
.takeout
.archives
.borrow()
.iter()
.map(|p| p.display().to_string())
.collect();
if archives.is_empty() {
return;
}
ui.takeout.dry_run.set(dry_run);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ImportTakeout { archives, dry_run },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
ui.takeout.running.set(true);
ui.takeout.summary_group.set_visible(false);
ui.takeout.progress_group.set_visible(true);
ui.takeout.progress_bar.set_fraction(0.0);
ui.takeout.progress_label.set_text(if dry_run {
"Reading archives…"
} else {
"Starting import…"
});
sync_takeout_actions(&ui);
toast(
&ui,
if dry_run {
"Scanning the export…"
} else {
"Import started — you can leave this page, it keeps running."
},
);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't start the import", &message, kind);
}
_ => toast_error(
&ui,
"Couldn't start the import",
"The mount service didn't respond.",
),
}
});
}
/// Poll the daemon for the import's state while the page is on screen, and paint
/// the report once a run has finished. Cheap and inflight-guarded, like the other
/// tick polls.
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
if ui.takeout.inflight.get() {
return;
}
ui.takeout.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ImportStatus);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.takeout.inflight.set(false);
let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
return;
};
let was_running = ui.takeout.running.replace(running);
if running {
ui.takeout.progress_group.set_visible(true);
} else {
ui.takeout.progress_group.set_visible(false);
// An import runs for hours, so its ending is announced rather than
// left to be discovered: a toast for whoever is looking at the
// window, and a desktop notification for whoever is not.
if was_running {
let dry = ui.takeout.dry_run.get();
let headline = if dry {
"Scan finished"
} else {
"Import finished"
};
toast(&ui, headline);
if !dry {
let body = match &summary {
Some(s) if s.failed > 0 => {
format!("{} photos added, {} failed.", s.uploaded, s.failed)
}
Some(s) if s.cancelled => {
format!("Stopped after {} photos.", s.uploaded)
}
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
None => "The Google Photos import has finished.".to_string(),
};
notify("takeout-import", headline, &body);
}
}
}
if was_running != running {
sync_takeout_actions(&ui);
}
match summary {
Some(summary) => repaint_summary(&ui, &summary),
None => ui.takeout.summary_group.set_visible(false),
}
});
}
/// Paint the last run's report.
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
ui.takeout.summary_group.remove(&row);
}
let dry = ui.takeout.dry_run.get();
ui.takeout
.summary_group
.set_title(if dry { "Scan result" } else { "Last import" });
ui.takeout.summary_group.set_description(Some(if dry {
"Nothing was uploaded — this is what an import would do."
} else if summary.cancelled {
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
continue where it left off."
} else {
"Finished."
}));
// Only lines that carry information: a run with no failures should not have
// to show a "0 failed" row for the user to read past.
let mut lines: Vec<(String, String)> = vec![
("Photos in the export".into(), summary.found.to_string()),
(
if dry { "Would upload" } else { "Uploaded" }.into(),
summary.uploaded.to_string(),
),
];
if summary.duplicates > 0 {
lines.push((
"Already in Proton Photos".into(),
summary.duplicates.to_string(),
));
}
if summary.albums_created > 0 {
lines.push((
if dry {
"Albums to create"
} else {
"Albums created"
}
.into(),
summary.albums_created.to_string(),
));
}
if summary.album_links > 0 {
lines.push((
"Photos filed into albums".into(),
summary.album_links.to_string(),
));
}
if summary.skipped_trashed > 0 {
lines.push((
"Skipped (in Google's trash)".into(),
summary.skipped_trashed.to_string(),
));
}
if summary.bytes > 0 {
lines.push((
if dry {
"Data to upload"
} else {
"Data uploaded"
}
.into(),
human_bytes(summary.bytes),
));
}
if summary.failed > 0 {
lines.push(("Failed".into(), summary.failed.to_string()));
}
let mut rows = Vec::new();
for (title, value) in lines {
let row = adw::ActionRow::builder()
.title(title)
.subtitle(value)
.build();
row.add_css_class("property");
ui.takeout.summary_group.add(&row);
rows.push(row);
}
*ui.takeout.summary_rows.borrow_mut() = rows;
ui.takeout.summary_group.set_visible(true);
}
/// Paint the import's progress from the transfer snapshot the Settings tick
/// already fetched, so this page costs no extra poll. A job with no known total
/// pulses instead of showing a fraction, the same as the Activity list.
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
return;
};
ui.takeout.progress_group.set_visible(true);
if job.total > 0 {
ui.takeout
.progress_bar
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
ui.takeout
.progress_label
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
} else {
ui.takeout.progress_bar.pulse();
ui.takeout
.progress_label
.set_text(if job.detail.is_empty() {
"Working…"
} else {
&job.detail
});
}
}
+28 -24
View File
@@ -122,7 +122,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Butto
factory.connect_setup(move |_, item| { factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let icon = gtk4::Image::builder().pixel_size(32).build(); let thumbnail = file_thumbnail_widget(40, 32);
let name = gtk4::Label::builder() let name = gtk4::Label::builder()
.halign(gtk4::Align::Start) .halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle) .ellipsize(gtk4::pango::EllipsizeMode::Middle)
@@ -169,35 +169,38 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Butto
row.set_margin_bottom(6); row.set_margin_bottom(6);
row.set_margin_start(6); row.set_margin_start(6);
row.set_margin_end(6); row.set_margin_end(6);
row.append(&icon); row.append(&thumbnail);
row.append(&text); row.append(&text);
row.append(&restore); row.append(&restore);
row.append(&purge); row.append(&purge);
item.set_child(Some(&row)); item.set_child(Some(&row));
}); });
factory.connect_bind(|_, item| { factory.connect_bind({
let item = item.downcast_ref::<gtk4::ListItem>().unwrap(); let ui = ui.clone();
let Some(entry) = bound_entry(item) else { move |_, item| {
return; let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
}; let Some(entry) = bound_entry(item) else {
let row = item.child().and_downcast::<gtk4::Box>().unwrap(); return;
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
return;
};
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
let kind = if entry.is_dir {
"Folder".to_string()
} else {
human_bytes(entry.size)
}; };
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); let row = item.child().and_downcast::<gtk4::Box>().unwrap();
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
return;
};
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
let kind = if entry.is_dir {
"Folder".to_string()
} else {
human_bytes(entry.size)
};
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
}
} }
}); });
list.set_factory(Some(&factory)); list.set_factory(Some(&factory));
@@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
/// Fetch the trash listing and repaint the page. /// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) { pub(crate) fn load_trash(ui: &Rc<Ui>) {
cancel_file_thumbnails(ui);
// Drop the old rows first: a stale row here would offer Restore on something // Drop the old rows first: a stale row here would offer Restore on something
// that may already be gone. // that may already be gone.
ui.trash.model.remove_all(); ui.trash.model.remove_all();
+15 -16
View File
@@ -11,8 +11,8 @@ pub(crate) struct DetailsState {
pub(crate) details_suppress: Cell<bool>, pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so /// The grid's and list's selection models. Both wrap the one browser model, so
/// a selection in either drives the details pane. /// a selection in either drives the details pane.
pub(crate) grid_selection: gtk4::SingleSelection, pub(crate) grid_selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::SingleSelection, pub(crate) list_selection: gtk4::MultiSelection,
} }
/// The widgets in the browser's details pane that a selection repaints. /// The widgets in the browser's details pane that a selection repaints.
@@ -158,24 +158,22 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
ui.details.list_selection.clone(), ui.details.list_selection.clone(),
] { ] {
let ui_sel = ui.clone(); let ui_sel = ui.clone();
selection.connect_selection_changed(move |sel, _, _| { selection.connect_selection_changed(move |_, _, _| {
match entry_at(sel.model().as_ref(), sel.selected()) { // The details pane describes *one* entry. A batch is the bulk bar's
Some(entry) => show_details(&ui_sel, &entry), // business, so the pane steps aside rather than picking a member of
None => hide_details(&ui_sel), // the selection to speak for the rest.
let entries = selected_entries(&ui_sel);
match entries.as_slice() {
[entry] => show_details(&ui_sel, entry),
_ => hide_details(&ui_sel),
} }
sync_bulk_bar(&ui_sel);
}); });
} }
let ui_close = ui.clone(); let ui_close = ui.clone();
ui.details.details.close_button.connect_clicked(move |_| { ui.details.details.close_button.connect_clicked(move |_| {
ui_close clear_selection(&ui_close);
.details
.grid_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
ui_close
.details
.list_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
hide_details(&ui_close); hide_details(&ui_close);
}); });
@@ -289,8 +287,9 @@ pub(crate) fn hide_details(ui: &Rc<Ui>) {
*ui.details.details_entry.borrow_mut() = None; *ui.details.details_entry.borrow_mut() = None;
} }
/// The entry highlighted in whichever browser view is on screen, if any. Backs the /// The single entry the details pane is showing, if any. Backs the actions that
/// F2 / Delete shortcuts. /// only make sense on one entry (rename, versions); the batch-capable ones ask
/// [`selected_entries`] instead.
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> { pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
ui.details.details_entry.borrow().clone() ui.details.details_entry.borrow().clone()
} }
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod details; pub(crate) mod details;
pub(crate) mod share_dialog; pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
pub(crate) mod versions_dialog; pub(crate) mod versions_dialog;
@@ -0,0 +1,469 @@
//! Shared thumbnails for ordinary Drive image files.
//!
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
//! File listings need a smaller, reusable surface: one loader backs My files,
//! search, Shared and Trash, with modification-time cache keys
//! so a replaced image never flashes its previous contents.
use crate::*;
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256;
const THUMB_MISSING_MAX: usize = 2048;
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
const THUMB_RETRY: Duration = Duration::from_secs(2);
pub(crate) struct FileThumbnailState {
textures: RefCell<HashMap<String, gtk4::gdk::Texture>>,
texture_order: RefCell<VecDeque<String>>,
missing: RefCell<HashSet<String>>,
missing_order: RefCell<VecDeque<String>>,
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
queue: RefCell<VecDeque<FileThumbRequest>>,
inflight: Cell<bool>,
source: RefCell<Option<glib::SourceId>>,
generation: Cell<u64>,
generation_loading: Cell<bool>,
}
#[derive(Debug, PartialEq, Eq)]
enum ReplyDisposition {
Ignore,
Load,
Retry,
Missing,
}
fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition {
if !wanted {
ReplyDisposition::Ignore
} else if has_path {
ReplyDisposition::Load
} else if pending {
ReplyDisposition::Retry
} else {
ReplyDisposition::Missing
}
}
impl FileThumbnailState {
pub(crate) fn new() -> Self {
Self {
textures: RefCell::new(HashMap::new()),
texture_order: RefCell::new(VecDeque::new()),
missing: RefCell::new(HashSet::new()),
missing_order: RefCell::new(VecDeque::new()),
wanted: RefCell::new(HashMap::new()),
queue: RefCell::new(VecDeque::new()),
inflight: Cell::new(false),
source: RefCell::new(None),
generation: Cell::new(0),
generation_loading: Cell::new(false),
}
}
fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) {
let mut textures = self.textures.borrow_mut();
let mut order = self.texture_order.borrow_mut();
if textures.insert(key.to_string(), texture).is_none() {
order.push_back(key.to_string());
}
while order.len() > THUMB_CACHE_MAX {
if let Some(old) = order.pop_front() {
textures.remove(&old);
}
}
}
fn store_missing(&self, key: String) {
let mut missing = self.missing.borrow_mut();
let mut order = self.missing_order.borrow_mut();
if missing.insert(key.clone()) {
order.push_back(key);
}
while order.len() > THUMB_MISSING_MAX {
if let Some(old) = order.pop_front() {
missing.remove(&old);
}
}
}
}
/// Abandon every opportunistic ordinary-file thumbnail associated with the
/// listing being left. Advancing a wire generation lets the daemon cancel work
/// even when an older request reaches it after this one.
pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
source.remove();
}
ui.file_thumbs.queue.borrow_mut().clear();
ui.file_thumbs.wanted.borrow_mut().clear();
let generation = ui.file_thumbs.generation.replace(0);
// The request is useful even when its reply is not. `spawn_request` owns the
// socket round-trip on a worker thread, so dropping the receiver is safe.
if generation != 0 {
drop(spawn_request(
ui.dirs.control_socket(),
Request::CancelFileThumbs {
generation: generation.saturating_add(1),
},
));
}
}
/// A square thumbnail surface with a generic file icon underneath the picture.
/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry.
pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay {
let fallback = gtk4::Image::builder().pixel_size(fallback_size).build();
let picture = gtk4::Picture::builder()
.width_request(size)
.height_request(size)
.content_fit(gtk4::ContentFit::Cover)
.can_shrink(true)
.visible(false)
.build();
picture.add_css_class("file-thumbnail");
let overlay = gtk4::Overlay::new();
overlay.set_width_request(size);
overlay.set_height_request(size);
overlay.set_halign(gtk4::Align::Center);
overlay.set_valign(gtk4::Align::Center);
overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.set_child(Some(&fallback));
overlay.add_overlay(&picture);
overlay
}
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
/// these requests in place and rebinding the model is enough to implement the
/// status-bar zoom without downloading or decoding an image again.
pub(crate) fn resize_file_thumbnail(widget: &gtk4::Overlay, size: i32, fallback_size: i32) {
widget.set_width_request(size);
widget.set_height_request(size);
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
return;
};
fallback.set_pixel_size(fallback_size);
if let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() {
picture.set_width_request(size);
picture.set_height_request(size);
}
}
/// Bind a thumbnail surface to one entry, painting a cached texture immediately
/// or queueing an on-demand daemon request. Non-images retain the generic icon.
pub(crate) fn bind_file_thumbnail(
ui: &Rc<Ui>,
widget: &gtk4::Overlay,
entry: &DirEntry,
symbolic_fallback: bool,
) {
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
return;
};
// The browser may add a status badge after the picture. Locate the picture
// immediately after the base fallback instead of assuming it is the last
// overlay child.
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
return;
};
let fallback_name = if symbolic_fallback {
format!("{}-symbolic", icon_base_for(entry))
} else {
icon_base_for(entry).to_string()
};
fallback.set_icon_name(Some(&fallback_name));
fallback.set_visible(true);
picture.set_visible(false);
picture.set_paintable(gtk4::gdk::Paintable::NONE);
// The widget name is a cheap binding token. List factories recycle their
// children; a reply for the previous entry must not paint into the new one.
let key = thumbnail_key(&entry.uid, entry.modified);
picture.set_widget_name(&key);
remove_waiter(ui, &picture);
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
return;
}
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
paint(&picture, &key, texture);
return;
}
if ui.file_thumbs.missing.borrow().contains(&key) {
return;
}
ui.file_thumbs
.wanted
.borrow_mut()
.entry(key)
.or_default()
.push(picture);
let mut queue = ui.file_thumbs.queue.borrow_mut();
queue.retain(|item| item.uid != entry.uid);
queue.push_back(FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
});
drop(queue);
schedule_file_thumbs(ui);
}
/// Create and immediately bind a thumbnail for a non-recycled row.
pub(crate) fn file_thumbnail(
ui: &Rc<Ui>,
entry: &DirEntry,
size: i32,
fallback_size: i32,
symbolic_fallback: bool,
) -> gtk4::Overlay {
let widget = file_thumbnail_widget(size, fallback_size);
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
widget
}
/// Whether the file name denotes an image format for which Proton or the local
/// decoder can reasonably provide a thumbnail.
pub(crate) fn is_image_name(name: &str) -> bool {
is_thumbnail_image_name(name)
}
fn thumbnail_key(uid: &str, modified: i64) -> String {
format!("{uid}:{modified}")
}
fn remove_waiter(ui: &Rc<Ui>, picture: &gtk4::Picture) {
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
wanted.retain(|_, pictures| {
pictures.retain(|candidate| candidate != picture);
!pictures.is_empty()
});
}
fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::gdk::Texture) {
if picture.widget_name() != key {
return;
}
picture.set_paintable(Some(texture));
picture.set_visible(true);
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
{
fallback.set_visible(false);
}
}
fn schedule_file_thumbs(ui: &Rc<Ui>) {
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
return;
}
if ui.file_thumbs.generation.get() == 0 {
reserve_file_thumb_generation(ui);
return;
}
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
source.remove();
}
let ui_flush = ui.clone();
let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || {
ui_flush.file_thumbs.source.borrow_mut().take();
flush_file_thumbs(&ui_flush);
});
*ui.file_thumbs.source.borrow_mut() = Some(source);
}
fn reserve_file_thumb_generation(ui: &Rc<Ui>) {
if ui.file_thumbs.generation_loading.replace(true) {
return;
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ReserveFileThumbGeneration,
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.file_thumbs.generation_loading.set(false);
match result {
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
ui.file_thumbs.generation.set(generation);
schedule_file_thumbs(&ui);
}
_ => retry_file_thumb_generation(&ui),
}
});
}
fn retry_file_thumb_generation(ui: &Rc<Ui>) {
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
source.remove();
}
let ui_retry = ui.clone();
let source = glib::timeout_add_local_once(THUMB_RETRY, move || {
ui_retry.file_thumbs.source.borrow_mut().take();
schedule_file_thumbs(&ui_retry);
});
*ui.file_thumbs.source.borrow_mut() = Some(source);
}
fn flush_file_thumbs(ui: &Rc<Ui>) {
if ui.file_thumbs.inflight.get() {
return;
}
let items: Vec<FileThumbRequest> = {
let mut queue = ui.file_thumbs.queue.borrow_mut();
let wanted = ui.file_thumbs.wanted.borrow();
let mut batch = Vec::new();
while batch.len() < THUMB_BATCH {
let Some(item) = queue.pop_front() else {
break;
};
if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) {
batch.push(item);
}
}
batch
};
if items.is_empty() {
return;
}
ui.file_thumbs.inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::FileThumbs {
items: items.clone(),
generation: ui.file_thumbs.generation.get(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.file_thumbs.inflight.set(false);
match result {
Ok(Ok(Response::Thumbs { items: replies })) => {
let mut requested: HashMap<String, FileThumbRequest> = items
.iter()
.cloned()
.map(|item| (item.uid.clone(), item))
.collect();
let mut pending = Vec::new();
for reply in replies {
let Some(request) = requested.remove(&reply.uid) else {
continue;
};
let key = thumbnail_key(&request.uid, request.modified);
let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
match reply_disposition(wanted, reply.path.is_some(), reply.pending) {
ReplyDisposition::Ignore => continue,
ReplyDisposition::Load => {
let Some(path) = reply.path else { continue };
match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
ui.file_thumbs.store_texture(&key, texture.clone());
if let Some(pictures) =
ui.file_thumbs.wanted.borrow_mut().remove(&key)
{
for picture in pictures {
paint(&picture, &key, &texture);
}
}
}
Err(e) => {
tracing::debug!("cannot decode file thumbnail {path}: {e}");
ui.file_thumbs.store_missing(key.clone());
ui.file_thumbs.wanted.borrow_mut().remove(&key);
}
}
}
ReplyDisposition::Retry => pending.push(request),
ReplyDisposition::Missing => {
ui.file_thumbs.store_missing(key.clone());
ui.file_thumbs.wanted.borrow_mut().remove(&key);
}
}
}
pending.extend(requested.into_values());
if !pending.is_empty() {
retry_file_thumbs(&ui, pending);
}
}
Ok(Ok(Response::FileThumbsStale)) => {
ui.file_thumbs.generation.set(0);
retry_file_thumbs(&ui, items);
}
Ok(Ok(Response::Error { message, .. })) => {
tracing::debug!("file thumbnails failed: {message}");
retry_file_thumbs(&ui, items);
}
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
tracing::debug!("file thumbnails: no reply");
retry_file_thumbs(&ui, items);
}
}
schedule_file_thumbs(&ui);
});
}
fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
let ui = ui.clone();
glib::timeout_add_local_once(THUMB_RETRY, move || {
let wanted = ui.file_thumbs.wanted.borrow();
let retry: Vec<FileThumbRequest> = items
.into_iter()
.filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified)))
.collect();
drop(wanted);
ui.file_thumbs.queue.borrow_mut().extend(retry);
schedule_file_thumbs(&ui);
});
}
#[cfg(test)]
mod tests {
use super::{ReplyDisposition, is_image_name, reply_disposition};
#[test]
fn image_extensions_are_case_insensitive_and_specific() {
for name in [
"photo.jpg",
"SCAN.PNG",
"animation.Gif",
"archive.tiff",
"camera.nef",
"negative.CR3",
"camera.RAW",
] {
assert!(is_image_name(name), "{name}");
}
for name in [
"notes.txt",
"movie.mp4",
"image.jpg.zip",
"jpg",
"camera.heic",
"frame.avif",
"graphic.svg",
"camera.rw",
] {
assert!(!is_image_name(name), "{name}");
}
}
#[test]
fn cancelled_listing_replies_cannot_become_permanent_misses() {
assert_eq!(
reply_disposition(false, false, false),
ReplyDisposition::Ignore
);
assert_eq!(
reply_disposition(true, false, false),
ReplyDisposition::Missing
);
}
}
+15 -172
View File
@@ -1,26 +1,23 @@
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher. //! `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 //! 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 //! round-trip and then exits, so there is no main loop to keep off. Talking to
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive //! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
//! activation policy, so the two front ends cannot drift on what "open" means. //! 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 //! 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 //! 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 //! and accepts free text, and any text that matches no entry becomes the next
//! search. Escape (an empty selection) ends it. //! 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 std::path::{Path, PathBuf};
use pdfs_core::config::AppDirs; 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::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;
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits}; use crate::query;
/// Guard against a launcher that keeps handing back text we cannot satisfy — /// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever. /// without it a scripted (non-interactive) menu could spin forever.
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.to_string() .to_string()
})?; })?;
let mountpoint = match request(&socket, Request::Status)? { let mountpoint = query::mountpoint(&socket, &dirs);
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
_ => dirs.default_mountpoint(),
};
let mut query = options let mut query = options
.query .query
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.filter(|q| !q.is_empty()); .filter(|q| !q.is_empty());
for _ in 0..MAX_ROUNDS { for _ in 0..MAX_ROUNDS {
let hits = match &query { let hits = match &query {
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?, Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
None => pins(&socket)?, None => query::pins(&socket)?,
}; };
let items: Vec<MenuItem> = if hits.is_empty() { let items: Vec<MenuItem> = if hits.is_empty() {
vec![MenuItem::new(placeholder(query.as_deref()), None)] vec![MenuItem::new(placeholder(query.as_deref()), None)]
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
query = None; query = None;
continue; 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. // No entry matched what was typed: treat it as the next query.
MenuChoice::Custom(text) => query = Some(text), 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 /// 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 /// is matched back, and two files of the same name in the same folder listing
/// would otherwise be indistinguishable. /// would otherwise be indistinguishable.
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
label = format!("{} · {} ({suffix})", hit.name(), hit.location()); label = format!("{} · {} ({suffix})", hit.name(), hit.location());
suffix += 1; suffix += 1;
} }
items.push(MenuItem::new(label, Some(icon_name(hit)))); items.push(MenuItem::new(label, Some(query::icon_name(hit))));
} }
items 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use pdfs_core::control::LocalHit; use pdfs_core::control::{LocalHit, SearchHit};
fn drive(name: &str, path: &str, is_dir: bool) -> Hit { fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
Hit::Drive(SearchHit { Hit::Drive(SearchHit {
@@ -331,18 +188,4 @@ mod tests {
assert!(placeholder(None).contains("type to search")); assert!(placeholder(None).contains("type to search"));
assert!(placeholder(Some("md")).contains("md")); 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"
);
}
} }
+372
View File
@@ -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"
);
}
}
+43 -5
View File
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
mod activation; mod activation;
mod dmenu; mod dmenu;
mod fzf;
mod query;
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; 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 /// 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 /// — 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. /// local file is already on disk — but they share one keyboard cursor.
#[derive(Clone)] #[derive(Clone, serde::Serialize, serde::Deserialize)]
enum Hit { enum Hit {
Drive(SearchHit), Drive(SearchHit),
Local(LocalHit), Local(LocalHit),
@@ -341,12 +343,20 @@ Options:
rofi, …) instead of the built-in GTK window. Also rofi, …) instead of the built-in GTK window. Also
settable permanently as \"prompt\": { \"mode\": \"dmenu\" } settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
in config.json. 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. --gtk Force the built-in window, overriding that setting.
--menu <COMMAND> Launcher command line for --dmenu, e.g. --menu <COMMAND> Launcher command line for --dmenu, e.g.
--menu 'fuzzel --dmenu --width 60'. Overrides --menu 'fuzzel --dmenu --width 60'. Overrides
\"prompt\": { \"menu\": [...] }. \"prompt\": { \"menu\": [...] }.
--query <TEXT> Search for TEXT immediately instead of opening on the --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. -h, --help Show this help.
"; ";
@@ -356,6 +366,12 @@ struct Args {
mode: Option<PromptMode>, mode: Option<PromptMode>,
menu: Option<Vec<String>>, menu: Option<Vec<String>>,
query: Option<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> { 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, mode: None,
menu: None, menu: None,
query: None, query: None,
feed: None,
inner: false,
}; };
let mut argv = argv.peekable(); let mut argv = argv.peekable();
while let Some(arg) = argv.next() { while let Some(arg) = argv.next() {
match arg.as_str() { match arg.as_str() {
"--dmenu" => args.mode = Some(PromptMode::Dmenu), "--dmenu" => args.mode = Some(PromptMode::Dmenu),
"--fzf" => args.mode = Some(PromptMode::Fzf),
"--gtk" => args.mode = Some(PromptMode::Gtk), "--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 // A launcher command is one shell-ish string so it can live in a
// keybinding; splitting on whitespace is enough for flags, and a // keybinding; splitting on whitespace is enough for flags, and a
// launcher argument needing spaces belongs in config.json. // 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 // The stored mode makes an existing keybinding switch front ends without
// being re-bound; an explicit flag still wins. // being re-bound; an explicit flag still wins.
let stored = AppDirs::new() let stored = AppDirs::new()
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode()) .map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
.unwrap_or_default(); .unwrap_or_default();
if args.mode.unwrap_or(stored) == PromptMode::Dmenu { let launcher = match args.mode.unwrap_or(stored) {
return match dmenu::run(dmenu::Options { PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
menu: args.menu, menu: args.menu,
query: args.query, 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, Ok(()) => glib::ExitCode::SUCCESS,
Err(message) => { Err(message) => {
eprintln!("pdfs-prompt: {message}"); eprintln!("pdfs-prompt: {message}");
+194
View File
@@ -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");
}
}
+10
View File
@@ -2650,6 +2650,16 @@ collisions without losing the source. Simple rename-only and move-only calls
remain synchronous. The managed live suite subsequently passed the replacement remain synchronous. The managed live suite subsequently passed the replacement
and combined move/rename cases. 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 ## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
+243
View File
@@ -9,6 +9,245 @@ 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 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). scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
Nothing yet.
## [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.
Schema: **28**. Builds against the published `proton-sdk` / `proton-drive-rs` **0.6.1** — the album
writes this release needs are in the registry crates, so the local `[patch.crates-io]` shim the
feature was developed against is gone and no local path dependency remains.
### Added
- **Import a Google Photos Takeout export into Proton Photos** — `pdfs import-google-photos
takeout-*.zip` (add `--dry-run` to see what it would do, `--wait` to follow it), or the import
button in the Photos header, which multi-selects the export's zips. Albums in the export are
recreated as Proton albums and their photos filed into them; year folders and `Archive/` import
into the timeline; `Trash/` is skipped. Capture times and favourites come from Google's per-photo
JSON sidecars, including the truncated and `(N)`-swapped sidecar names Google produces.
- Photos already on the account are **not re-uploaded**: the import asks the photos volume which of
the export's names exist (batched, 150 per request), reads and SHA-1s only those files, and skips
the ones whose content digest matches too — Proton's own duplicate rule. A library already partly
synced by Proton Photos on a phone therefore imports only what is missing, and an interrupted
import resumes by being run again. Google keeps a full copy of a photo in every album folder it
appears in; those fold into one upload that joins several albums.
- The import streams each photo straight out of its zip, so an export needs no scratch space to
unpack into, and reports as a job in `pdfs transfers` and the GUI's activity view. `pdfs
import-status` shows the counts, `pdfs cancel-import` stops it — a cancelled run still files what
it uploaded into its albums.
- **An Import page in the GUI** (Settings → *Import from Google Photos*, or the import button in
the Photos header). Archives are staged by drag-and-drop onto the page or through the file
chooser, listed with their sizes so the set can be checked before anything is sent, and launched
as either a Scan (dry run) or a confirmed Import. The page shows live progress off the import's
job, keeps the finished run's report on screen, and can stop a run in flight; because the import
outlives the page, it is polled wherever the user navigates and its completion is reported as a
desktop notification.
- SDK (`proton-sdk-rs` **0.6.1**): album **writes** — `ProtonPhotosClient::create_album` and
`add_photos_to_album`, ported from the TypeScript SDK (upstream C# has no album write API), plus
batch duplicate detection (`find_duplicates_many`, `name_collisions`). The workspace deps moved
`"0.6.0"` → `"0.6.1"` and `Cargo.lock` re-resolved to the registry crates. Adding a photo from an
album *shared with us* (a different volume) is still unported and fails that photo's outcome.
- **Multi-select in Files.** Both views select a batch (click-drag, Ctrl-click, Shift-click); a bar
slides in reporting the count and offering Move to Trash and offline on/off for the whole
selection. Delete acts on everything selected, Esc clears it, and right-clicking inside a
selection offers the batch actions rather than the one row under the pointer. Actions that need a
single subject — rename, move, share, revisions — stay single-entry, and the details pane steps
aside for a batch instead of speaking for it.
- **Undo on trash.** "Moved *X* to Trash" now carries an Undo that restores the batch, single files
included.
- **The empty states are a place to act**: an empty folder offers Upload files / New folder, and an
empty photo timeline offers Upload photos / Import from Google Photos, instead of describing what
the user could go and do elsewhere.
- **Ctrl+F works from any page**, navigating to Files and focusing the search box, rather than being
silently inert everywhere but Files.
- Staged Takeout archives survive closing the window; archives that have since been moved or deleted
are dropped from the restored set.
### Fixed
- Settings showed the local cache as "X of 0 B used" when no cache budget was set. An unset budget
means *unlimited*, so it now reads "X cached — no limit set" rather than implying a zero cap.
- The cache-budget editor fired a `SetCacheBudget` round-trip — and a toast — on every step, so a
run of `+` clicks applied a series of caps the user never asked for and stacked a column of
toasts. It now waits for the value to settle.
- A long pin list pushed everything below it off the end of Settings. It shows the first six with a
"Show all N" row for the rest.
## [1.8.2] — 2026-08-17 ## [1.8.2] — 2026-08-17
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
@@ -439,6 +678,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 - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. 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.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.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 [1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
+2 -1
View File
@@ -14,7 +14,8 @@ Consolidated from the historical feature roadmap; current bug status lives in [`
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration. * **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`). * **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges. * **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented. * **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favourites are implemented.
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches. * **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls. * **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs. * **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
<defs>
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8B72FF"/>
<stop offset="1" stop-color="#5A38E8"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
<!-- cloud: the remote drive (symmetric about x=64) -->
<g fill="#FFFFFF">
<circle cx="64" cy="42" r="20"/>
<circle cx="44" cy="52" r="14"/>
<circle cx="84" cy="52" r="14"/>
<rect x="32" y="50" width="64" height="16" rx="8"/>
</g>
<!-- pull: files arrive only when opened -->
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 75v17"/>
<path d="M53 84l11 11 11-11"/>
<path d="M45 108h38" stroke-opacity="0.55"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 990 B

+7 -5
View File
@@ -9,18 +9,20 @@
# Build + install: cd packaging && makepkg -fi # Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux pkgname=proton-drive-linux
pkgver=1.8.2 pkgver=1.10.0
pkgrel=1 pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray" pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64') arch=('x86_64')
url="https://github.com/narl/proton-drive-linux" url="https://github.com/narrrl/proton-drive-linux"
license=('MIT') license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0') depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
makedepends=('cargo' 'git') makedepends=('cargo' 'git')
provides=('pdfs') provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime. # Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service' 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') options=('!lto' '!debug')
# Built in place from the checkout; nothing to fetch or checksum. # Built in place from the checkout; nothing to fetch or checksum.
@@ -1,11 +1,11 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Proton Drive (Tray) Name=Proton Drive for Linux (Tray)
GenericName=Cloud Drive Tray GenericName=Cloud Drive Tray
Comment=Proton Drive system tray icon Comment=Proton Drive status and quick actions (unofficial client)
Exec=pdfs-tray Exec=pdfs-tray
Icon=io.narl.proton-drive-linux Icon=io.narl.proton-drive-linux
Terminal=false Terminal=false
Categories=Network;FileTransfer;GTK; Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage; Keywords=proton;drive;cloud;sync;storage;files;backup;
NoDisplay=true NoDisplay=true
+3 -3
View File
@@ -1,12 +1,12 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=Proton Drive Name=Proton Drive for Linux
GenericName=Cloud Drive GenericName=Cloud Drive
Comment=Proton Drive files-on-demand for Linux Comment=Files-on-demand Proton Drive (unofficial client)
Exec=pdfs-app Exec=pdfs-app
Icon=io.narl.proton-drive-linux Icon=io.narl.proton-drive-linux
Terminal=false Terminal=false
Categories=Network;FileTransfer;GTK; Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage; Keywords=proton;drive;cloud;sync;storage;files;backup;
StartupNotify=true StartupNotify=true
SingleMainWindow=true SingleMainWindow=true
+21 -9
View File
@@ -1,14 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
<defs> <defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#8b6dff"/> <stop offset="0" stop-color="#8B72FF"/>
<stop offset="1" stop-color="#6d4aff"/> <stop offset="1" stop-color="#5A38E8"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
<!-- cloud --> <rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
<path fill="#ffffff" d="M44 84c-11 0-20-9-20-20 0-10 7-18 17-20 3-11 13-19 25-19 13 0 24 10 26 23 9 1 16 9 16 18 0 10-8 18-18 18z"/>
<!-- down arrow (on-demand pull) --> <!-- cloud: the remote drive (symmetric about x=64) -->
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/> <g fill="#FFFFFF">
<circle cx="64" cy="42" r="20"/>
<circle cx="44" cy="52" r="14"/>
<circle cx="84" cy="52" r="14"/>
<rect x="32" y="50" width="64" height="16" rx="8"/>
</g>
<!-- pull: files arrive only when opened -->
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 75v17"/>
<path d="M53 84l11 11 11-11"/>
<path d="M45 108h38" stroke-opacity="0.55"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 990 B

+10 -4
View File
@@ -12,9 +12,9 @@
Name: proton-drive-linux Name: proton-drive-linux
Version: %{version} Version: %{version}
Release: 1%{?dist} Release: 1%{?dist}
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray) Summary: Unofficial Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
License: MIT License: MIT
URL: https://github.com/narl/proton-drive-linux URL: https://github.com/narrrl/proton-drive-linux
ExclusiveArch: x86_64 ExclusiveArch: x86_64
BuildRequires: cargo BuildRequires: cargo
@@ -33,6 +33,7 @@ Requires: gtk4
Requires: libadwaita Requires: libadwaita
Requires: libsecret Requires: libsecret
Requires: webkitgtk6.0 Requires: webkitgtk6.0
Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
# DE-specific; do not Require a single desktop environment. # DE-specific; do not Require a single desktop environment.
@@ -40,11 +41,16 @@ Recommends: gnome-keyring
Recommends: gnome-shell-extension-appindicator Recommends: gnome-shell-extension-appindicator
Recommends: kwallet Recommends: kwallet
# Only for `pdfs-prompt --fzf`; every other front end works without them.
Suggests: fzf
Suggests: foot
Provides: pdfs = %{version}-%{release} Provides: pdfs = %{version}-%{release}
%description %description
Unofficial Proton Drive client featuring a FUSE files-on-demand mount, Files-on-demand Proton Drive for the Linux desktop: a FUSE virtual mount with
CLI, GTK4/Libadwaita GUI, system tray, and search launcher. block-level caching, a scriptable CLI, and a GTK4/Libadwaita desktop app with
system tray and search launcher. Unofficial and not affiliated with Proton AG.
%prep %prep
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout". # In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
+1 -1
View File
@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Proton Drive Files-On-Demand mount Description=Proton Drive Files-On-Demand mount
Documentation=https://github.com/narl/proton-drive-linux Documentation=https://github.com/narrrl/proton-drive-linux
# Tie the mount to the desktop session: start after it is up and stop with it. # Tie the mount to the desktop session: start after it is up and stop with it.
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target PartOf=graphical-session.target