Compare commits

..
44 Commits
Author SHA1 Message Date
nvrl 860d29cfe8 fix(core,fuse): un-park abandoned creates, retire orphan staged bytes, serialise refreshes
Four faults found by reading the journal for issues that were never fixed,
tracked as B94-B97 in docs/BUGS.md.

A create parked behind a temp-file rename (B70) had no exit but that rename,
so 43 uploads sat in the queue for weeks holding their staged bytes. The
primary drain worker now sweeps parked creates while the queue is idle: one
whose node is gone is discarded, and one older than PARK_EXPIRY_MS that nobody
holds open is un-parked rather than dropped, since staged bytes are the only
copy of the write. An open node stays parked however old it is.

.tmp and .temp leave TRANSIENT_SUFFIXES. The list served two mechanisms with
different consequences: ignoring a name during mirror reconcile is reversible,
parking a create is not, and a Takeout index.tmp is a final name that nothing
will ever rename.

Staged blobs that match no queued op and name no node were kept forever and
warned about once per blob per mount (95 blobs, ~445 MiB, oldest two months).
Blobs past STAGING_ORPHAN_RETAIN are retired; the rest are one aggregated
warning.

Refresh control requests now run one per scope. Every scope is individually
cheap, but duplicates contend for the database lock the running refresh holds,
which left five in flight for twenty-three minutes. request_kind names the
scope so the report says which view wedged. The photo metadata pass also stops
at shutdown instead of running into the cancelled runtime and logging one
"skipping undecryptable photo" per node.
2026-09-19 20:29:48 +02:00
nvrl 6cc8f72bab fix(core,fuse,gui,cli): read only the photos a refresh has not read
A timeline refresh resolved every photo's node to learn its name, media
type, favourite tag, content hash and photo relation — 63 round-trips for
a 12,445-photo library, minutes of work, repeated every time the timeline
went stale sixty seconds later. None of that metadata changes on its own,
and when it does change the event feed names the node it changed.

`photos.resolved_at` (schema v31) records that a photo's node was read.
The refresh resolves the photos without that mark: the ones it has never
read, and the ones a remote event marked stale. An existing timeline is
backfilled as resolved, so upgrading does not re-read the library once.

Remote changes now name what they changed instead of asking for
everything. `classify_photo_events` turns a `NodeUpdated` into
`photos_mark_unresolved(uid)`, so a favourite toggled on a phone costs one
node read rather than the whole library; a local toggle is claimed as our
own echo, because the feed replays it and a `NodeUpdated` carries nothing
that says what changed. A continuity loss and `refresh photos --full` drop
every mark, which is the way back to a whole-library read.

Along the way: `photos_replace` never kept a photo's name, so a photo the
SDK could not decrypt was renamed to nothing on every refresh — and the
tab split and the RAW+JPEG grouping are both derived from the name. It is
kept now, and a row that *was* resolved is authoritative instead, so a
photo unlinked from its main elsewhere finally loses its `main_uid`.

`TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT`:
a chunk of 200 was split there into a full request and a 50-uid tail.
2026-09-19 17:39:49 +02:00
nvrl 376f3edb28 fix(core,fuse,gui,cli): pair a Pixel raw with its JPEG, and stop refresh timing out
Three things the 12,445-photo library showed that the tests did not.

A Pixel raw capture is two files named after the role each plays —
`PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng` — so their stems differ
and the pairing rule never saw one shot. The marker is now cut off before names
are compared, narrowly: digits, then one role word, so an ordinary name that
happens to contain `.raw-` keeps its stem. Fed the real library's first 40 rows
through the grouping, they collapse to 21 tiles.

A raw that has a JPEG beside it is now painted with that JPEG's thumbnail, which
the server usually already holds. The Raw tab costs no raw download and no raw
decode for those tiles. Raws are found in one batched read, so an ordinary page
pays nothing for the lookup.

Photos could not be moved to Trash at all: they live on the photo volume, which
the mount never holds, so no `nodes` row exists, every authority answered
`Unknown`, and the write check refused the whole library with "trash access: not
allowed". Membership of our own timeline is the authority that does exist for a
photo, and it now answers. An album photo on someone else's volume still fails
closed.

Refreshing the gallery then reported `io: Resource temporarily unavailable (os
error 11)` (B92). The daemon held the control reply until the whole timeline had
been re-read — 63 batches of 200 nodes for this library, minutes of work against
a 120 s read bound. The daemon now answers as soon as the refresh is running,
`PhotosRefreshStatus` reports when it is done, and both front ends follow it: the
gallery reloads the page when it clears, and `pdfs refresh photos` blocks so the
command still means what it says. Each refresh logs its photo count, batch count
and duration.

Released as 1.11.0.
2026-09-19 17:08:33 +02:00
nvrl 09c0684a5e fix(core): keep pre-v30 photos on the page until a refresh groups them
A database written before the grouping column opens with `group_key` NULL on
every row, and NULL matches nothing: the join dropped the whole timeline until
the first refresh ran. `COALESCE(group_key, uid)` is what makes such a row its
own group, which is how the gallery behaved before the column existed.

Covered by a migration test that rewinds a fixture to v29.
2026-09-19 15:41:24 +02:00
nvrl c1df3b6cc2 feat(gui): show a grouped shot as one tile, and reach its other files
The grouping landed in the daemon; this is what it looks like. A tile whose shot
was stored as more than one file badges that in the corner — "RAW" when one of
the members is a raw file, since that is the word the person is looking for, and
a plain count otherwise (a live photo, a burst). The lightbox gains a control
that steps through the group's files, which is the only way to reach the RAW now
that the grid shows the shot once.

Deleting says what it will do: the daemon takes the whole group, so the
confirmation names the file count when a selection stands for more files than
tiles.
2026-09-19 15:39:14 +02:00
nvrl afd71713d5 feat(core,fuse): group the RAW and the JPEG of one shot
A camera that writes both puts two files on the volume, and the timeline showed
two tiles of the same photo. They are one shot to the person who took it.

Schema v30 records what ties them together: the server's own duplicate hash and
main-photo relation, plus a `group_key` every member of a shot carries. The key
is computed when the timeline is replaced, in three steps — the server relation
first, because it knows about live photos and bursts and we do not have to guess
at them; then same capture day, same file-name stem and one member raw while the
other is not, which is the pair a camera writes; otherwise the photo is its own
group. The member the grid shows is the non-raw one, since a JPEG decodes in
milliseconds and is what the person expects to see.

The gallery therefore pages by group and carries `group_size` and `has_raw` on
each entry, and the date scrubber counts what the page shows. The Raw tab is the
exception and still lists every raw file, because that tab exists to answer
"which raws do I have". Album pages are not grouped at all: an album is a list
someone made, and it shows exactly what they put in it.

`TimelineRow` is a struct now. It had grown to five positional fields and was
about to grow to seven, at which point nobody can read a call site.
2026-09-19 15:34:17 +02:00
nvrl c6bdba024f feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone
stayed on the page for minutes, and a photo you wanted gone had to be deleted
on another device. Both are now handled here.

Deleting needs a uid-addressed request, because `Request::Delete` takes a
mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes`
trashes through the SDK and answers per-uid outcomes, so a partial failure is
reportable; the daemon then drops those rows with `Db::photos_delete`, album
membership included, rather than leaving the gallery to wait for the next
timeline refresh. The GUI removes the tiles immediately with an Undo toast and
puts back anything the server refused — the photos are in Proton's trash and
stay recoverable there either way. Selection is Ctrl/Shift+click, the Select
toggle or Delete in the lightbox.

Remote deletions arrive on their own now. The Drive volume and the photos
volume have separate event streams, so the photos volume gets its own poller
and its own cursor. Trash and delete events remove rows; anything else only
marks the timeline stale, because there is no inode space to converge on this
volume. With the timeline TTL down to 60 s and an explicit Refresh that waits
for the server instead of only clearing the freshness stamp, a photo deleted
elsewhere leaves the grid in about ten seconds.
2026-09-19 15:25:04 +02:00
nvrl 5a8e73657c feat(gui): justify the gallery rows and keep the thumbnails on screen
The Photos grid laid every photo out as a centre-cropped square. That is
why portraits showed black bars and why a panorama was unrecognisable at
tile size: the tile's shape had nothing to do with the photo's.

Rows are now justified. Photos are taken in capture order until they no
longer fit the target height, and the row is then scaled to land exactly
on the right margin, so each tile is its own photo's shape and nothing is
cropped or letterboxed. The last row of a day stays at the target height
rather than being stretched — a day holding two photos should look like
a short day, not like a layout bug. Ratios come from the daemon where it
has one; where it has not, the tile is laid out square and the day
re-flows once a decoded thumbnail proves the real shape, on the existing
debounce so a screenful of decodes costs one re-flow. Extreme ratios are
clamped, so one strip of film cannot decide what a row looks like.

Ctrl+scroll and Ctrl+± now move the target row height, and the grid is
tighter to match: 2 px gaps, smaller heading margins, a 6 px radius and
no card behind each tile, so the page reads as photographs rather than
as a deck of cards.

Thumbnails also stopped disappearing on scroll. The texture cache was
evicting in insertion order — which is precisely what a scroll back up
asks for again — so it is now least-recently-used and holds 1500 rather
than 600, which at the new density is several screens instead of under
two. A batch tops itself up with the photos just past the rows on
screen, in the direction of travel, and once the queue is short the
near-miss tiles are fetched rather than dropped.
2026-09-19 15:05:30 +02:00
nvrl cf0d495f8c fix(fuse): join the worker pool before the runtime goes away, and log deep queues
Teardown flagged the FUSE worker pool closed but never joined it, so the
process could drop the tokio runtime while a worker was still inside a
job — which is where 2026-09-18's "A Tokio 1.x context was found, but it
is being shutdown" panic came from. `Workers::stop_and_join` now waits
for the jobs in flight, bounded at 10 s so a worker wedged on the network
cannot hold the stop the user asked for; what is still stuck is named in
the log and shutdown continues around it.

The queues stay unbounded — the only caller that could feel backpressure
is fuser's dispatch loop, and blocking that is the stall the pool exists
to avoid — but a lane now logs its depth every 256 jobs, so a queue that
keeps climbing is visible before it is reported as a slow mount.

Documents the hang as B91, with the journal evidence, what made it
undiagnosable, and the candidates the evidence does not yet separate.
2026-09-19 14:49:31 +02:00
nvrl bee15ee7cf feat(fuse,packaging): watch the daemon for stalls and answer the systemd watchdog
A supervisor thread ticks every 15 s and does three things: it WARNs
about a worker that has held one job for two minutes, a control request
that never returned, or a lane that has not completed anything while its
queue is non-empty; it samples resident size and the pending-op count
into the log every five minutes; and it answers the systemd watchdog.

The watchdog ping is deliberately not a heartbeat from a thread that
does nothing else — that would keep the unit "healthy" through exactly
the hang it is meant to catch. It goes out only after a real round trip
over the control socket, so it means "a client can still talk to this
daemon", which is the property that failed on 2026-09-18.

`sd_notify` is spoken directly rather than through libsystemd (READY,
WATCHDOG, STOPPING), including the abstract-namespace address; off
systemd every call is a no-op.

The unit gains `NotifyAccess=all` (rather than `Type=notify`, which
would fail the unit on the start timeout when no login is stored yet),
`WatchdogSec=120`, `Restart=always` — a hang is not a failing exit code
— and `MemoryHigh`/`MemoryMax` as a backstop against the 5.2 G growth
the hung run showed.
2026-09-19 14:45:15 +02:00
nvrl f24ac8a429 feat(fuse,core,cli): report what the daemon is doing, for the next hang
On 2026-09-18 the daemon hung: 43 minutes of CPU over 20 hours, 5.2 GiB
resident, silent from 17:10 until a manual restart the next day, and no
panic. Nothing in the journal said what it had been waiting on, and
nothing in the daemon could be asked.

- The worker pool publishes per-thread state: whether the thread is busy,
  how long it has been in that state, and the label of the job in hand.
  Every FUSE handler that hands work to the pool now names its job
  (`read`, `lookup`, `rename`), so a stuck worker is named rather than
  merely counted. Queue depths and per-lane completion counts are
  mirrored outside the queue lock.
- `Request::Diagnostics` / `pdfs diagnostics` reports those, plus
  resident size, the control handler count against its limit, the
  in-flight control requests with their ages, and the pending-op count.
- Built to answer while the daemon is wedged: atomics and `try_lock`
  only, and the one field that needs the database reads it on a throwaway
  thread with a 500 ms deadline.
2026-09-19 14:40:26 +02:00
nvrl f78f663740 feat(core,fuse): restore the whole trashed tree, and bump the SDK to 0.6.5
The trash is a flat list of uids, but a delete has a shape: a folder, and
often items inside it that were trashed separately beforehand. Restoring
one of those in isolation is not what it means — a folder came back
without the contents deleted with it, and a file came back inside a
folder that is still trashed, which is to say nowhere the user can see.

- Schema 29: `trash.parent_uid`, filled by the trash refresh from the
  node's parent, plus an index for the walk.
- `expand_restore` takes the connected piece of the trashed tree around
  what was asked for (every trashed descendant, every trashed ancestor)
  and orders it into waves, shallowest first: the server cannot put a
  node back under a parent that is still trashed. A uid the listing does
  not know about is restored on its own, because the listing is
  materialised in chunks and can be stale.
- `Core::restore` restores wave by wave, still streaming per-node
  outcomes, so one node the server refuses leaves the rest restored.

The SDK bump brings the 6cbf2f44 port: permanent API errors are no
longer retried on block transfers, expired block URLs are re-minted on
the whole-file and public-link download paths, and a server-supplied
Retry-After is capped at 120 s.
2026-09-19 14:31:40 +02:00
nvrl 470948c191 chore(version) bumped to 1.10.2 2026-09-16 23:05:09 +02:00
nvrl b095b9f6b0 fix(core,fuse): never stat a foreign mount from the local-file indexer
The "This computer" indexer walks $HOME, and `default_excludes` only kept it
out of the Drive mountpoint and our own state/cache dirs. A home directory
that also holds third-party FUSE mounts (sshfs, rclone) got walked into.

`scan` does ask for `same_file_system(true)`, which looks sufficient and is
not: `ignore` implements it as `is_same_file_system` -> `device_num(path)` ->
`path.metadata()`, run after the directory is queued. Learning that a mount is
foreign requires stat'ing it first, and against a mount whose server is gone
that stat never returns.

The consequence is worse than a stalled scan. The blocked thread sits in
uninterruptible FUSE wait, so it ignores SIGKILL: the daemon can never exit,
systemd's stop times out, and the unreapable process keeps the cache.db lock
the next start needs, turning one dead mount into a permanent restart loop.
No userspace recovery exists once a thread is in that state, so the fix has to
be to never enter it.

Exclude every mountpoint strictly below the scan root, read by path from
/proc/self/mounts (octal-unescaped, so a path with a space still matches).
`filter_entry` runs before the device stat, so the walker never touches them.
Re-read per scan rather than cached at startup: a mount that appeared since
the daemon started is the likeliest to be half-alive.

This does not change what is indexed — a nested mount is a different device,
so it was already going to be skipped. Only the stat in front of that decision
goes away.

Refs: docs/BUGS.md B90
2026-09-16 23:05:06 +02:00
nvrl f4d6580cd3 chore(version) bumped to 1.10.1 2026-09-15 15:13:22 +02:00
nvrl b2f62f70e1 chore(deps,core,gui): upgrade dependencies to latest
- keyring 3 -> keyring-core 1 + dbus-secret-service-keyring-store 1
  (crypto-rust). Same libdbus Secret Service backend; with no target the
  store matches items keyring 3 wrote, so stored sessions survive.
- ksni 0.2 -> 0.3 via the blocking API, with assume_sni_available so a
  tray that starts before the SNI watcher waits instead of failing. Drops
  the clap 2 / atty chain (Dependabot atty advisory).
- gtk4 0.11, libadwaita 0.9, webkit6 0.6, rusqlite 0.40, fuser 0.18,
  directories 6, sha1/sha2 0.11, base64 0.23.
2026-09-15 15:07:00 +02:00
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
nvrl 078774f6a1 release(1.8.2) 2026-08-17 00:49:24 +02:00
nvrl 326a9b4e92 chore(docs) updated changelog + bug states 2026-08-17 00:04:54 +02:00
nvrl 78dc72a34d release(1.8.1) 2026-08-16 23:33:43 +02:00
nvrl 438b2b463e chore(docs) updated changelog 2026-08-16 22:03:55 +02:00
nvrl 4cd49168c4 fix(drain) failed attempts 2026-08-16 22:02:27 +02:00
nvrl 481c3fe0c7 version bump + bug docs 2026-08-16 21:34:55 +02:00
nvrl 098da2e97a fix(log) log unreachable nodes 2026-08-16 21:34:12 +02:00
nvrl d03d3eac3f fix(drive) locks + 1.7.1 2026-08-16 20:41:03 +02:00
nvrl 433e206203 chore(changelog) 2026-08-16 00:54:55 +02:00
92 changed files with 20314 additions and 2365 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
Architecture: amd64
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}
Description: Proton Drive client for Linux
Proton Drive client for Linux featuring:
- FUSE files-on-demand mount
- Command-line interface (CLI)
- GTK4 system tray, desktop application, and search launcher
Homepage: https://github.com/narrrl/proton-drive-linux
Description: Unofficial Proton Drive client for Linux
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
- FUSE virtual mount with block-level caching
- Scriptable command-line interface (pdfs)
- GTK4 desktop application, system tray, and search launcher
EOF
# 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
+568 -789
View File
File diff suppressed because it is too large. Load diff
+24 -8
View File
@@ -3,15 +3,20 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "1.7.0"
version = "1.11.1"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
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]
proton-sdk = { version = "0.5.1" }
proton-drive-rs = { version = "0.5.1" }
proton-sdk = { version = "0.6.5" }
proton-drive-rs = { version = "0.6.5" }
# Internal crates.
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"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
thiserror = "2"
anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not
@@ -29,18 +35,28 @@ anyhow = "1"
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
directories = "6"
ignore = "0.4"
sha2 = "0.10"
sha2 = "0.11"
# Google Takeout exports are zip sets; the importer reads their central
# directories and streams entries out without extracting to disk first.
zip = { version = "8", default-features = false, features = ["deflate"] }
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
# import has to speak SHA-1 whatever its age.
sha1 = "0.11"
# Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
rusqlite = { version = "0.40", features = ["bundled"] }
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
# with no `target` it matches items v3 wrote, so existing logins survive.
keyring-core = "1"
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
clap = { version = "4", features = ["derive"] }
rpassword = "7"
async-trait = "0.1"
futures = "0.3"
fuser = "0.17"
fuser = "0.18"
libc = "0.2"
+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
- **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.
- **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.
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
@@ -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
own prompt flag is appended.
### Live launcher search (fzf)
The two-step above exists because no dmenu-style launcher can call back for new
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
exactly like the built-in window:
```bash
pdfs-prompt --fzf # pinned files, then live results as you type
pdfs-prompt --fzf --query invoice # start on a search
```
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
own matcher is switched off, so the ordering you see is the daemon's ranking —
abbreviations and typos included, which a launcher filtering a fixed list cannot
do. Enter opens; Escape closes.
fzf is a terminal program, so when it is started without one — from a WM
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
Hyprland:
```
windowrulev2 = float, class:^(pdfs-prompt)$
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
windowrulev2 = center, class:^(pdfs-prompt)$
bind = SUPER, space, exec, pdfs-prompt --fzf
```
Set it as the default front end, and pick the terminal explicitly, in
`config.json`:
```json
{
"prompt": {
"mode": "fzf",
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
"menu_limit": 50
}
}
```
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
without one it is appended, which is what every detected terminal expects.
## Choosing How Files Open
By default every result is handed to `xdg-open`. An `open_with` block in
@@ -270,19 +373,21 @@ sudo apt-get install -y \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev
libdbus-1-dev \
libimage-exiftool-perl
```
### Arch Linux
```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+)
```bash
sudo dnf install -y \
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):
@@ -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:
```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
```
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
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
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "CLI for the Proton Drive Linux client"
[[bin]]
+580 -13
View File
@@ -17,6 +17,7 @@ use pdfs_core::control::{
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
};
use pdfs_core::db::Db;
use pdfs_core::service;
#[derive(Parser)]
#[command(
@@ -158,6 +159,65 @@ enum Command {
},
/// List the photo albums (including ones shared with this account).
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).
Album {
/// Album node uid in `volume~link` form (from `pdfs albums`).
@@ -221,6 +281,10 @@ enum Command {
},
/// Show the daemon's in-flight transfers (active uploads/downloads).
Transfers,
/// Show what the daemon is doing right now: worker threads, queue depths,
/// in-flight control requests and memory. For a daemon that has stopped
/// answering rather than one that is merely busy.
Diagnostics,
/// List what is in the account's trash, with the uids the restore and
/// delete-forever commands take.
Trash,
@@ -246,6 +310,11 @@ enum Command {
/// What to refresh: a folder path (inside the mountpoint or relative to
/// it), `trash`, or `photos`. The mount root if omitted.
target: Option<String>,
/// With `photos`: read every photo's metadata again instead of only what
/// changed. Minutes on a large library; for a gallery that disagrees with
/// the account.
#[arg(long)]
full: bool,
},
/// Manage the account's registered devices.
@@ -589,6 +658,22 @@ fn main() -> Result<()> {
} => cmd_photos(limit, offset, favorites),
Command::Favorite { uid, remove } => cmd_favorite(uid, remove),
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::OpenPhoto { uid } => cmd_open_photo(uid),
Command::Search { query, limit } => cmd_search(query, limit),
@@ -598,11 +683,12 @@ fn main() -> Result<()> {
Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { sources, parent } => cmd_upload(sources, parent),
Command::Transfers => cmd_transfers(),
Command::Diagnostics => cmd_diagnostics(),
Command::Trash => cmd_trash(),
Command::Restore { uids } => cmd_restore(uids),
Command::DeleteForever { uids } => cmd_delete_forever(uids),
Command::EmptyTrash => cmd_empty_trash(),
Command::Refresh { target } => cmd_refresh(target),
Command::Refresh { target, full } => cmd_refresh(target, full),
Command::Devices { action } => cmd_devices(action),
Command::Sync { action } => cmd_sync(action),
Command::Share {
@@ -1053,6 +1139,9 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
format!(" ({})", e.role)
};
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
if let Some(line) = shared_by_line(&e) {
println!("{:>15}{line}", "");
}
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
@@ -1061,6 +1150,39 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
Ok(())
}
/// Who shared an entry with me, and when, as a line printed under it — `None`
/// when the invitation said nothing (owned content, or a node inside a shared
/// folder rather than its root). An inviter whose signature did not verify is
/// said to be claimed, not stated: the name may be forged.
fn shared_by_line(entry: &pdfs_core::control::DirEntry) -> Option<String> {
if entry.shared_by.is_empty() {
return None;
}
let mut line = format!("shared by {}", entry.shared_by);
if entry.shared_at > 0 {
line.push_str(&format!(" at {}", format_epoch_utc(entry.shared_at)));
}
if entry.shared_by_unverified {
line.push_str(" WARNING: invitation signature did not verify; the sender may be forged");
}
Some(line)
}
/// `YYYY-MM-DD` (UTC) for epoch seconds, without pulling a date crate into the
/// CLI for one column. Civil-from-days per Howard Hinnant's algorithm.
fn format_epoch_utc(secs: i64) -> String {
let z = secs.div_euclid(86_400) + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
/// Download a file shared with me. The daemon puts the plaintext in its content
/// cache and replies with that path; with a `dest` we copy it out, so the caller
/// gets a file it owns rather than one the cache may later evict.
@@ -1280,6 +1402,15 @@ fn cmd_login(username: Option<String>) -> Result<()> {
other => anyhow::Error::new(other).context("login failed"),
})?;
// A daemon that is already up is parked in `wait_for_session`, sleeping
// between keyring checks; restart it so the mount appears now instead of
// whenever the next poll fires. Only when it is already active — `pdfs
// login` deliberately does not enable the unit the way the GUI does, so a
// headless user driving `pdfs daemon` by hand keeps that setup.
if service::is_active() {
service::restart();
}
println!("Logged in as {username}. Session stored in the system keyring.");
Ok(())
}
@@ -1419,6 +1550,59 @@ fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
Ok(())
}
/// How long to wait before the next login check, after `attempts` checks have
/// already come back [`pdfs_core::Error::NotLoggedIn`].
///
/// Doubles from 3s to a 15s ceiling. The interval is not just a sleep:
/// [`auth::load`] builds a fresh `keyring::Entry` on every call, so each check
/// opens a new D-Bus connection to the Secret Service and negotiates an
/// encrypted session over it. A flat 3s poll meant 28,800 of those a day from a
/// daemon that had nothing to do; the ceiling cuts that by 80%.
///
/// The ceiling stays low because the sleep is also the worst-case login →
/// mount latency for anything that does *not* poke systemd: front-ends restart
/// the unit on login (`service::enable_start`), which interrupts the sleep
/// immediately, but a hand-run `pdfs daemon` has no such wake-up path.
fn login_poll_delay(attempts: u32) -> std::time::Duration {
const BASE_SECS: u64 = 3;
const MAX_SECS: u64 = 15;
// `checked_shl` rather than `<<`: the daemon can sit unauthenticated for
// weeks, and a shift past the width of the type is a panic, not a big number.
let factor = 1u64.checked_shl(attempts).unwrap_or(u64::MAX);
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
}
/// Block until a session is stored, backing off between checks.
///
/// `load` and `sleep` are parameters rather than direct calls so the backoff
/// schedule can be tested without a keyring and without real time passing.
fn wait_for_session<L, S>(mut load: L, mut sleep: S) -> Result<()>
where
L: FnMut() -> std::result::Result<(), pdfs_core::Error>,
S: FnMut(std::time::Duration),
{
let mut attempts: u32 = 0;
loop {
match load() {
Ok(()) => return Ok(()),
Err(pdfs_core::Error::NotLoggedIn) => {
let delay = login_poll_delay(attempts);
// Say it once at INFO. Repeating it every interval buried the
// journal in tens of thousands of identical lines a day.
if attempts == 0 {
tracing::info!("not logged in; waiting for a session…");
} else {
tracing::debug!(delay_secs = delay.as_secs(), "still not logged in");
}
sleep(delay);
attempts = attempts.saturating_add(1);
}
Err(e) => return Err(e.into()),
}
}
}
/// Run the auto-mount daemon loop. Waits for a stored login, mounts, and keeps
/// the mount alive: a clean stop (SIGTERM via `systemctl --user stop`) exits 0;
/// an external unmount triggers a remount; errors back off and retry. This is
@@ -1427,16 +1611,7 @@ fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
loop {
// Wait until a session is stored. The GUI enables this service on login,
// but the service may also start at boot before the user has logged in.
loop {
match auth::load() {
Ok(_) => break,
Err(pdfs_core::Error::NotLoggedIn) => {
tracing::info!("not logged in; waiting…");
std::thread::sleep(std::time::Duration::from_secs(3));
}
Err(e) => return Err(e.into()),
}
}
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
@@ -1711,6 +1886,213 @@ fn cmd_albums() -> Result<()> {
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<()> {
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
@@ -1781,6 +2163,67 @@ fn cmd_rm(path: PathBuf) -> Result<()> {
Ok(())
}
/// Seconds as something a human reads at a glance: the ages here run from
/// "still starting" to "wedged since yesterday".
fn human_secs(secs: u64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => format!("{}m{}s", s / 60, s % 60),
s if s < 86400 => format!("{}h{}m", s / 3600, (s % 3600) / 60),
s => format!("{}d{}h", s / 86400, (s % 86400) / 3600),
}
}
fn cmd_diagnostics() -> Result<()> {
let response = control_request(CtlRequest::Diagnostics)?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Diagnostics { diagnostics: d } => {
println!(
"uptime {} rss {} pending ops {}",
human_secs(d.uptime_secs),
human_bytes(d.rss_bytes),
d.pending_ops,
);
println!(
"control handlers {}/{}",
d.control_handlers, d.control_handler_limit,
);
println!(
"queued: meta {} (done {}), transfer {} (done {})",
d.meta_queued, d.meta_completed, d.transfer_queued, d.transfer_completed,
);
println!("workers:");
for worker in &d.workers {
let what = if worker.busy {
worker.label.as_str()
} else {
"idle"
};
println!(
" {:<20} {:<12} {}",
worker.name,
what,
human_secs(worker.age_secs)
);
}
if d.inflight.is_empty() {
println!("in-flight requests: none");
} else {
println!("in-flight requests (oldest first):");
for request in &d.inflight {
println!(" {:<20} {}", request.kind, human_secs(request.age_secs));
}
}
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_trash() -> Result<()> {
match control_request(CtlRequest::ListTrash)? {
CtlResponse::Entries { entries } if entries.is_empty() => println!("(trash is empty)"),
@@ -1831,22 +2274,51 @@ fn cmd_empty_trash() -> Result<()> {
/// Drop a cached listing. `trash` and `photos` name those two listings; anything
/// else is read as a folder path, so a folder actually called "trash" is still
/// reachable as `./trash`.
fn cmd_refresh(target: Option<String>) -> Result<()> {
fn cmd_refresh(target: Option<String>, full: bool) -> Result<()> {
let scope = match target.as_deref() {
Some("trash") => RefreshScope::Trash,
Some("photos") => RefreshScope::Photos,
Some("photos") => RefreshScope::Photos { full },
path => RefreshScope::Dir {
path: path.unwrap_or("").to_string(),
},
};
let photos = matches!(scope, RefreshScope::Photos { .. });
match control_request(CtlRequest::Refresh { scope })? {
CtlResponse::Ok { message } => println!("{message}"),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
if photos {
wait_for_photos_refresh()?;
}
Ok(())
}
/// How often [`wait_for_photos_refresh`] asks the daemon whether the timeline
/// refresh it started is done.
const PHOTOS_REFRESH_POLL: std::time::Duration = std::time::Duration::from_secs(2);
/// Block until the daemon's timeline refresh has finished.
///
/// The daemon answers `Refresh { Photos }` as soon as the refresh is running,
/// because re-reading a large library takes minutes and a control reply may not.
/// `pdfs refresh photos` means "the timeline is up to date when I return", so
/// the waiting happens here, where no socket read bound applies.
fn wait_for_photos_refresh() -> Result<()> {
loop {
std::thread::sleep(PHOTOS_REFRESH_POLL);
match control_request(CtlRequest::PhotosRefreshStatus)? {
CtlResponse::PhotosRefresh { running: true } => continue,
CtlResponse::PhotosRefresh { running: false } => {
println!("refreshed");
return Ok(());
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
}
}
/// Ask for a typed `yes` before an irreversible destroy. Errors (aborting the
/// command) on anything else, including a non-interactive stdin: a piped or
/// scripted invocation must not silently wipe the trash.
@@ -2410,3 +2882,98 @@ mod diagnose_tests {
std::fs::remove_dir(dir).unwrap();
}
}
#[cfg(test)]
mod login_wait_tests {
use super::*;
use std::time::Duration;
#[test]
fn poll_delay_doubles_then_holds_at_the_ceiling() {
assert_eq!(login_poll_delay(0), Duration::from_secs(3));
assert_eq!(login_poll_delay(1), Duration::from_secs(6));
assert_eq!(login_poll_delay(2), Duration::from_secs(12));
assert_eq!(login_poll_delay(3), Duration::from_secs(15));
assert_eq!(login_poll_delay(4), Duration::from_secs(15));
// A daemon left unauthenticated for weeks reaches attempt counts well
// past the width of the shift; that must cap, not panic.
assert_eq!(login_poll_delay(64), Duration::from_secs(15));
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
}
#[test]
fn waiting_for_a_session_stops_hammering_the_keyring() {
let mut checks_left = 20;
let mut slept: Vec<Duration> = Vec::new();
wait_for_session(
|| {
if checks_left == 0 {
Ok(())
} else {
checks_left -= 1;
Err(pdfs_core::Error::NotLoggedIn)
}
},
|delay| slept.push(delay),
)
.unwrap();
assert_eq!(slept.len(), 20, "one wait per failed check");
assert!(
slept.windows(2).all(|w| w[1] >= w[0]),
"the interval must never shrink: {slept:?}"
);
// The point of the change. Every check is a fresh D-Bus session against
// the Secret Service, so 20 of them must not fit inside a single minute
// the way a flat 3s poll did.
let total: Duration = slept.iter().sum();
assert!(
total >= Duration::from_secs(4 * 60),
"20 checks should span at least 4 minutes, spanned {total:?}"
);
}
#[test]
fn a_stored_session_is_not_waited_for_at_all() {
let mut slept = 0;
wait_for_session(|| Ok(()), |_| slept += 1).unwrap();
assert_eq!(slept, 0);
}
#[test]
fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01");
assert_eq!(format_epoch_utc(951_782_400), "2000-02-29");
assert_eq!(format_epoch_utc(1_700_000_000), "2023-11-14");
assert_eq!(format_epoch_utc(-86_400), "1969-12-31");
}
#[test]
fn a_forged_looking_invitation_is_flagged_not_stated() {
let mut entry = pdfs_core::control::DirEntry {
name: "Budget.ods".into(),
is_dir: false,
size: 1,
modified: 0,
pinned: false,
cached: false,
uid: "vol~link".into(),
path: String::new(),
role: "viewer".into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
};
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
entry.shared_by = "alice@proton.me".into();
entry.shared_at = 1_700_000_000;
assert_eq!(
shared_by_line(&entry).as_deref(),
Some("shared by alice@proton.me at 2023-11-14")
);
entry.shared_by_unverified = true;
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
}
}
+6 -1
View File
@@ -5,6 +5,8 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies]
@@ -18,8 +20,11 @@ tracing.workspace = true
directories.workspace = true
ignore.workspace = true
sha2.workspace = true
sha1.workspace = true
zip.workspace = true
rusqlite.workspace = true
keyring.workspace = true
keyring-core.workspace = true
dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true
async-trait.workspace = true
libc.workspace = true
+8 -3
View File
@@ -6,7 +6,7 @@
//! interactive step; the refresh token auto-renews via the HTTP client's 401
//! path, so no fresh 2FA is required until the refresh token itself expires.
use keyring::Entry;
use keyring_core::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::cache::EncryptedCacheRepository;
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
}
fn keyring_entry() -> Result<Entry> {
// Installed lazily rather than once at startup so a session bus that was not
// up yet on the first attempt is retried on the next one.
if keyring_core::get_default_store().is_none() {
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
}
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
}
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
let entry = keyring_entry()?;
match entry.get_password() {
Ok(json) => Ok(serde_json::from_str(&json)?),
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
Err(e) => Err(e.into()),
}
}
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
}
}
match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
File diff suppressed because it is too large. Load diff
+549 -4
View File
@@ -68,6 +68,11 @@ pub enum Request {
#[serde(default)]
favorites: bool,
},
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
/// live photo and its clip. Replies with [`Response::Photos`], holding one
/// item per file in server order; a photo that stands alone comes back as a
/// single item. The lightbox uses it to switch between a shot's files.
PhotoGroup { uid: String },
/// The months the timeline spans (newest first, with per-month counts) so a
/// front-end can build a date scrubber without paging the whole library.
/// `kind` scopes the counts to one tab when set.
@@ -93,6 +98,49 @@ pub enum Request {
/// 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.
PhotoThumbs { uids: Vec<String> },
/// Trash photos by node uid, answered with [`Response::Trashed`].
///
/// Uid-addressed rather than path-addressed because the photos volume is not
/// part of the FUSE mount: the gallery knows a photo only by its uid, and
/// [`Request::Delete`] has no path to take for it. The nodes land in Proton
/// trash, so this stays recoverable from the Trash page.
///
/// A photo that belongs to a group takes the whole group with it: trashing
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
/// deleting that photo.
TrashNodes { 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,
/// Whether a photos-timeline refresh is running right now. Replies with
/// [`Response::PhotosRefresh`].
///
/// [`RefreshScope::Photos`] starts the refresh and returns at once, so this
/// is how a front-end knows when the refreshed timeline is worth re-reading.
PhotosRefreshStatus,
/// Download a photo's full content into the cache; replies with its path.
OpenPhoto { uid: String },
/// Add or remove Proton's `Favorite` tag on a photo. Replies with
@@ -103,6 +151,70 @@ pub enum Request {
/// re-encrypted for our timeline, which the SDK does not implement yet; the
/// daemon reports that as an error rather than silently doing nothing.
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.
///
/// A path, not the bytes: this protocol is line-delimited JSON, and
@@ -209,6 +321,13 @@ pub enum Request {
/// [`Response::Transfers`]. Cheap to poll: the daemon keeps the registry in
/// memory, so a front-end can render a live progress widget.
GetQueueStatus,
/// Report what the daemon is doing *right now*, for debugging a daemon that
/// stopped answering rather than one that is merely busy. Replies
/// [`Response::Diagnostics`].
///
/// Answered from lock-free state on purpose: it has to come back while the
/// daemon is wedged, which is the only moment anyone asks for it.
Diagnostics,
/// List what is in the account's trash. Replies with [`Response::Entries`];
/// a trashed node has no path inside the mount, so each entry carries only
/// its `uid` — the handle for [`Request::Restore`] and
@@ -469,8 +588,19 @@ pub enum RefreshScope {
Dir { path: String },
/// The trash listing.
Trash,
/// The photos timeline.
Photos,
/// The photos timeline. The reply comes back as soon as the refresh has
/// *started*, not when it has finished: re-reading a large library takes
/// minutes, which is far longer than a control reply may take. Poll
/// [`Request::PhotosRefreshStatus`] to know when the new timeline is there.
///
/// A refresh normally only reads the photos the daemon has never read and
/// the ones a remote event marked changed. `full` drops that knowledge and
/// reads every photo's node again — the repair for a timeline that somehow
/// disagrees with the account, and minutes of work on a large library.
Photos {
#[serde(default)]
full: bool,
},
}
/// A registered device in a [`Response::Devices`] listing.
@@ -652,6 +782,10 @@ pub struct SharedItem {
/// The shared node's decrypted name.
pub name: String,
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
/// my own tree). Empty when the path is unknown.
#[serde(default)]
@@ -812,6 +946,22 @@ pub struct DirEntry {
/// report. Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)]
pub role: String,
/// Who shared this node with me, as the invitation claims it — empty for
/// content I own, for a node reached *inside* a shared folder (only the share
/// root carries the invitation), and when the API did not say. Trust it only
/// as far as [`shared_by_unverified`](Self::shared_by_unverified) allows.
/// Defaulted for wire-compat with clients/daemons predating it.
#[serde(default)]
pub shared_by: String,
/// When it was shared with me, epoch seconds; 0 when unknown.
#[serde(default)]
pub shared_at: i64,
/// The invitation's signature did not check out against `shared_by`'s keys,
/// so the name may be forged. False when it verified *and* when it could not
/// be checked at all (no signature, no keys): only an actual mismatch is a
/// warning worth showing.
#[serde(default)]
pub shared_by_unverified: bool,
}
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
@@ -1052,6 +1202,64 @@ pub struct PhotoMonth {
pub count: usize,
}
/// How a photo re-date is going, or how the last one ended
/// ([`Response::RedateStatus`]). Cumulative for the run, so the same shape
/// serves progress and the final report.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct RedateSummary {
/// Photos examined.
pub examined: usize,
/// Photos whose name implies a capture time more than a day from the one
/// they carry — what the run would (or did) repair.
pub candidates: usize,
/// Photos re-uploaded with the right capture time, their original trashed.
pub redated: usize,
/// Photos that could not be downloaded, re-uploaded, or trashed. The
/// original is left alone in every failure case.
pub failed: usize,
/// Album memberships re-created on the new copies.
pub album_links: usize,
/// Bytes uploaded.
pub bytes: u64,
/// True when the run stopped early because it was cancelled.
pub cancelled: bool,
/// True when nothing was actually uploaded or trashed.
pub dry_run: bool,
/// Up to a handful of examples, as `(name, stored capture time, the time the
/// name implies)` — what a dry run is read for.
#[serde(default)]
pub samples: Vec<(String, i64, i64)>,
}
/// How a Google Photos Takeout import is going, or how the last one ended
/// ([`Response::ImportStatus`]).
///
/// 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.
///
/// An album is a folder node on the photos volume carrying album properties, so
@@ -1105,6 +1313,15 @@ pub struct PhotoItem {
/// tags it has not learned yet.
#[serde(default)]
pub favorite: bool,
/// How many files this entry stands for: a shot stored as a RAW and a JPEG
/// is one entry and two files. Older daemons omit it, and `0` reads the same
/// as `1` — one file, nothing to indicate.
#[serde(default)]
pub group_size: u32,
/// Whether one of those files is a camera raw. The tile badges it, because
/// the JPEG it shows is not the whole of what is stored.
#[serde(default)]
pub has_raw: bool,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
@@ -1113,6 +1330,13 @@ fn default_photo_kind() -> PhotoKind {
PhotoKind::Photo
}
/// One node a [`Request::TrashNodes`] batch could not trash, and why.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TrashFailure {
pub uid: String,
pub message: String,
}
/// One thumbnail in a [`Response::Thumbs`] batch.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PhotoThumb {
@@ -1131,6 +1355,76 @@ pub struct PhotoThumb {
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
/// have mounted at all otherwise.
fn default_online() -> bool {
@@ -1162,6 +1456,55 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
}
}
/// One worker thread in a [`Diagnostics`] report.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WorkerState {
/// Thread name (`pdfs-fuse-meta-0`, `pdfs-fuse-3`).
pub name: String,
pub busy: bool,
/// The job in hand (`read`, `lookup`), empty when idle.
pub label: String,
/// How long it has been in that state. A busy worker with a large age is
/// the shape of a hang.
pub age_secs: u64,
}
/// One control request the daemon is still serving, in a [`Diagnostics`] report.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InflightRequest {
/// The request's variant name (`OpenFile`, `PhotosTimeline`).
pub kind: String,
pub age_secs: u64,
}
/// What the daemon is doing right now (reply to [`Request::Diagnostics`]).
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct Diagnostics {
/// How long this daemon process has been running.
pub uptime_secs: u64,
/// Resident set size, or `0` where it could not be read. The 2026-09-18 hang
/// peaked at 5.2 GiB, so this is the number that says whether the daemon is
/// growing again.
pub rss_bytes: u64,
pub workers: Vec<WorkerState>,
/// Jobs waiting for a worker, per lane.
pub meta_queued: usize,
pub transfer_queued: usize,
/// Jobs finished per lane since start. Queue depth that does not move while
/// these do not either is a stalled lane, not a loaded one.
pub meta_completed: u64,
pub transfer_completed: u64,
/// Control requests still being served, oldest first.
pub inflight: Vec<InflightRequest>,
/// Control handler threads in use, and the ceiling. At the ceiling the
/// daemon refuses new connections, which looks exactly like a dead daemon
/// from the CLI.
pub control_handlers: usize,
pub control_handler_limit: usize,
/// Queued mutations not yet drained.
pub pending_ops: u64,
}
/// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)]
pub enum Response {
@@ -1270,11 +1613,49 @@ pub enum Response {
available: bool,
items: Vec<AlbumInfo>,
},
/// What the daemon is doing right now (reply to [`Request::Diagnostics`]).
Diagnostics { diagnostics: Diagnostics },
/// 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`]),
/// newest first.
PhotoMonths { months: Vec<PhotoMonth> },
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
Thumbs { items: Vec<PhotoThumb> },
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
/// rather than as one verdict: the server answers each node separately, and
/// a front-end that removed all of them on a partial success would show a
/// photo as gone that is still there.
Trashed {
trashed: Vec<String>,
failed: Vec<TrashFailure>,
},
/// 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 },
/// Whether a photos-timeline refresh is still running (reply to
/// [`Request::PhotosRefreshStatus`]).
PhotosRefresh { running: bool },
/// An on-disk path the front-end can open (e.g. a downloaded photo).
FilePath { path: String },
/// Full-text search results (reply to [`Request::Search`]).
@@ -1409,7 +1790,7 @@ impl ErrorKind {
}
/// 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.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
@@ -1568,6 +1949,167 @@ 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());
}
/// The lightbox asks for one shot's files by the uid of the tile that was
/// clicked, and gets them back in the shape the gallery already paints.
#[test]
fn photo_group_requests_roundtrip() {
let request = Request::PhotoGroup {
uid: "volume~first".into(),
};
let line = serde_json::to_string(&request).unwrap();
assert_eq!(line, r#"{"PhotoGroup":{"uid":"volume~first"}}"#);
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
/// The group fields are new, so a page from a daemon that predates them has
/// to keep painting: one file, nothing to badge.
#[test]
fn an_older_daemons_photo_has_no_group() {
let item: PhotoItem = serde_json::from_str(
r#"{"uid":"volume~first","capture_time":1700000000,"thumb_path":null,
"name":"a.jpg","ratio":1.5,"no_thumb":false}"#,
)
.unwrap();
assert_eq!(item.group_size, 0, "absent reads the same as one file");
assert!(!item.has_raw);
}
/// The gallery trashes by uid, and reports per-uid outcomes back: a partial
/// failure has to survive the wire so the GUI can put those tiles back.
#[test]
fn trash_requests_and_outcomes_roundtrip() {
let request = Request::TrashNodes {
uids: vec!["volume~first".into(), "volume~second".into()],
};
let line = serde_json::to_string(&request).unwrap();
assert!(!line.contains('\n'), "wire form must be a single line");
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let response = Response::Trashed {
trashed: vec!["volume~first".into()],
failed: vec![TrashFailure {
uid: "volume~second".into(),
message: "permission denied".into(),
}],
};
let line = serde_json::to_string(&response).unwrap();
let decoded: Response = serde_json::from_str(&line).unwrap();
let Response::Trashed { trashed, failed } = decoded else {
panic!("decoded the wrong response variant");
};
assert_eq!(trashed, ["volume~first"]);
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].uid, "volume~second");
assert_eq!(failed[0].message, "permission denied");
}
#[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());
}
/// `full` defaults, so a front-end built against the older wire shape still
/// asks for the ordinary refresh rather than failing to parse.
#[test]
fn a_photos_refresh_scope_defaults_to_not_full() {
let scope: RefreshScope = serde_json::from_str(r#"{"Photos":{}}"#).unwrap();
assert_eq!(scope, RefreshScope::Photos { full: false });
let line = serde_json::to_string(&RefreshScope::Photos { full: true }).unwrap();
assert_eq!(line, r#"{"Photos":{"full":true}}"#);
}
/// A photos refresh is followed, not awaited, so the request that reports
/// its progress is part of the wire contract.
#[test]
fn photos_refresh_status_roundtrips() {
let line = serde_json::to_string(&Request::PhotosRefreshStatus).unwrap();
assert_eq!(line, r#""PhotosRefreshStatus""#);
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
let line = serde_json::to_string(&Response::PhotosRefresh { running: true }).unwrap();
assert_eq!(line, r#"{"PhotosRefresh":{"running":true}}"#);
let decoded: Response = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
#[test]
fn unified_search_request_and_response_roundtrip() {
let request = Request::SearchV2 {
@@ -1738,7 +2280,10 @@ mod tests {
scope: RefreshScope::Trash,
},
Request::Refresh {
scope: RefreshScope::Photos,
scope: RefreshScope::Photos { full: false },
},
Request::Refresh {
scope: RefreshScope::Photos { full: true },
},
];
for req in reqs {
+21
View File
@@ -182,12 +182,29 @@ impl Db {
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
/// which has to put the re-uploaded copy back into the albums the original
/// was in.
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt =
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
let rows = stmt
.query_map([uid], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// How many photos of `album_uid` are persisted.
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
let conn = self.read();
@@ -226,6 +243,10 @@ impl Db {
// photo on someone else's volume is not in our timeline, and
// favouriting one is not supported yet anyway.
favorite: false,
// Nor are album rows grouped: an album is a list the user
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
})
})?
.collect::<rusqlite::Result<_>>()?;
+228 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 25;
pub(super) const SCHEMA_VERSION: i64 = 31;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -161,6 +161,115 @@ impl Db {
tx.execute_batch(MIGRATION_V25)?;
}
}
if current < 26 {
// Same guard as V22: a fixture that never exercised the queue may
// not have `pending_op`, and altering an absent table is an error
// rather than a no-op.
// A fixture built from the current schema and then rewound to an
// older `schema_version` already has the column, and `ADD COLUMN`
// has no `IF NOT EXISTS`.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops && !has_column {
tx.execute_batch(MIGRATION_V26)?;
}
}
if current < 27 {
// Same guards as V26, for the same two reasons: the table may not
// exist in an old fixture, and a fixture rewound from the current
// schema already has the column.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
WHERE name = 'access_deferred_since'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops && !has_column {
tx.execute_batch(MIGRATION_V27)?;
}
}
if current < 28 {
// Same guards as V26/V27, for the same two reasons.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
WHERE name = 'local_mtime_ns'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_entries: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_entries && !has_column {
tx.execute_batch(MIGRATION_V28)?;
}
}
if current < 29 {
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
// and a fixture rewound from the current schema already has the
// column.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_trash: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_trash && !has_column {
tx.execute_batch(MIGRATION_V29)?;
}
}
if current < 30 {
// Same guards as V26-V29: the column may already be there on a
// fixture rewound from the current schema, and a very old fixture
// may not have `photos` at all.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V30)?;
}
}
if current < 31 {
// Same guards as V26-V30.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_photos: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_photos && !has_column {
tx.execute_batch(MIGRATION_V31)?;
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -725,3 +834,121 @@ UPDATE nodes
DROP TABLE node_paths;
";
/// V26: `pending_op.claimed_at` — the drain's claim column.
///
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
/// every queued rename and small write behind it for as long as it ran. Several
/// workers can share the queue only if a claim is visible to the others: this
/// column is what makes "already being drained by someone" a fact about the row
/// rather than about one thread's local variable.
///
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
/// claim is process-local state that outlives nothing — the single-writer lock
/// means a non-zero value found at open belongs to a crashed run, and
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
///
/// The index is partial so it costs nothing on the ordinary row: only the
/// handful of claimed rows are in it, which is exactly the set the claim query
/// has to subtract to keep two workers off one node.
const MIGRATION_V26: &str = "
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
ON pending_op(uid) WHERE claimed_at <> 0;
";
/// Schema v27: when an op was first deferred by the local access check.
///
/// An access deferral consumes no attempt and records no error — the op has not
/// failed remotely, it has not been sent at all. That is right for a permission
/// change the user is about to undo, and wrong for a condition that never
/// clears: the row is re-deferred every
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
/// empty, so a queue that can never drain is indistinguishable from a busy one.
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
/// 30 days, none of them visible anywhere.
///
/// `0` means "not currently access-deferred"; anything else is the ms at which
/// the first consecutive deferral happened, which is what
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
/// deferral against.
const MIGRATION_V27: &str = "
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
";
/// Schema v28: sub-second precision for a mirror folder's local baseline.
///
/// A reconcile pass decides whether the local side of a path changed by
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
/// that comparison were whole seconds, so an edit that landed in the same second
/// as the last sync and left the file the same length — a fixed-size record
/// rewritten, an image re-exported, a database page updated in place — read as
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
/// older content until something else about the file moved (bugs.md B25).
///
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
/// zero for it would make every already-synced file compare as changed and
/// re-upload an entire mirror on first start. `NULL` means "this row predates
/// the column", which the comparison reads as "fall back to whole seconds" —
/// exactly the behaviour that row was written under. Each row gains a real value
/// the next time its path is synced.
const MIGRATION_V28: &str = "
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
";
/// Schema v29: the trash listing remembers each trashed node's parent, so a
/// restore can reason about the shape of what it is putting back.
///
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
/// folder and, often, items inside it that were trashed separately. Restoring
/// one of those in isolation is what the parent link fixes — a child restored
/// under a still-trashed parent lands somewhere invisible, and a folder restored
/// without its separately-trashed contents comes back empty.
///
/// `NULL` means "this row predates the column" (or the node is a volume root);
/// the next trash refresh fills it in, and until then the restore behaves as it
/// did before.
const MIGRATION_V29: &str = "
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
";
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
/// who took it, so the timeline records what ties them together.
///
/// `content_hash` and `main_uid` come from the server's own photo properties —
/// the duplicate-detection hash, and the main photo a related one belongs to.
/// `group_key` is what the gallery pages by: every member of one shot carries
/// the same key, and the member the grid shows is the one whose `main_uid` is
/// its own uid.
///
/// All three are `NULL` until the next timeline refresh resolves the photo's
/// node, and a row without a `group_key` is its own group — which is exactly how
/// the gallery behaved before this column existed.
const MIGRATION_V30: &str = "
ALTER TABLE photos ADD COLUMN content_hash TEXT;
ALTER TABLE photos ADD COLUMN main_uid TEXT;
ALTER TABLE photos ADD COLUMN group_key TEXT;
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
";
/// Schema v31: which photos a refresh still has to read from the server.
///
/// A refresh used to resolve every photo's node to learn its name, media type,
/// favourite tag, content hash and photo relation — minutes of round-trips for a
/// library of any size, repeated every time the timeline went stale. `resolved_at`
/// records that the work was already done, so a refresh only reads the photos it
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
///
/// The backfill treats an existing timeline as resolved, so an upgrade does not
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
/// ever learned about.
const MIGRATION_V31: &str = "
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
";
+14 -4
View File
@@ -56,8 +56,8 @@ pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
PendingCounts, PendingOp, RenameMeta, op_supersedes,
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
pub use pins::PinRow;
@@ -230,7 +230,7 @@ impl Db {
}
Err(e) => return Err(e),
};
Ok(Self {
let db = Self {
conn: Mutex::new(conn),
// No pool for `:memory:`. A second connection to that name is a
// second, empty database rather than another view of this one, so
@@ -240,7 +240,17 @@ impl Db {
idle: Mutex::new(Vec::new()),
}),
_single_writer: single_writer,
})
};
// A drain claim names a worker thread in the process that took it, and
// the single-writer lock above says that process is not running. Any
// claim still on disk is therefore a crashed run's, and leaving it would
// hide those ops from every worker for good.
match db.clear_op_claims() {
Ok(0) => {}
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
}
Ok(db)
}
/// A connection for a `SELECT`-only method.
+212 -2
View File
@@ -84,6 +84,25 @@ pub const LOCAL_VOLUME: &str = "local";
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
/// How long a create may stay parked before the park is treated as permanent
/// and the bytes are let through anyway (ms).
///
/// The park has exactly one exit: a rename from the transient name to the
/// finished one. Every writer that reaches that rename does so in seconds — an
/// atomic-write scratch file immediately, a download when it completes. A park
/// still standing an hour later belongs to a writer that will never perform
/// that rename: an editor that deletes its swap file instead, a download the
/// user abandoned, or a file that simply *is* named `report.tmp` and is never
/// going to be called anything else.
///
/// Before this existed such a row sat at [`PARK_UNTIL`] for the life of the
/// database, holding the only copy of the file's bytes in `staging/` and
/// counting against the queue while promising progress that could not come. The
/// sweep un-parks it instead of dropping it: bytes the user can see in the mount
/// are bytes the user expects on Drive, and invariant 1 of the drain (a staged
/// blob is dropped only once its op has landed) admits no other answer.
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
/// A mutation that has been accepted locally but not yet performed against the
/// API — the durable half of the write-back queue (offline.md Phase 3).
///
@@ -512,17 +531,115 @@ impl Db {
Ok(op)
}
/// Take the oldest due op for this worker, marking it claimed so no other
/// drain worker takes it, or `None` when there is nothing to do.
///
/// The drain is several workers over one queue, which is only safe because
/// the claim is a column: a row is offered to exactly one worker, and the
/// worker that took it is the only one that may retire, defer or fail it.
///
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
///
/// * `claimed_at = 0` — not already in flight somewhere else.
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
/// node, but there it has to hold absolutely: a queued rename and a queued
/// revision of one file must land in the order they were made, and two
/// workers on one node would also race over its staged blob and its
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
/// order for free — the older op is claimed first and the newer one is
/// invisible until it retires.
///
/// Select and mark are one transaction, so two workers cannot both see the
/// row as unclaimed.
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let op = {
let mut stmt = tx.prepare_cached(&format!(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at <= ?1
AND claimed_at = 0
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
ORDER BY id LIMIT 1",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
))?;
stmt.query_row(params![now], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})
.optional()?
};
if let Some(op) = op.as_ref() {
tx.execute(
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
params![op.id, now.max(1)],
)?;
}
tx.commit()?;
Ok(op)
}
/// Give a claimed op back to the queue, whatever became of the attempt.
///
/// Called on every path out of a drain attempt rather than only the failing
/// ones: a handler that retires its own row leaves nothing to release (the
/// `UPDATE` matches nothing), and a handler that returns without retiring
/// *and* without failing would otherwise leave the row claimed by a worker
/// that has moved on — invisible to every worker, forever.
pub fn release_op_claim(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
params![id],
)?;
Ok(())
}
/// Drop every claim in the table.
///
/// A claim describes a worker in *this* process, so one surviving on disk
/// means the previous run died holding it. The single-writer lock
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
/// draining this queue while we are opening it.
pub fn clear_op_claims(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute(
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
[],
)?)
}
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
/// queue is empty. Used by the drain loop to sleep exactly until the next
/// debounced or backed-off op becomes eligible rather than waiting the full
/// idle-poll interval.
///
/// Claimed ops are excluded: another worker is already on them, so they are
/// not work this one is waiting for, and counting them would have an idle
/// worker spin on a row it cannot have.
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
let conn = self.read();
let ts: Option<i64> = conn
.query_row(
&format!(
"SELECT MIN(next_attempt_at) FROM pending_op \
WHERE parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~'",
WHERE claimed_at = 0 \
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
),
@@ -563,6 +680,40 @@ impl Db {
Ok(rows)
}
/// Every parked create/mkdir, oldest first, for the park sweep.
///
/// Parked rows are invisible to [`next_due_op`](Self::next_due_op) by
/// construction, so nothing else in the drain ever looks at them. This is
/// the one query that does.
pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3)
ORDER BY created_at",
)?;
let rows = stmt
.query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many queued ops there are of each kind, for `Response::Status`.
///
/// Split because "3 uploads queued" has to mean three files whose bytes are
@@ -623,11 +774,16 @@ impl Db {
/// Record a failed attempt and when to next try. Leaves the row in place —
/// the staged bytes are still the only copy of the user's write.
///
/// Clears any access-deferral window: whatever the op was waiting for has
/// now been reported, so the next uncleared deferral starts its own window
/// rather than escalating again on the following recheck.
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
access_deferred_since = 0
WHERE id = ?1",
params![id, error, next_attempt_at],
)?;
@@ -653,6 +809,60 @@ impl Db {
Ok(())
}
/// Defer an op the local access check refused, and report how long it has
/// been refused for.
///
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
/// on the retry clock, plus the one thing that was missing: the first
/// deferral of a run stamps `access_deferred_since`, and every deferral
/// returns that stamp. A caller can therefore tell a deferral that is one
/// recheck old from one that has been repeating unchanged for days, which is
/// the difference between a permission the user is mid-way through changing
/// and a queue that will never drain.
///
/// Returns the ms at which the current run of deferrals began — `now` on the
/// first one.
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET next_attempt_at = CASE
WHEN next_attempt_at >= ?4 THEN next_attempt_at
ELSE ?3
END,
access_deferred_since = CASE
WHEN access_deferred_since = 0 THEN ?2
ELSE access_deferred_since
END
WHERE id = ?1",
params![id, now, next_attempt_at, PARK_UNTIL],
)?;
let since = conn
.query_row(
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
params![id],
|r| r.get::<_, i64>(0),
)
.optional()?;
// A row that drained out from under this call leaves nothing to measure;
// reporting `now` says "this run just started", which is the reading that
// never escalates something that no longer exists.
Ok(since.filter(|since| *since != 0).unwrap_or(now))
}
/// Forget an op's access-deferral window, after an attempt the access check
/// let through. The op is no longer blocked, so a later deferral is the
/// start of a new run rather than the continuation of an old one.
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op SET access_deferred_since = 0
WHERE id = ?1 AND access_deferred_since <> 0",
params![id],
)?;
Ok(())
}
/// Every staged blob the queue still refers to.
///
/// The startup staging reconcile subtracts this from what is actually in
+448 -75
View File
@@ -34,67 +34,347 @@ pub struct StoredPhoto {
pub kind: crate::control::PhotoKind,
/// Whether the photo carries Proton's `Favorite` tag.
pub favorite: bool,
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
/// two files and one group. `1` for a photo that stands on its own.
pub group_size: usize,
/// Whether any member of the group is a camera raw file. The grid badges
/// that, because the JPEG it shows is not the whole of what is stored.
pub has_raw: bool,
}
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
/// favorite)`. The two trailing options are "what this refresh learned" — a
/// `None` keeps whatever is already stored rather than clearing it.
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
/// One row of a timeline replacement. Everything but the uid and the capture
/// time is "what this refresh learned": a `None` keeps whatever is already
/// stored rather than clearing it, because the timeline DTO carries only the uid
/// and the capture time and resolving the node can fail.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TimelineRow {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
pub media_type: Option<String>,
pub favorite: Option<bool>,
/// The server's duplicate-detection hash for the plaintext.
pub content_hash: Option<String>,
/// The main photo this one is *related* to (a live-photo video, a burst
/// sibling), when the server says it belongs to one.
pub main_uid: Option<String>,
/// When this refresh resolved the photo's node, if it did at all. `Some`
/// makes every other field authoritative — including a `None` that means
/// "the server no longer reports one" — while `None` means the refresh
/// skipped this photo and whatever is stored must be kept.
pub resolved_at: Option<i64>,
}
impl TimelineRow {
/// A row carrying only what the timeline listing itself gives.
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
Self {
uid: uid.into(),
capture_time,
..Self::default()
}
}
}
/// What a previous refresh learned about a photo and a new one must not lose.
#[derive(Clone, Debug, Default)]
struct Learned {
name: Option<String>,
ratio: Option<f64>,
thumb_state: i64,
media_type: Option<String>,
favorite: bool,
content_hash: Option<String>,
main_uid: Option<String>,
/// When this photo's node was last resolved, kept so a refresh that skips it
/// does not make it look unresolved again.
resolved_at: Option<i64>,
}
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
/// Grouping by day rather than by exact second is deliberate: a camera writes
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
/// subject on different days are not one photo.
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
conn.query_row(
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
params![capture_time],
|r| r.get::<_, Option<String>>(0),
)
.ok()
.flatten()
}
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
/// marker removed, so the two files a raw capture writes share a stem too.
fn name_stem(name: Option<&str>) -> Option<String> {
let name = name?;
let stem = match name.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem,
_ => name,
};
let stem = stem.to_ascii_lowercase();
Some(strip_shot_marker(&stem).to_string())
}
/// Drop a `.RAW-NN.ROLE` marker from a stem.
///
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
/// stems that differ in exactly the part naming which file of the shot this is.
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
/// on purpose — digits, then a single role word — so an ordinary name that
/// happens to contain `.raw-` keeps its stem.
fn strip_shot_marker(stem: &str) -> &str {
let Some(at) = stem.rfind(".raw-") else {
return stem;
};
let rest = &stem[at + ".raw-".len()..];
let Some((index, role)) = rest.split_once('.') else {
return stem;
};
let marked = !index.is_empty()
&& index.bytes().all(|b| b.is_ascii_digit())
&& !role.is_empty()
&& role.bytes().all(|b| b.is_ascii_alphabetic());
if marked { &stem[..at] } else { stem }
}
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
/// representative needs.
struct Grouped {
uid: String,
kind: crate::control::PhotoKind,
main_uid: Option<String>,
day: Option<String>,
stem: Option<String>,
}
/// Which group each photo belongs to, and which member of it the gallery shows.
///
/// Precedence, as agreed:
///
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
/// photo's group. The server knows about live photos and bursts; we do not
/// have to guess at them.
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
/// to share a name apart.
/// 3. Otherwise the photo is its own group.
///
/// The representative is the non-raw member when the group has one — a JPEG
/// decodes in milliseconds and is what the person expects to see — and the
/// earliest member in server order otherwise.
fn group_photos(entries: &[Grouped]) -> Vec<String> {
let mut parent: Vec<usize> = (0..entries.len()).collect();
fn find(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
let union = |parent: &mut [usize], a: usize, b: usize| {
let (a, b) = (find(parent, a), find(parent, b));
if a != b {
parent[b] = a;
}
};
let index: HashMap<&str, usize> = entries
.iter()
.enumerate()
.map(|(i, e)| (e.uid.as_str(), i))
.collect();
for (i, entry) in entries.iter().enumerate() {
if let Some(main) = entry.main_uid.as_deref()
&& let Some(&j) = index.get(main)
{
union(&mut parent, j, i);
}
}
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
// the same bucket, and the raw / non-raw split is checked pairwise there.
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
for (i, entry) in entries.iter().enumerate() {
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
by_name.entry((day, stem)).or_default().push(i);
}
}
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
for bucket in by_name.values() {
for (n, &i) in bucket.iter().enumerate() {
for &j in &bucket[n + 1..] {
if is_raw(i) != is_raw(j) {
union(&mut parent, i, j);
}
}
}
}
// Representative per root, then the key every member carries.
let mut representative: HashMap<usize, usize> = HashMap::new();
for i in 0..entries.len() {
let root = find(&mut parent, i);
match representative.get(&root).copied() {
Some(current) if !is_raw(current) || is_raw(i) => {}
_ => {
representative.insert(root, i);
}
}
}
(0..entries.len())
.map(|i| {
let root = find(&mut parent, i);
entries[representative[&root]].uid.clone()
})
.collect()
}
/// The columns every photo read selects, with the group aggregates joined in.
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
/// camera raw file — one query rather than a second lookup per tile.
///
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
/// own group: the column is `NULL` until the next refresh computes it, and a
/// `NULL` would otherwise match nothing and drop the photo off the page.
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
p.kind, p.favorite, g.n, g.raw \
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
/// Read one row of [`PHOTO_SELECT`].
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
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,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
})
}
impl Db {
/// Replace the timeline wholesale. Each item is
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
/// when the refresh could not resolve that photo's node, in which case the
/// flag already stored is kept rather than silently cleared.
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
/// read from the server in this pass, so its fields are taken as they are —
/// a `None` there means the server no longer reports that value. A row whose
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
/// whatever is already stored rather than having it silently cleared.
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
// timeline DTO carries only the uid and capture time, so the daemon may
// not know a photo's media type yet when it replaces the timeline. Keep
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
// `name` and `media_type` are learned-and-kept like the ratio and thumb
// verdict: the timeline DTO carries only the uid and capture time, so the
// daemon may not know them when it replaces the timeline — because the
// refresh skipped an already-resolved photo, or because resolving it
// failed. Keep any previously learned value so the tab split and the
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
// both are derived from the name. The photo relation is kept for the same
// reason — losing it would break a group up until the next resolve.
let learned: HashMap<String, Learned> = {
let mut stmt = tx.prepare(
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
main_uid, resolved_at FROM photos",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
Learned {
name: r.get(1)?,
ratio: r.get(2)?,
thumb_state: r.get(3)?,
media_type: r.get(4)?,
favorite: r.get::<_, i64>(5)? != 0,
content_hash: r.get(6)?,
main_uid: r.get(7)?,
resolved_at: r.get(8)?,
},
))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
// Everything the insert and the grouping need, resolved once per photo.
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
.iter()
.map(|row| {
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
thumb_state: THUMB_UNKNOWN,
..Learned::default()
});
if row.resolved_at.is_some() {
// This pass read the node itself, so what it says is the
// truth — including what it no longer says. A photo unlinked
// from its main on another device loses its `main_uid` here,
// which a carry could never express.
carried.name = row.name.clone();
carried.media_type = row.media_type.clone();
carried.favorite = row.favorite.unwrap_or(false);
carried.content_hash = row.content_hash.clone();
carried.main_uid = row.main_uid.clone();
} else {
carried.name = row.name.clone().or(carried.name);
carried.media_type = row.media_type.clone().or(carried.media_type);
carried.favorite = row.favorite.unwrap_or(carried.favorite);
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
}
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind = crate::control::PhotoKind::classify(
carried.name.as_deref(),
carried.media_type.as_deref(),
);
(row, carried, kind)
})
.collect();
let entries: Vec<Grouped> = resolved
.iter()
.map(|(row, carried, kind)| Grouped {
uid: row.uid.clone(),
kind: *kind,
main_uid: carried.main_uid.clone(),
day: capture_day(&tx, row.capture_time),
stem: name_stem(carried.name.as_deref()),
})
.collect();
let group_keys = group_photos(&entries);
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
content_hash, main_uid, group_key, resolved_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
)?;
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media, learned_favorite) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None, false));
let media_type = media_type.clone().or(learned_media);
let favorite = favorite.unwrap_or(learned_favorite);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
for (seq, ((row, carried, kind), group_key)) in
resolved.iter().zip(group_keys.iter()).enumerate()
{
stmt.execute(params![
uid,
capture_time,
name,
ratio,
thumb_state,
row.uid,
row.capture_time,
carried.name,
carried.ratio,
carried.thumb_state,
seq as i64,
media_type,
carried.media_type,
kind.as_i64(),
favorite as i64,
carried.favorite as i64,
carried.content_hash,
carried.main_uid,
group_key,
carried.resolved_at,
])?;
}
}
@@ -102,11 +382,84 @@ impl Db {
Ok(())
}
/// The uids whose node metadata is already resolved, so a refresh can skip
/// reading their nodes again. Everything else — a photo never seen before, or
/// one a remote event marked stale — is resolved. That is what keeps a
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
pub fn photos_resolved(&self) -> Result<Vec<String>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut uids = Vec::new();
for row in rows {
uids.push(row?);
}
Ok(uids)
}
/// Mark photos as needing a fresh metadata resolve, and report how many rows
/// that matched. This is how a remote change is recorded: the event feed says
/// *which* node changed but never *what* changed about it, so the uid is
/// written down and the next refresh reads the node.
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
if uids.is_empty() {
return Ok(0);
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let mut marked = 0;
{
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
for uid in uids {
marked += stmt.execute(params![uid])?;
}
}
tx.commit()?;
Ok(marked)
}
/// The same for the whole timeline: every photo is resolved again on the next
/// refresh. For a continuity loss, where the daemon cannot know what it
/// missed, and for an explicit `refresh photos --full`.
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
let conn = self.conn.lock();
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
}
/// Forget photos that have been trashed, so the gallery does not show them
/// until the next timeline refresh would have caught up.
///
/// Album membership goes with them: a trashed photo is gone from every album
/// that held it, and leaving the rows behind would keep it on the album
/// pages and in the album covers.
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
if uids.is_empty() {
return Ok(0);
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let mut removed = 0;
{
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
for uid in uids {
removed += photos.execute([uid])?;
albums.execute([uid])?;
}
}
tx.commit()?;
Ok(removed)
}
/// One page of the persisted timeline, newest first. `kind`, when set,
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
/// `favorites` restricts the page to favourited photos.
///
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
/// one tile. The Raw tab is the exception and lists every raw file, because
/// that tab exists to answer "which raws do I have".
pub fn photos_page(
&self,
offset: usize,
@@ -118,23 +471,24 @@ impl Db {
let conn = self.read();
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql = String::from(
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
);
let mut sql = String::from(PHOTO_SELECT);
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if kind != Some(crate::control::PhotoKind::Raw) {
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
}
if let Some(k) = kind {
binds.push(k.as_i64());
conds.push(format!("kind = ?{}", binds.len()));
conds.push(format!("p.kind = ?{}", binds.len()));
}
if let Some((from, to)) = range {
binds.push(from);
conds.push(format!("capture_time >= ?{}", binds.len()));
conds.push(format!("p.capture_time >= ?{}", binds.len()));
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
conds.push(format!("p.capture_time < ?{}", binds.len()));
}
if favorites {
conds.push("favorite = 1".to_string());
conds.push("p.favorite = 1".to_string());
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
@@ -145,22 +499,41 @@ impl Db {
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |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,
})
})?
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Every photo of one photo's group, in server order — the RAW and the JPEG
/// of a shot, or just the photo itself when it stands alone. The lightbox
/// switches between these, and deleting a tile trashes all of them.
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
let conn = self.read();
let sql = format!(
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(params![uid], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The whole persisted timeline, in stored order — every file, not one row
/// per group. 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 sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map([], stored_photo)?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
@@ -175,9 +548,15 @@ impl Db {
kind: Option<crate::control::PhotoKind>,
) -> Result<Vec<(i32, i32, usize)>> {
let conn = self.read();
// The scrubber has to agree with the page it scrubs, so it counts what
// the page shows: groups everywhere except on the Raw tab.
let (filter, binds): (&str, Vec<i64>) = match kind {
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
None => ("", Vec::new()),
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
Some(k) => (
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
vec![k.as_i64()],
),
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
};
let sql = format!(
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
@@ -202,7 +581,13 @@ impl Db {
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
let conn = self.read();
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
// Photos and Videos count groups — one shot is one entry, however many
// files it was stored as. Raw counts files, because the Raw tab lists
// them individually.
let mut stmt = conn.prepare(
"SELECT kind, COUNT(*) FROM photos \
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
)?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
for row in rows {
@@ -225,21 +610,9 @@ impl Db {
}
let conn = self.read();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |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,
})
})?;
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
+21 -8
View File
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
pub remote_uid: Option<String>,
pub local_mtime: i64,
pub local_size: i64,
/// The same modification time to nanosecond precision, when it is known.
///
/// `local_mtime` alone cannot tell an edit that landed inside the same
/// second as the last sync — and left the file the same length — from no
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
/// written before schema 28, which the comparison reads as "compare whole
/// seconds", so upgrading does not make an entire mirror look changed.
pub local_mtime_ns: Option<i64>,
pub remote_rev: Option<String>,
pub remote_hash: Option<String>,
}
@@ -168,7 +176,8 @@ impl Db {
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
@@ -179,6 +188,7 @@ impl Db {
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?,
})
})?;
let mut map = HashMap::new();
@@ -216,14 +226,16 @@ impl Db {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash",
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash,
local_mtime_ns = excluded.local_mtime_ns",
params![
folder_id,
e.rel_path,
@@ -232,6 +244,7 @@ impl Db {
e.local_size,
e.remote_rev,
e.remote_hash,
e.local_mtime_ns,
],
)?;
Ok(())
File diff suppressed because it is too large. Load diff
+24 -3
View File
@@ -11,6 +11,10 @@ pub struct StoredTrash {
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
/// The folder this node was trashed from, when it is known. `None` for a
/// row written before schema v29, and for a node whose parent the server
/// did not report.
pub parent_uid: Option<String>,
}
impl Db {
@@ -20,7 +24,8 @@ impl Db {
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)?;
for item in items {
stmt.execute(params![
@@ -28,7 +33,8 @@ impl Db {
item.name,
item.is_dir as i64,
item.size,
item.mtime
item.mtime,
item.parent_uid
])?;
}
}
@@ -41,7 +47,7 @@ impl Db {
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime FROM trash
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
@@ -51,6 +57,7 @@ impl Db {
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
parent_uid: r.get(5)?,
})
})?;
let mut items = Vec::new();
@@ -60,5 +67,19 @@ impl Db {
Ok(items)
}
/// Every trashed node paired with the folder it was trashed from, for the
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
/// restore wants only the links, not the names, sizes or ordering.
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
let conn = self.read();
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
let mut pairs = Vec::new();
for row in rows {
pairs.push(row?);
}
Ok(pairs)
}
// ---- device sync (devices.md) -----------------------------------------
}
+1 -1
View File
@@ -11,7 +11,7 @@ pub enum Error {
Proton(#[from] proton_sdk::ProtonError),
#[error("keyring: {0}")]
Keyring(#[from] keyring::Error),
Keyring(#[from] keyring_core::Error),
#[error("serialization: {0}")]
Serde(#[from] serde_json::Error),
+1
View File
@@ -20,6 +20,7 @@ pub mod search;
pub mod service;
pub mod shell;
pub mod syncignore;
pub mod takeout;
pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result};
+101
View File
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
/// The paths a scan should never descend into: the Drive mountpoint (walking it
/// would fault every remote node in through FUSE) plus our own state and cache
/// dirs, which hold blobs no user searches for by name.
///
/// Callers should extend this with [`nested_mount_points`] for each scan root —
/// see that function for why `same_file_system(true)` is not enough on its own.
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
vec![
mountpoint.to_path_buf(),
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
]
}
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
///
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
/// the directory is queued — so the walker has to touch a foreign mount to learn
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
/// the half-dead process keeps the cache.db lock that the next start needs
/// (docs/BUGS.md B90).
///
/// Excluding the mountpoints by path avoids that stat entirely. It does not
/// change what ends up indexed: a nested mount is a different device, so
/// `same_file_system(true)` was already going to skip it.
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
return Vec::new();
};
parse_nested_mount_points(&mounts, root)
}
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
/// be tested without a real `/proc`.
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
mounts
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(|field| PathBuf::from(unescape_mount_field(field)))
// Strictly below: `root` itself being a mountpoint must not exclude the
// whole scan.
.filter(|path| path.starts_with(root) && path != root)
.collect()
}
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
/// real path, silently putting a mount with a space in its name back in the walk.
fn unescape_mount_field(field: &str) -> String {
let mut out = String::with_capacity(field.len());
let mut rest = field;
while let Some(at) = rest.find('\\') {
out.push_str(&rest[..at]);
let escape = rest[at + 1..].get(..3);
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
Some(byte) => {
out.push(byte as char);
rest = &rest[at + 4..];
}
// Not an octal escape after all — keep the backslash verbatim.
None => {
out.push('\\');
rest = &rest[at + 1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -268,4 +331,42 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap();
}
/// A dead network FUSE mount under the scan root must be skipped by *path*,
/// before anything stats it — see `nested_mount_points`. The scan root itself
/// being a mountpoint must not exclude the entire walk.
#[test]
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
let mounts = "\
/dev/sda2 /home ext4 rw 0 0
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
tmpfs /run/user/1000 tmpfs rw 0 0
";
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
assert_eq!(
nested,
vec![
PathBuf::from("/home/narl/remote/narl.io"),
PathBuf::from("/home/narl/remote/gdrive"),
]
);
// `/home` is an ancestor, not a descendant: excluding it would empty the
// scan. `/run/user/1000` is unrelated.
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
}
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
/// mount whose path contains one back into the walk.
#[test]
fn mount_paths_are_unescaped() {
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
assert_eq!(
parse_nested_mount_points(mounts, Path::new("/home/narl")),
vec![PathBuf::from("/home/narl/My Cloud")]
);
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
}
}
+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)]
enum PromptFlag {
/// `--prompt=TEXT`
@@ -105,6 +124,13 @@ pub struct PromptConfig {
/// the prompt text; without one the launcher's own prompt flag is appended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub menu: Option<Vec<String>>,
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
/// anywhere in the argv is replaced by the command to run; without one the
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
/// expects.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terminal: Option<Vec<String>>,
/// How many results to feed the launcher. Unset means
/// [`DEFAULT_MENU_LIMIT`].
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -124,6 +150,8 @@ pub enum PromptMode {
Gtk,
/// The user's own launcher, driven over stdin/stdout.
Dmenu,
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
Fzf,
}
/// Rows an external launcher can show without becoming a scroll exercise. Larger
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
Some(argv)
}
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
/// including the "run this command" flag — the caller appends the command, or
/// substitutes it for a `{cmd}` token, via [`with_command`].
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
return Some(argv.clone());
}
let flavor = KNOWN_TERMINALS
.iter()
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
}
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
/// user wrote one, otherwise appended.
///
/// The token exists for terminals that want the command in the middle of their
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
/// last element is the flag the command follows.
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
if !argv.iter().any(|arg| arg == "{cmd}") {
let mut argv = argv.to_vec();
argv.extend_from_slice(command);
return argv;
}
let mut out = Vec::with_capacity(argv.len() + command.len());
for arg in argv {
if arg == "{cmd}" {
out.extend_from_slice(command);
} else {
out.push(arg.clone());
}
}
out
}
/// Show `items` in the launcher named by `argv` and wait for a choice.
///
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
@@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
}
fn on_path(program: &str) -> bool {
/// Whether `program` is an executable file on `PATH`.
pub fn on_path(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
@@ -408,6 +474,47 @@ mod tests {
);
}
#[test]
fn a_configured_terminal_is_used_verbatim() {
let configured = vec!["my-term".to_string(), "-e".to_string()];
assert_eq!(
resolve_terminal(Some(&configured)),
Some(configured.clone())
);
// An empty list is "not configured", not "run nothing".
assert_eq!(
resolve_terminal(Some(&Vec::new())),
resolve_terminal(None),
"an empty terminal list must fall back to detection"
);
}
#[test]
fn the_command_is_appended_unless_the_user_placed_it() {
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
assert_eq!(
with_command(&["foot".into(), "--".into()], &command),
vec!["foot", "--", "pdfs-prompt", "--fzf"]
);
assert_eq!(
with_command(
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
&command
),
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
);
}
#[test]
fn every_known_terminal_sets_the_same_app_id() {
for argv in KNOWN_TERMINALS {
assert!(
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
"{argv:?} must be floatable by one window rule"
);
}
}
#[test]
fn a_configured_launcher_is_used_verbatim() {
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
+21 -2
View File
@@ -23,8 +23,9 @@
//! ```
use std::ffi::OsStr;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
spawn(&argv, path);
}
/// Launch `argv`, detached from whatever is opening it.
///
/// The child gets its own process group and no inherited stdio, because the
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
/// closes the moment the selection is made. A child left in the terminal's
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
/// "open this folder in a terminal" would open a window that dies immediately
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
/// first place: every `argv` is another application, and a terminal one is
/// wrapped in its own emulator by the `terminal` rule flag.
fn spawn(argv: &[String], path: &Path) {
let Some((program, args)) = argv.split_first() else {
return;
};
tracing::info!(path = %path.display(), command = ?argv, "opening");
if let Err(e) = Command::new(program).args(args).spawn() {
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn();
if let Err(e) = spawned {
tracing::error!(command = ?argv, "open failed: {e}");
}
}
+11 -3
View File
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
.unwrap_or(false)
}
/// Enable the service so it survives reboots/logins and start it now. Called on
/// successful login.
/// Enable the service so it survives reboots/logins, and bring it up now.
/// Called on successful login.
///
/// `restart` rather than `enable --now`: the unit may already be active but
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
/// keep sleeping and the mount would not appear until the next poll fired.
/// Restarting makes the new session take effect immediately, and starts the
/// unit when it is not running at all.
pub fn enable_start() -> bool {
systemctl(&["enable", "--now"])
let enabled = systemctl(&["enable"]);
systemctl(&["restart"]) && enabled
}
/// Stop the running mount without disabling it. Called by the tray's
+23 -9
View File
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
"Thumbs.db",
];
/// Suffixes of files that are, by convention, incomplete or throwaway while they
/// wear the name: a download in flight, an editor's swap or backup, an atomic
/// write's scratch copy. On an on-demand mount these should never seal a remote
/// revision — the app renames the finished file to its real name, and only that
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
/// Suffixes that mean a file is incomplete *by construction*: only the program
/// writing it gives it this name, only while the write is in flight, and the
/// finished article always arrives under a different one. On an on-demand mount
/// these should never seal a remote revision — the app renames the finished file
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
/// and docs/BUGS.md B70.
///
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
/// disk, and a later pass picks it up if the rules change. Parking one on the
/// on-demand mount is irreversible in practice: the only exit is a rename to a
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
/// template, a tool's output — never gets one. It then never reaches Drive at
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
/// forever. The generic-scratch case they were added for renames within
/// milliseconds, so it never needed the park; the download suffixes below, which
/// stay in flight for minutes, are what B70 was actually about.
const TRANSIENT_SUFFIXES: &[&str] = &[
".crdownload", // Chromium/Brave/Edge
".part", // Firefox, wget, aria2c
".partial", // some downloaders / older IE
".download", // Safari / macOS
".tmp", // generic scratch
".temp", // generic scratch
".swp", // vim swap
".swx", // vim swap (secondary)
];
@@ -319,8 +331,6 @@ mod tests {
"movie.mkv.part",
"big.iso.partial",
"photo.jpg.download",
"export.xml.tmp",
"notes.TEMP",
".notes.txt.swp",
"doc.txt~",
".~lock.report.odt#",
@@ -340,6 +350,10 @@ mod tests {
"archive.partly.zip", // ".partly" is not ".part"
"template.dotx",
"readme",
// A file that is *named* `.tmp` and never renamed: parking it would
// keep it off Drive for good.
"index.tmp",
"notes.TEMP",
] {
assert!(!is_transient_name(name), "{name} should not be transient");
}
File diff suppressed because it is too large. Load diff
+5
View File
@@ -5,10 +5,14 @@ edition.workspace = true
license.workspace = true
authors.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
description = "FUSE on-demand filesystem for Proton Drive"
[dependencies]
pdfs-core.workspace = true
zip.workspace = true
sha1.workspace = true
proton-drive-rs.workspace = true
futures.workspace = true
tokio.workspace = true
@@ -16,6 +20,7 @@ tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
base64.workspace = true
image.workspace = true
notify = "8"
parking_lot.workspace = true
+266 -2
View File
@@ -1,6 +1,7 @@
//! Remote-event invalidation and local-index background workers.
use super::*;
use crate::shutdown::Shutdown;
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
/// entry is gone. Shared by the trash and delete paths, which differ only in
@@ -424,6 +425,165 @@ pub(super) async fn run_event_sync(
}
}
/// The state key holding the photos volume's event cursor. Separate from the
/// Drive cursor because the two volumes have their own event streams, and one
/// cursor cannot address both.
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
/// Poll the photos volume's event stream, so a photo deleted or added on another
/// device shows up in the gallery within one poll instead of on the timeline's
/// freshness window.
///
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
/// part of the FUSE mount, so there is no inode space to converge and nothing to
/// invalidate in the kernel. A trash or a delete removes the rows behind the
/// gallery; anything else only drops the timeline's freshness stamp, and the
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
/// file.
pub(super) async fn run_photos_event_sync(core: Core) {
let photos = core.photos();
// The photos volume only exists once the user has used Photos at all, and
// the daemon may well start before the network does.
let scope = loop {
match photos.get_photos_root().await {
Ok(Some(root)) => break root.tree_event_scope_id(),
Ok(None) => {
debug!("no photos volume; photo event sync not started");
return;
}
Err(error) => {
warn!(%error, "reading the photos root failed; retrying");
tokio::time::sleep(ONLINE_PROBE_MAX).await;
}
}
};
let mut cursor: Option<DriveEventId> = core
.db
.state_str(PHOTOS_EVENT_CURSOR)
.ok()
.flatten()
.map(DriveEventId::from);
info!(?cursor, "photo event sync started");
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
Ok(events) => events,
Err(error) => {
warn!(%error, "photo event poll failed; retrying after interval");
continue;
}
};
let Some(head) = events.last().map(|event| event.id().clone()) else {
continue;
};
apply_photo_events(&core, &events);
// One cursor write per batch, after the batch is applied: a crash in
// between replays events whose effects are idempotent.
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
warn!(%error, "persisting the photo event cursor failed");
continue;
}
cursor = Some(head);
}
}
/// What one batch of photos-volume events asks the gallery's tables to do.
#[derive(Debug, Default, PartialEq)]
pub(super) struct PhotoEventPlan {
/// Photos that left the volume, by delete or by trash.
pub(super) gone: Vec<String>,
/// Photos whose node changed, so their stored metadata is no longer trusted.
pub(super) touched: Vec<String>,
/// Whether the whole timeline has to be read again.
pub(super) full: bool,
}
impl PhotoEventPlan {
fn is_empty(&self) -> bool {
self.gone.is_empty() && self.touched.is_empty() && !self.full
}
}
/// Read one batch of photos-volume events. Pure, so the classification is
/// testable without a volume behind it.
///
/// `is_echo` claims an event as this daemon's own write. The feed replays our
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
/// every favourite the user toggles here would mark its own photo stale and buy
/// a resolve of a node we just wrote.
fn classify_photo_events(
events: &[DriveEvent],
mut is_echo: impl FnMut(&NodeUid) -> bool,
) -> PhotoEventPlan {
let mut plan = PhotoEventPlan::default();
for event in events {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => plan.gone.push(node_uid.to_string()),
DriveEvent::NodeUpdated {
node_uid,
is_trashed,
..
} => {
if *is_trashed {
plan.gone.push(node_uid.to_string());
} else if !is_echo(node_uid) {
// An untrash, a rename, a favourite toggled on the phone: the
// event says which node changed but never what about it, so
// the uid is marked stale and the next refresh reads it.
plan.touched.push(node_uid.to_string());
}
}
// The daemon cannot know what it missed here, so everything it
// thinks it knows about every photo is dropped.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
plan.full = true;
}
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
}
}
plan
}
/// Apply one batch of photos-volume events to the gallery's tables.
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
let plan = classify_photo_events(events, |uid| core.take_self_change(uid));
let PhotoEventPlan {
gone,
touched,
full,
} = &plan;
if !gone.is_empty() {
match core.db.photos_delete(gone) {
Ok(removed) if removed > 0 => {
debug!(removed, "photos removed by a remote event");
}
Ok(_) => {}
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
}
}
// A stale mark is what makes the next refresh cheap: it reads these photos'
// nodes and carries every other photo's metadata as it stands.
if !touched.is_empty() {
match core.db.photos_mark_unresolved(touched) {
Ok(marked) if marked > 0 => debug!(marked, "photos changed by a remote event"),
Ok(_) => {}
Err(error) => warn!(%error, "marking remotely changed photos failed"),
}
}
if *full {
match core.db.photos_mark_all_unresolved() {
Ok(marked) => debug!(marked, "the photo event stream lost continuity"),
Err(error) => warn!(%error, "marking the whole timeline unresolved failed"),
}
}
if !plan.is_empty() {
core.invalidate_photos();
core.invalidate_albums();
}
}
/// Keep the local-file index fresh for the launcher prompt's "This computer"
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
/// then sleeps; runs on its own thread for the life of the daemon.
@@ -437,6 +597,7 @@ pub(super) fn run_local_index(
indexing: Arc<AtomicBool>,
transfers: Arc<TransferRegistry>,
mountpoint: PathBuf,
shutdown: Arc<Shutdown>,
) {
loop {
let age = db.local_indexed_at().ok().flatten();
@@ -445,7 +606,11 @@ pub(super) fn run_local_index(
if stale {
scan_local_once(&db, &indexing, &transfers, &mountpoint);
}
std::thread::sleep(LOCAL_INDEX_CHECK);
// Interruptible, so teardown joins this thread instead of leaving it to
// keep rewriting the index for a mount that is gone (bugs.md B44).
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
return;
}
}
}
@@ -481,7 +646,12 @@ fn scan_local_once(
}
};
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
let mut excludes =
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
// Resolved per scan rather than once at startup: mounts come and go, and a
// mount that appeared since the daemon started is exactly the one most
// likely to be a half-alive network filesystem (see `nested_mount_points`).
excludes.extend(localindex::nested_mount_points(&home));
indexing.store(true, Ordering::Relaxed);
let started = Instant::now();
@@ -535,6 +705,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -781,4 +953,96 @@ mod tests {
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
assert!(db.get_event_cursor().unwrap().is_none());
}
/// A photo trashed or deleted elsewhere leaves the gallery; one that only
/// changed is marked stale, so the next refresh reads that node and no other.
#[test]
fn a_photo_event_names_what_it_changed() {
let plan = classify_photo_events(
&[
DriveEvent::NodeDeleted {
id: DriveEventId::from("e1"),
node_uid: uid("deleted"),
parent_node_uid: None,
},
DriveEvent::NodeUpdated {
id: DriveEventId::from("e2"),
node_uid: uid("trashed"),
parent_node_uid: None,
is_trashed: true,
is_shared: false,
},
DriveEvent::NodeUpdated {
id: DriveEventId::from("e3"),
node_uid: uid("favourited"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
},
],
|_| false,
);
assert_eq!(
plan,
PhotoEventPlan {
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
touched: vec![uid("favourited").to_string()],
full: false,
}
);
}
/// The feed replays this daemon's own writes. Applying one would cost a
/// resolve of a node whose changed field the daemon just wrote itself.
#[test]
fn our_own_photo_write_is_not_a_remote_change() {
let plan = classify_photo_events(
&[DriveEvent::NodeUpdated {
id: DriveEventId::from("e1"),
node_uid: uid("ours"),
parent_node_uid: None,
is_trashed: false,
is_shared: false,
}],
|claimed| *claimed == uid("ours"),
);
assert_eq!(plan, PhotoEventPlan::default());
}
/// A gap in the stream means the daemon cannot know what it missed, so it
/// keeps nothing.
#[test]
fn a_lost_photo_event_stream_asks_for_everything() {
for event in [
DriveEvent::ContinuityLost {
id: DriveEventId::from("e1"),
},
DriveEvent::ScopeAccessLost {
id: DriveEventId::from("e1"),
},
] {
let plan = classify_photo_events(std::slice::from_ref(&event), |_| false);
assert!(plan.full, "{event:?}");
assert!(plan.gone.is_empty());
assert!(plan.touched.is_empty());
}
}
/// Bookkeeping events change nothing about the gallery, and must not buy a
/// refresh of it.
#[test]
fn photo_bookkeeping_events_ask_for_nothing() {
let plan = classify_photo_events(
&[
DriveEvent::CursorAdvanced {
id: DriveEventId::from("e1"),
},
DriveEvent::SharedWithMeUpdated {
id: DriveEventId::from("e2"),
},
],
|_| false,
);
assert_eq!(plan, PhotoEventPlan::default());
}
}
+344 -28
View File
@@ -11,18 +11,18 @@
use std::io::{BufRead, BufReader, Write as _};
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::time::{Duration, Instant};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
TransferDirection,
TransferDirection, TrashFailure,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
use tracing::{debug, info, warn};
use super::transfers::CountingReader;
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
@@ -67,6 +67,16 @@ impl Drop for ControlHandlerPermit {
}
}
/// Handler threads in use, for the diagnostics. At the limit the daemon refuses
/// connections, which a front-end cannot tell apart from a dead daemon.
pub(crate) fn active_handlers() -> usize {
ACTIVE_CONTROL_HANDLERS.load(Ordering::Acquire)
}
pub(crate) fn handler_limit() -> usize {
MAX_CONTROL_HANDLERS
}
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
let mut bytes = Vec::new();
loop {
@@ -104,22 +114,100 @@ fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// Turn a CLI-supplied path into the mount that owns it and the path relative
/// to *that* mount's root.
///
/// [`rel_to_mount`] is the older, primary-only reading, and it is what every
/// mutating request still uses. It rejects anything outside the primary
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
/// serving a stale listing had no manual escape hatch (`docs/BUGS.md` B86).
///
/// A relative path keeps the old meaning, relative to the primary mount, since
/// there is nothing in it to choose a mount by.
fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
let p = Path::new(path);
if !p.is_absolute() {
return Ok((core.clone(), p.to_path_buf()));
}
if let Some(routed) = core.rooted_at(p) {
return Ok(routed);
}
// Fall back to the primary reading so the error text stays the familiar one
// when the path is simply outside everything this daemon has mounted.
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
}
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path);
if p.is_absolute() {
let relative = if p.is_absolute() {
p.strip_prefix(mountpoint)
.map(Path::to_path_buf)
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else {
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")))
}
}
/// Handle one control-socket connection: read a single JSON request line,
/// dispatch it against `core`, and write back a JSON response line.
/// Refresh scopes being served right now, one entry per running refresh.
///
/// A `Vec` because there are three scopes and at most `MAX_CONTROL_HANDLERS`
/// handlers, so a set would buy nothing.
static REFRESHING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// What makes two refreshes the same work: the scope, and for a directory the
/// folder, since refreshing one folder says nothing about another. A full
/// photos refresh is not the same work as an ordinary one — it forgets what the
/// daemon knows first — so it keys apart.
fn scope_key(scope: &RefreshScope) -> String {
match scope {
RefreshScope::Dir { path } => format!("dir:{path}"),
RefreshScope::Trash => "trash".to_string(),
RefreshScope::Photos { full: true } => "photos:full".to_string(),
RefreshScope::Photos { full: false } => "photos".to_string(),
}
}
/// Holds one refresh scope for as long as it is being served. `acquire`
/// returns `None` when that scope is already in flight.
struct RefreshGuard(String);
impl RefreshGuard {
fn acquire(scope: &RefreshScope) -> Option<Self> {
let key = scope_key(scope);
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
if running.contains(&key) {
return None;
}
running.push(key.clone());
Some(Self(key))
}
}
impl Drop for RefreshGuard {
fn drop(&mut self) {
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
if let Some(at) = running.iter().position(|key| *key == self.0) {
running.swap_remove(at);
}
}
}
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
warn!(error = %e, "control: could not set request timeout");
@@ -144,6 +232,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
return;
}
};
// Registered before the request is even parsed, so a handler that wedges
// inside the daemon shows up by name in `pdfs diagnostics`.
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => {
let pins = core.cache.list_pins();
@@ -193,37 +284,76 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
pins: core.cache.list_pins(),
},
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_dir(&rel) {
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
Ok((core, rel)) => match core.list_dir(&rel) {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Refresh { scope }) => {
let result = match &scope {
RefreshScope::Dir { path } => {
rel_to_mount(mountpoint, path).and_then(|rel| core.refresh_dir(&rel))
Ok(CtlRequest::Refresh { scope }) => match RefreshGuard::acquire(&scope) {
// The same scope is already being refreshed by another handler.
// Running it again cannot make the answer newer, and the duplicate
// would spend its whole run contending for the state and database
// locks the first one holds — which is how five stacked photo
// refreshes sat in flight for twenty-three minutes at mount time.
None => {
debug!(scope = %scope_key(&scope), "a refresh of this scope is already running");
CtlResponse::Ok {
message: "refreshing".to_string(),
}
RefreshScope::Trash => {
core.invalidate_trash();
Ok(())
}
RefreshScope::Photos => {
core.invalidate_photos();
// Whatever changed the timeline can just as easily have
// changed an album; one scope covers the whole photos view.
core.invalidate_albums();
Ok(())
}
};
match result {
Ok(()) => CtlResponse::Ok {
message: "refreshed".to_string(),
},
Err(e) => CtlResponse::error(e),
}
}
Some(_refreshing) => {
let result = match &scope {
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
.and_then(|(core, rel)| core.refresh_dir(&rel)),
RefreshScope::Trash => {
core.invalidate_trash();
Ok(())
}
RefreshScope::Photos { full } if !full && core.timeline_refreshing() => {
// A refresh is already running and will publish exactly the
// timeline this request asks for. Doing the work again
// cannot make it newer, and the invalidation plus the
// album read contend with the running refresh for the
// database lock — which is how a handful of stacked photo
// refreshes at mount time left five control requests
// in flight for twenty-three minutes.
debug!("a photos refresh is already running; joining it");
Ok(())
}
RefreshScope::Photos { full } => {
core.invalidate_photos();
// A full refresh is "forget what you know", not a second code
// path: the ordinary refresh then finds every photo stale.
if let Some(Err(e)) = full.then(|| core.db.photos_mark_all_unresolved()) {
warn!(error = %e, "marking the whole timeline unresolved failed");
}
// Whatever changed the timeline can just as easily have
// changed an album; one scope covers the whole photos view.
core.invalidate_albums();
// The refresh is started here, not awaited. A library of
// twelve thousand photos is re-read in chunks of
// `TIMELINE_ENRICH_CHUNK` nodes, which takes minutes —
// longer than the client's read bound, so awaiting it
// returned `Resource temporarily unavailable` to a caller
// whose refresh was in fact running fine (B92). The front
// end follows it with `PhotosRefreshStatus`.
core.spawn_timeline_refresh();
Ok(())
}
};
match result {
Ok(()) => CtlResponse::Ok {
message: match scope {
RefreshScope::Photos { .. } => "refreshing".to_string(),
_ => "refreshed".to_string(),
},
},
Err(e) => CtlResponse::error(e),
}
}
},
Ok(CtlRequest::PhotosTimeline {
offset,
limit,
@@ -243,6 +373,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
Ok(items) => CtlResponse::Photos {
available: true,
items,
counts: None,
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
Ok(()) => CtlResponse::Ok {
@@ -296,6 +434,50 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed) {
Ok((trashed, failed)) => CtlResponse::Trashed {
trashed,
failed: failed
.into_iter()
.map(|(uid, message)| TrashFailure { uid, message })
.collect(),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
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::PhotosRefreshStatus) => CtlResponse::PhotosRefresh {
running: core.timeline_refreshing(),
},
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath {
@@ -305,6 +487,81 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
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 {
name,
media_type,
@@ -521,6 +778,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::Diagnostics) => CtlResponse::Diagnostics {
diagnostics: core.diagnostics(),
},
Ok(CtlRequest::ListTrash) => match core.list_trash() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
@@ -1084,6 +1344,12 @@ pub(crate) fn run_control_socket(
) {
info!("control socket listening");
for conn in listener.incoming() {
// Checked on every accept, including the deliberate poke teardown sends
// to wake this thread out of a blocking accept (bugs.md B44).
if core.shutdown.is_stopping() {
info!("control socket closing");
return;
}
match conn {
Ok(stream) => {
let Some(permit) = ControlHandlerPermit::acquire() else {
@@ -1146,4 +1412,54 @@ mod request_limit_tests {
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());
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Two refreshes of the same scope are one piece of work; two refreshes of
/// different scopes are not.
#[test]
fn a_scope_refreshes_once_at_a_time() {
let photos = RefreshScope::Photos { full: false };
let held = RefreshGuard::acquire(&photos).expect("first refresh runs");
assert!(RefreshGuard::acquire(&photos).is_none());
// A full refresh forgets what the daemon knows, so it is other work.
assert!(RefreshGuard::acquire(&RefreshScope::Photos { full: true }).is_some());
assert!(
RefreshGuard::acquire(&RefreshScope::Dir {
path: "a".to_string()
})
.is_some()
);
drop(held);
assert!(RefreshGuard::acquire(&photos).is_some());
}
}
+254
View File
@@ -0,0 +1,254 @@
//! What the daemon is doing right now, for the case where it has stopped doing
//! anything at all.
//!
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
//! manual `systemctl --user restart`. There was no panic, so nothing in the
//! journal said what it had been waiting on — and nothing in the daemon could be
//! asked, either. This module is the answer to "what was it stuck on?".
//!
//! Everything here is built to work *while* the daemon is wedged, which rules
//! out the obvious implementations: no lock that a stuck job might hold, no
//! database query on the calling thread, no waiting on the worker pool. A
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
//! exactly the moment it matters.
use std::sync::LazyLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use parking_lot::Mutex;
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
use super::Core;
/// When this process started. Read at the first touch, which
/// [`super::mount::mount`] makes happen during startup so the uptime is the
/// daemon's, not the first diagnostics request's.
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
/// Control requests currently being served, keyed by a monotonic ticket.
///
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
/// entries, and the only operations are push, remove-by-ticket and read-all.
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
/// Start the daemon's uptime clock. Called once, from the mount path.
pub(crate) fn start_clock() {
LazyLock::force(&STARTED);
}
/// Registers one in-flight control request for as long as it is held.
pub(crate) struct InflightGuard(u64);
impl InflightGuard {
pub(crate) fn new(kind: String) -> Self {
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
INFLIGHT.lock().push((ticket, kind, Instant::now()));
Self(ticket)
}
}
impl Drop for InflightGuard {
fn drop(&mut self) {
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
}
}
/// The variant name of a serialised [`pdfs_core::control::Request`], without
/// parsing it into one.
///
/// serde's external tagging makes this a property of the text: a unit variant is
/// the bare string `"Status"`, anything else is an object whose single key is
/// the variant name. Reading the name off the line rather than matching on 80
/// variants keeps this cheap, and keeps it correct when a variant is added.
pub(crate) fn request_kind(line: &str) -> String {
let line = line.trim();
let name = if let Some(rest) = line.strip_prefix('{') {
rest.trim_start()
.strip_prefix('"')
.and_then(|rest| rest.split('"').next())
} else {
line.strip_prefix('"')
.and_then(|rest| rest.split('"').next())
};
let name = name.unwrap_or("unknown");
// A bare `Refresh` says nothing about which view wedged, and the three
// scopes cost wildly different amounts of work: a directory refresh is two
// cheap calls, a photos refresh can pull a whole library. The scope is one
// more peek into the same line.
if name == "Refresh"
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
{
return format!("Refresh({scope})");
}
name.to_string()
}
/// The variant name serde wrote for the value of `key`, whether that value is a
/// bare string (a unit variant) or an object whose single key is the name.
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
let rest = line.split_once(key)?.1.trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
rest.strip_prefix('"')?
.split('"')
.next()
.map(str::to_string)
}
/// Resident set size in bytes, or `0` when `/proc` did not answer.
///
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
/// need the page size, and this needs no dependency at all.
pub(crate) fn rss_bytes() -> u64 {
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return 0;
};
parse_vm_rss(&status)
}
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
fn parse_vm_rss(status: &str) -> u64 {
status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
.map_or(0, |kb| kb.saturating_mul(1024))
}
impl Core {
/// Assemble a [`Diagnostics`] report.
///
/// The pending-op count is the one field that needs the database, so it is
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
/// the database must still be able to report everything else about itself.
pub(crate) fn diagnostics(&self) -> Diagnostics {
let pool = self.workers.snapshot();
let now = Instant::now();
let mut inflight: Vec<InflightRequest> = INFLIGHT
.try_lock()
.map(|entries| {
entries
.iter()
.map(|(_, kind, started)| InflightRequest {
kind: kind.clone(),
age_secs: now.duration_since(*started).as_secs(),
})
.collect()
})
.unwrap_or_default();
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
Diagnostics {
uptime_secs: STARTED.elapsed().as_secs(),
rss_bytes: rss_bytes(),
workers: pool
.workers
.into_iter()
.map(|worker| WorkerState {
name: worker.name,
busy: worker.busy,
label: worker.label,
age_secs: worker.age_ms / 1000,
})
.collect(),
meta_queued: pool.meta_queued,
transfer_queued: pool.transfer_queued,
meta_completed: pool.meta_completed,
transfer_completed: pool.transfer_completed,
inflight,
control_handlers: super::control::active_handlers(),
control_handler_limit: super::control::handler_limit(),
pending_ops: self.pending_ops_quickly(),
}
}
/// Queued mutations, or `0` if the database did not answer within
/// [`PENDING_COUNT_DEADLINE`].
fn pending_ops_quickly(&self) -> u64 {
let db = self.db.clone();
let (tx, rx) = std::sync::mpsc::channel();
if std::thread::Builder::new()
.name("pdfs-diagnostics".into())
.spawn(move || {
let counts = db.pending_op_counts().unwrap_or_default();
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
let _ = tx.send(total);
})
.is_err()
{
return 0;
}
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
}
}
/// How long the report waits for the database before reporting without it.
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
#[cfg(test)]
mod tests {
use super::*;
/// The request name is read off the wire format, so it must match both
/// shapes serde produces.
#[test]
fn the_request_kind_comes_off_both_wire_shapes() {
assert_eq!(request_kind("\"Status\""), "Status");
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
assert_eq!(request_kind("garbage"), "unknown");
}
/// A stuck `Refresh` is only actionable if the report says which view it
/// was refreshing, so the scope rides along with the name.
#[test]
fn a_refresh_reports_its_scope() {
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
"Refresh(Photos)"
);
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
"Refresh(Dir)"
);
assert_eq!(
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
"Refresh(Trash)"
);
// A shape this does not recognise still names the request.
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
}
/// A guard registers for its lifetime and nothing longer: a handler that
/// returns must not leave a permanent "in flight" entry behind.
#[test]
fn a_guard_registers_only_while_it_lives() {
let before = INFLIGHT.lock().len();
{
let _guard = InflightGuard::new("OpenFile".into());
assert_eq!(INFLIGHT.lock().len(), before + 1);
}
assert_eq!(INFLIGHT.lock().len(), before);
}
/// Whatever else it reports, the resident size has to be a real reading.
#[test]
fn rss_is_readable() {
assert!(rss_bytes() > 0);
}
/// `VmRSS` is in kB, and a status without one must read as unknown rather
/// than as a daemon using no memory.
#[test]
fn vm_rss_is_kilobytes_and_absence_is_zero() {
assert_eq!(
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
2048 * 1024
);
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
}
}
File diff suppressed because it is too large. Load diff
+235 -105
View File
@@ -1,6 +1,7 @@
//! Kernel-facing FUSE callback adapter.
use super::*;
use crate::state::DirListing;
impl Filesystem for ProtonFs {
/// Negotiate kernel-side behaviour once, before any other callback.
@@ -48,7 +49,7 @@ impl Filesystem for ProtonFs {
/// ([`Core::account_quota_cached`] serves a warm one from memory).
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
let core = self.core.clone();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "statfs", move || {
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
let bsize = u64::from(REPORTED_BLKSIZE);
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
@@ -88,7 +89,7 @@ impl Filesystem for ProtonFs {
return;
}
let fs = self.clone();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "lookup", move || {
fs.serve_lookup(parent, &name, reply, true)
});
}
@@ -162,15 +163,46 @@ impl Filesystem for ProtonFs {
}
}
/// Freeze this directory's listing for the life of the handle.
///
/// `readdir` is paged, and its continuation offset only means anything
/// against a listing that does not move underneath it. Taking the snapshot
/// here — once per `opendir`, which the kernel pairs exactly with one
/// `releasedir` — is what makes the offsets stable, so a create or a trash
/// during an enumeration can no longer make the caller skip or repeat an
/// entry (bugs.md B43).
///
/// Enumerating a cold folder is a remote call, so it goes to a worker for
/// the same reason `lookup` and `readdir` do.
fn opendir(&self, _req: &Request, ino: INodeNo, _flags: OpenFlags, reply: ReplyOpen) {
let ino = ino.0;
if self.core.children_cached(ino) {
self.serve_opendir(ino, reply);
return;
}
let fs = self.clone();
self.core
.workers
.run(Lane::Meta, "opendir", move || fs.serve_opendir(ino, reply));
}
fn readdir(
&self,
_req: &Request,
ino: INodeNo,
_fh: FileHandle,
fh: FileHandle,
offset: u64,
reply: ReplyDirectory,
) {
let ino = ino.0;
// The snapshot is the whole point: served from it, this is a pure
// in-memory walk with no chance of a remote call, so it never needs a
// worker. Only a handle whose `opendir` failed to snapshot (or a kernel
// that issued `readdir` without one) falls back to the live path.
if let Some(listing) = self.core.state().dir_snapshots.get(&fh.0).cloned() {
reply_listing(&listing, offset, reply);
return;
}
// Same split as `lookup`: only the cold, remote-enumerating path pays
// for a worker.
if self.core.children_cached(ino) {
@@ -178,9 +210,21 @@ impl Filesystem for ProtonFs {
return;
}
let fs = self.clone();
self.core
.workers
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
self.core.workers.run(Lane::Meta, "readdir", move || {
fs.serve_readdir(ino, offset, reply)
});
}
fn releasedir(
&self,
_req: &Request,
_ino: INodeNo,
fh: FileHandle,
_flags: OpenFlags,
reply: ReplyEmpty,
) {
self.core.state().dir_snapshots.remove(&fh.0);
reply.ok();
}
/// A read-only open is a map lookup and answers inline; a write open has to
@@ -193,9 +237,9 @@ impl Filesystem for ProtonFs {
return;
}
let fs = self.clone();
self.core
.workers
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
self.core.workers.run(Lane::Transfer, "open", move || {
fs.serve_open(ino, flags, reply)
});
}
fn read(
@@ -285,7 +329,7 @@ impl Filesystem for ProtonFs {
// even read off the FUSE device. It only reads `state`, so moving it
// races with nothing. FUSE does not require replies in request order.
let core = self.core.clone();
self.core.workers.run(Lane::Transfer, move || {
self.core.workers.run(Lane::Transfer, "read", move || {
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
Ok(bytes) => reply.data(&bytes),
Err(e) => reply.error(e),
@@ -322,9 +366,9 @@ impl Filesystem for ProtonFs {
// one create at a time, with `ls` on the same mount timing out for as
// long as the burst lasted. See [`Workers`].
let fs = self.clone();
self.core
.workers
.run(Lane::Meta, move || fs.serve_create(parent, &name, reply));
self.core.workers.run(Lane::Meta, "create", move || {
fs.serve_create(parent, &name, reply)
});
}
fn write(
@@ -462,7 +506,7 @@ impl Filesystem for ProtonFs {
// the scratch file).
let ino = ino.0;
let fs = self.clone();
self.core.workers.run(Lane::Transfer, move || {
self.core.workers.run(Lane::Transfer, "truncate", move || {
if let Err(e) = fs.core.queue_truncate(ino, size) {
reply.error(e);
return;
@@ -576,7 +620,7 @@ impl Filesystem for ProtonFs {
let fs = self.clone();
self.core
.workers
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
.run(Lane::Transfer, "fsync", move || fs.serve_fsync(fh, reply));
}
fn release(
@@ -636,7 +680,7 @@ impl Filesystem for ProtonFs {
};
// `trash_child` is a remote call; see `serve_create`.
let fs = self.clone();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "unlink", move || {
fs.serve_unlink(parent, &name_str, reply)
});
}
@@ -657,9 +701,9 @@ impl Filesystem for ProtonFs {
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
// it: two remote calls. See `serve_create`.
let fs = self.clone();
self.core
.workers
.run(Lane::Meta, move || fs.serve_rmdir(parent, &name_str, reply));
self.core.workers.run(Lane::Meta, "rmdir", move || {
fs.serve_rmdir(parent, &name_str, reply)
});
}
fn mkdir(
@@ -687,9 +731,9 @@ impl Filesystem for ProtonFs {
// remote round trip, and inline it blocks every other request on the
// mount until it answers.
let fs = self.clone();
self.core
.workers
.run(Lane::Meta, move || fs.serve_mkdir(parent, &name, reply));
self.core.workers.run(Lane::Meta, "mkdir", move || {
fs.serve_mkdir(parent, &name, reply)
});
}
fn rename(
@@ -730,7 +774,7 @@ impl Filesystem for ProtonFs {
// move), and the replaced-destination path adds a trash on top. See
// `serve_create` for why none of that may run on the dispatch loop.
let fs = self.clone();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "rename", move || {
fs.serve_rename(parent, &name, newparent, &newname, flags, reply)
});
}
@@ -755,7 +799,7 @@ impl Filesystem for ProtonFs {
// network per call (B5).
let fs = self.clone();
let name = name.to_os_string();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "getxattr", move || {
fs.serve_getxattr(ino, &name, size, reply)
});
}
@@ -912,6 +956,58 @@ pub struct ProtonFs {
gid: u32,
}
/// How many times a write `open` will re-read its base after a `release`
/// published a newer queued revision underneath it (bugs.md B32).
///
/// One retry covers the race, which needs a close and an open of the same file
/// to overlap on different threads. More than that is not contention, it is a
/// file being republished continuously, and looping on it would keep a FUSE
/// worker busy indefinitely.
const OPEN_BASE_ATTEMPTS: u32 = 3;
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
/// the interned children in `children` order.
///
/// Split out because it is built from two places that must agree — `opendir`
/// freezes it, and the fallback `readdir` builds it live — and a divergence
/// between them would show up as entries appearing or vanishing depending on
/// whether the kernel happened to send an `opendir`.
fn build_listing(st: &State, ino: u64) -> DirListing {
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
// "." and ".." occupy offsets 0 and 1; real children follow.
let mut listing = vec![
(ino, true, ".".to_string()),
(parent, true, "..".to_string()),
];
if let Some(kids) = st.children.get(&ino) {
for &kid in kids {
if let Some(e) = st.entries.get(&kid) {
listing.push((kid, e.node.is_folder(), e.node.name.clone()));
}
}
}
listing
}
/// Fill a `readdir` reply from `listing`, resuming at `offset`.
///
/// The cookie handed back is the index of the *next* entry, so a resumed call
/// continues exactly where the last one stopped — which is only true because
/// the listing behind those indexes is a snapshot (bugs.md B43).
fn reply_listing(listing: &DirListing, offset: u64, mut reply: ReplyDirectory) {
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
let ft = if *is_dir {
FileType::Directory
} else {
FileType::RegularFile
};
if reply.add(INodeNo(*ino), (i + 1) as u64, ft, name) {
break;
}
}
reply.ok();
}
pub(crate) fn access_allowed(is_dir: bool, access: Access, mask: AccessFlags) -> bool {
(!mask.contains(AccessFlags::W_OK) || access.writable())
&& (!mask.contains(AccessFlags::X_OK) || is_dir)
@@ -1003,7 +1099,7 @@ impl ProtonFs {
if !off_loop {
let fs = self.clone();
let name = name.to_string();
self.core.workers.run(Lane::Meta, move || {
self.core.workers.run(Lane::Meta, "lookup", move || {
fs.serve_lookup(parent, &name, reply, true)
});
return;
@@ -1027,40 +1123,39 @@ impl ProtonFs {
}
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
fn serve_readdir(&self, ino: u64, offset: u64, mut reply: ReplyDirectory) {
/// The body of [`Filesystem::opendir`]: enumerate the folder if it is cold,
/// then publish one frozen listing under a fresh handle.
///
/// A failure to enumerate is reported as a failed `opendir` rather than
/// deferred to the first `readdir` — the caller gets the errno at the syscall
/// that can still act on it.
fn serve_opendir(&self, ino: u64, reply: ReplyOpen) {
if let Err(e) = self.core.ensure_children(ino) {
reply.error(e);
return;
}
let mut st = self.core.state();
let listing = Arc::new(build_listing(&st, ino));
// Shares the counter with write handles, so a directory handle can never
// collide with a file handle (both are `fh` values in one kernel space).
let fh = st.next_fh;
st.next_fh += 1;
st.dir_snapshots.insert(fh, listing);
drop(st);
reply.opened(FileHandle(fh), FopenFlags::empty());
}
/// The live, un-snapshotted `readdir`. Reached only by a handle that has no
/// snapshot — see [`Filesystem::readdir`].
fn serve_readdir(&self, ino: u64, offset: u64, reply: ReplyDirectory) {
if let Err(e) = self.core.ensure_children(ino) {
reply.error(e);
return;
}
let st = self.core.state();
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
// "." and ".." occupy offsets 0 and 1; real children follow.
let mut listing: Vec<(u64, FileType, String)> = vec![
(ino, FileType::Directory, ".".to_string()),
(parent, FileType::Directory, "..".to_string()),
];
if let Some(kids) = st.children.get(&ino) {
for &kid in kids {
if let Some(e) = st.entries.get(&kid) {
let ft = if e.node.is_folder() {
FileType::Directory
} else {
FileType::RegularFile
};
listing.push((kid, ft, e.node.name.clone()));
}
}
}
let listing = build_listing(&st, ino);
drop(st);
for (i, (ino, ft, name)) in listing.into_iter().enumerate().skip(offset as usize) {
// The stored offset is that of the *next* entry to resume at.
if reply.add(INodeNo(ino), (i + 1) as u64, ft, &name) {
break;
}
}
reply.ok();
reply_listing(&listing, offset, reply);
}
fn attr(&self, ino: u64, node: &Node, access: Access) -> FileAttr {
@@ -1142,66 +1237,101 @@ impl ProtonFs {
// any network access. An incomplete blob still has gaps referring to
// the remote base; stacking another write on it cannot be represented
// safely by WriteHandle, so fail the open rather than risk corruption.
let pending_base = self.core.pending.lock().get(&uid).cloned();
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
entry.open_count = entry.open_count.saturating_sub(1);
}
error!(%uid, "refusing write over incomplete queued revision");
reply.error(Errno::EIO);
return;
}
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
entry.open_count = entry.open_count.saturating_sub(1);
//
// Reading that blob and installing the handle cannot be done under one
// lock — the copy is the size of the file — so the base is re-checked at
// the moment the handle goes in, and a `release` that published a newer
// revision in between sends this around again. Without the re-check the
// new handle carried the *older* base, and its own release published
// content that silently discarded the revision closed a moment earlier
// (bugs.md B32). A second attempt is already unlikely and a third would
// mean something is republishing continuously, so give up rather than
// spin: the caller's `open` fails instead of its data being lost.
//
// `reply` is consumed by whichever arm answers, so the loop yields an
// outcome and the answer is sent once, after it.
let outcome = 'install: {
for attempt in 0..OPEN_BASE_ATTEMPTS {
let pending_base = self.core.pending.lock().get(&uid).cloned();
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
error!(%uid, "refusing write over incomplete queued revision");
break 'install Err(Errno::EIO);
}
error!(%uid, error = %e, "create scratch file failed");
reply.error(Errno::EIO);
return;
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
error!(%uid, error = %e, "create scratch file failed");
break 'install Err(Errno::EIO);
}
};
let mut initial_written = Intervals::default();
if let Some(pending) = &pending_base {
// Reflink where the filesystem can: this blob is the whole
// file, and a byte-for-byte copy of a multi-GB pending
// revision is the kind of work `open` must not do
// (`ContentCache::clone_or_copy`).
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
let _ = std::fs::remove_file(&path);
error!(%uid, source = %pending.path.display(), error = %e,
"copy queued revision into write scratch failed");
break 'install Err(Errno::EIO);
}
initial_written.add(0, pending.meta.len);
}
// Sampled *before* the state lock, never under it: no site in
// the daemon holds `pending` and `state` at once (see
// `stamp_pending_sizes`) and this must not become the first.
// What that costs is a window of a few instructions instead of
// a lock-tight comparison — against the multi-gigabyte copy
// above, which is the window that actually loses data.
let current = self.core.pending_blob(&uid);
let mut st = self.core.state();
// Only a handle this open is about to *create* can carry a stale
// base; if one already exists, that handle is authoritative and
// the scratch prepared above is discarded either way.
let creating = !st.active_writes.contains_key(&ino.0);
if creating && current != pending_base.map(|p| p.path) {
drop(st);
let _ = std::fs::remove_file(&path);
warn!(%uid, attempt, "queued revision changed while opening for write; retrying");
continue;
}
let fh = st.next_fh;
st.next_fh += 1;
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
WriteHandle {
ino: ino.0,
uid: uid.clone(),
file: Arc::new(file),
path,
written: initial_written,
// Starts at the current size; reads in [0, base_size)
// come from the base until overwritten.
len: base_size,
base_size,
base_mtime,
base_revision_id,
dirty: false,
open_count: 0,
}
});
aw.open_count += 1;
st.handles.insert(fh, ino.0);
break 'install Ok(fh);
}
error!(%uid, "queued revision kept changing while opening for write");
Err(Errno::EIO)
};
let mut initial_written = Intervals::default();
if let Some(pending) = &pending_base {
// Reflink where the filesystem can: this blob is the whole file, and
// a byte-for-byte copy of a multi-GB pending revision is the kind of
// work `open` must not do (`ContentCache::clone_or_copy`).
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
match outcome {
Ok(fh) => reply.opened(FileHandle(fh), FopenFlags::empty()),
Err(error) => {
// The lookup count was raised before any of this could fail.
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
entry.open_count = entry.open_count.saturating_sub(1);
}
let _ = std::fs::remove_file(&path);
error!(%uid, source = %pending.path.display(), error = %e,
"copy queued revision into write scratch failed");
reply.error(Errno::EIO);
return;
reply.error(error);
}
initial_written.add(0, pending.meta.len);
}
let mut st = self.core.state();
let fh = st.next_fh;
st.next_fh += 1;
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
WriteHandle {
ino: ino.0,
uid,
file: Arc::new(file),
path,
written: initial_written,
// Starts at the current size; reads in [0, base_size) come from
// the base until overwritten.
len: base_size,
base_size,
base_mtime,
base_revision_id,
dirty: false,
open_count: 0,
}
});
aw.open_count += 1;
st.handles.insert(fh, ino.0);
reply.opened(FileHandle(fh), FopenFlags::empty());
}
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
File diff suppressed because it is too large. Load diff
+139 -28
View File
@@ -341,6 +341,9 @@ pub fn mount(
username,
sweep_mode,
} = options;
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own.
super::diagnostics::start_clock();
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
@@ -365,6 +368,7 @@ pub fn mount(
readers: Arc::new(Mutex::new(HashMap::new())),
block_ring: Arc::new(Mutex::new(BlockRing::default())),
block_flight: Arc::new(BlockFlight::default()),
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
prefetch: Arc::new(Prefetch::default()),
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
@@ -376,6 +380,9 @@ pub fn mount(
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
@@ -385,7 +392,15 @@ pub fn mount(
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
self_changes: Arc::new(Mutex::new(HashMap::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)),
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
size_waiters: Arc::new(Default::default()),
@@ -413,16 +428,29 @@ pub fn mount(
// And finally the staged blobs neither of those accounts for: bytes a
// failed release left behind with nothing pointing at them.
core.reconcile_staging();
{
// Several drain workers over one queue. One thread meant a single large
// upload held every queued rename, trash and small write behind it for as
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
// workers off each other's nodes, which is the only ordering the queue
// actually needs. Worker 0 additionally runs the queue's idle chores.
//
// Every long-lived thread started from here on is kept in `workers` and
// joined at the end of this function. Leaving them running turned an
// in-process remount into a second generation of drain workers, sync engines
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
for worker in 0..DRAIN_WORKERS {
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-drain".into())
.spawn(move || core.run_pending_drain())?;
workers.push(
std::thread::Builder::new()
.name(format!("pdfs-drain-{worker}"))
.spawn(move || core.run_pending_drain(worker == 0))?,
);
}
// Start the folder-sync engine. It watches every mirror folder, polls the
// remotes, and reconciles on its own thread — never in front of a FUSE call.
sync::spawn(core.clone(), sync_rx);
workers.extend(sync::spawn(core.clone(), sync_rx));
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
// out identical to the live file, surface the ones that genuinely diverge.
@@ -434,18 +462,22 @@ pub fn mount(
} else {
info!(mode = ?sweep_mode, "conflict sweep enabled");
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || core.run_conflict_sweep_loop())?;
workers.push(
std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || core.run_conflict_sweep_loop())?,
);
}
// Mounted from the cache: watch for the network coming back so the mount can
// stop being read-only-ish without the user restarting the daemon.
if !online {
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || core.run_online_probe())?;
workers.push(
std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || core.run_online_probe())?,
);
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
@@ -455,9 +487,12 @@ pub fn mount(
let indexing = core.indexing.clone();
let transfers = core.transfers.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
let shutdown = core.shutdown.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
);
}
// Bind the control socket before the FUSE session takes over the thread. A
@@ -494,25 +529,51 @@ pub fn mount(
let control_core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
if let Err(error) = std::thread::Builder::new()
match std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
{
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(unmount_error) = mount.teardown() {
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
Ok(handle) => workers.push(handle),
Err(error) => {
// A failure here abandons the mount, so the background threads
// started above have to be told to stop with it.
stop_workers(&core, control_socket, workers);
let secondaries = core.mounts.lock().close_and_drain();
for (id, mount) in secondaries {
if let Err(unmount_error) = mount.teardown() {
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
}
}
if let Err(unmount_error) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %unmount_error, "unmount after control startup failure failed");
}
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
if let Err(unmount_error) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %unmount_error, "unmount after control startup failure failed");
}
let _ = std::fs::remove_file(control_socket);
return Err(error);
}
}
// The supervisor watches the pool and answers the systemd watchdog. Started
// after the listener, because its liveness probe is a round trip over that
// socket.
{
let supervisor_core = core.clone();
let socket = control_socket.to_path_buf();
match std::thread::Builder::new()
.name("pdfs-supervisor".into())
.spawn(move || supervisor::run(supervisor_core, socket))
{
Ok(handle) => workers.push(handle),
// Not fatal: the mount works without a supervisor, it just goes back
// to being undiagnosable if it wedges.
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
}
}
// The mount is live and the control socket answers: this is the moment the
// unit is actually usable, which is what `Type=notify` reports.
systemd::ready();
// Re-establish on-demand mounts only after the primary session is live.
// Keep the worker so shutdown can close publication, wait for any in-flight
// fetch to observe that closure, and then drain a stable registry.
@@ -530,6 +591,9 @@ pub fn mount(
}
};
rt.spawn(run_event_sync(client, scope, core.clone()));
// The photos volume has its own event stream; without it the gallery only
// learns about a phone-side deletion when the timeline goes stale.
rt.spawn(run_photos_event_sync(core.clone()));
if online {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
@@ -619,10 +683,57 @@ pub fn mount(
}
}
let _ = std::fs::remove_file(control_socket);
// Last, because the workers may legitimately be finishing an upload, and
// nothing they can still do is harmful once the mounts are down.
stop_workers(&core, control_socket, workers);
Ok(outcome)
}
/// How long teardown waits for the FUSE worker pool before proceeding without
/// it. Long enough for an in-flight block to finish, short enough that
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
/// Signal every background loop, wake the ones that are blocked, and join them.
///
/// The mount used to return with its drain workers, sync engine, sweep, online
/// probe, indexer and control listener all still running on `Core` clones. The
/// process usually exited straight afterwards and hid it; an in-process remount
/// did not, and started a second full set on top of the first (bugs.md B44).
///
/// Two of the waits need waking rather than merely flagging: the drain workers
/// block on their own condvar (`wake_drain`), and the control listener blocks
/// inside `accept`, which only returns when a connection arrives — so it gets
/// one, from us, after the flag is already set. A join that takes a while is
/// expected and correct: a worker part-way through an upload finishes it rather
/// than abandoning the user's bytes mid-flight.
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
// Say so before the joins: a teardown that takes a while must not be read as
// a missed watchdog ping and turned into a kill.
systemd::stopping();
core.shutdown.stop();
core.wake_drain();
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
// Connect while the socket still exists — that is the whole point of the
// poke — then unlink it so nothing else can arrive behind us. The listener
// re-checks the flag after every accept, so even a connection that races the
// unlink cannot put it back to sleep.
let _ = std::os::unix::net::UnixStream::connect(control_socket);
let _ = std::fs::remove_file(control_socket);
for worker in workers {
if worker.join().is_err() {
warn!("a background worker panicked before shutdown");
}
}
// The FUSE worker pool last, and *before* the caller drops the tokio
// runtime: a worker still inside a job when the runtime goes away panics on
// its next timer with "A Tokio 1.x context was found, but it is being
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
// network cannot hold the stop the user asked for.
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
debug!("background workers stopped");
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
File diff suppressed because it is too large. Load diff
+306 -71
View File
@@ -26,6 +26,11 @@ const PREFETCH_MAX: u64 = 8;
/// Window a sequential read starts at, and returns to after a seek.
const PREFETCH_MIN: u64 = 2;
/// Largest file [`Core::repair_block`] will pull down whole to work around a
/// revision whose block table understates it. Past this a single read would cost
/// gigabytes of download, so the read fails instead and says to pin the file.
pub(super) const REPAIR_MAX: u64 = 512 * 1024 * 1024;
/// Blocks that may be prefetching at once, across every file. Prefetch takes a
/// permit and gives up rather than queueing, so it can never sit in front of a
/// demand read in the SDK's flat block semaphore.
@@ -97,22 +102,38 @@ static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
/// whose tag no longer matches has its blocks dropped rather than served, so a
/// new revision can never be stitched together from the old one.
/// A ring entry's identity: the node, and the plaintext range the block covers.
///
/// The range, rather than the block index, because the index alone stopped
/// naming one range when the geometry started coming from the revision — a
/// block cached under the [`BlockGeometry::uniform`] fallback and the block at
/// the same index under the real geometry are different bytes
/// (`docs/BUGS.md` B85). A range is the same identity under either plan, so a
/// re-planned read misses rather than being served the wrong bytes.
type RingKey = (NodeUid, BlockSpan);
#[derive(Default)]
pub(super) struct BlockRing {
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
blocks: HashMap<RingKey, Arc<Vec<u8>>>,
/// Keys least-recently-used first, for eviction.
order: VecDeque<(NodeUid, u64)>,
order: VecDeque<RingKey>,
/// Per-node validity tag, `(mtime, size)`.
tags: HashMap<NodeUid, (i64, u64)>,
bytes: u64,
}
impl BlockRing {
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
fn get(
&mut self,
uid: &NodeUid,
mtime: i64,
size: u64,
span: BlockSpan,
) -> Option<Arc<Vec<u8>>> {
if self.tags.get(uid) != Some(&(mtime, size)) {
return None;
}
let key = (uid.clone(), idx);
let key = (uid.clone(), span);
let hit = self.blocks.get(&key).cloned()?;
// Move to the young end: a block being read repeatedly (the whole reason
// this exists) must not age out under a sequential read passing through.
@@ -123,14 +144,21 @@ impl BlockRing {
Some(hit)
}
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
fn insert(
&mut self,
uid: &NodeUid,
mtime: i64,
size: u64,
span: BlockSpan,
bytes: Arc<Vec<u8>>,
) {
if self.tags.get(uid) != Some(&(mtime, size)) {
// Revision changed under us (or first sight): anything held for this
// node describes the old one.
self.drop_node(uid);
self.tags.insert(uid.clone(), (mtime, size));
}
let key = (uid.clone(), idx);
let key = (uid.clone(), span);
if self.blocks.contains_key(&key) {
return;
}
@@ -171,8 +199,10 @@ impl BlockRing {
/// The result of fetching one block, shared between everyone who wanted it.
type BlockResult = Result<Arc<Vec<u8>>, Errno>;
/// Identifies a block *of a revision*: node, validity tag, index.
type BlockKey = (NodeUid, i64, u64, u64);
/// Identifies a block *of a revision*: node, validity tag, and the plaintext
/// range the block covers — see [`RingKey`] for why it is the range and not the
/// index.
type BlockKey = (NodeUid, i64, u64, BlockSpan);
/// A claim on a block key: who owns the fetch, and where its result is published.
type Claim = (u64, tokio::sync::watch::Receiver<Option<BlockResult>>);
@@ -412,11 +442,30 @@ impl Core {
self.prefetch.streams.lock().remove(uid);
}
/// How this revision's plaintext is divided into content blocks.
///
/// The revision's own geometry when a previous read has learned and recorded
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
/// first read, because learning it means opening a `RevisionReader`, and
/// opening one per read is precisely the per-file key derivation the block
/// cache exists to avoid (bugs.md B12, B85).
///
/// Being wrong here is not a correctness problem: `read_at` plans over the
/// real sizes and clamps to what was asked for either way. It costs a
/// straddling fetch and a cached block the next read will not ask for, and
/// both settle once the geometry is known.
fn block_geometry(&self, uid: &NodeUid, mtime: i64, fsize: u64) -> BlockGeometry {
self.cache
.block_geometry(uid, mtime, fsize)
.unwrap_or_else(|| BlockGeometry::uniform(fsize))
}
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
/// the on-disk caches before the network: a whole-file blob (pinned files)
/// first, then the block cache — fetching only the [`BLOCK_SIZE`]-aligned
/// blocks that overlap the request and caching each. `mtime`/`fsize` validate
/// both caches. Network I/O runs without any lock held.
/// first, then the block cache — fetching only the blocks that overlap the
/// request, on the revision's own boundaries ([`Core::block_geometry`]), and
/// caching each. `mtime`/`fsize` validate both caches. Network I/O runs
/// without any lock held.
pub(super) fn read_range(
&self,
uid: &NodeUid,
@@ -495,7 +544,7 @@ impl Core {
Ok(out)
}
/// Fetch block `bidx`, joining a fetch already in flight for it rather than
/// Fetch the block at `span`, joining a fetch already in flight rather than
/// issuing a second one, and publish the result to everyone who joined.
///
/// See [`BlockFlight`]. A follower whose leader disappears (a panic, or a
@@ -507,10 +556,10 @@ impl Core {
uid: &NodeUid,
mtime: i64,
fsize: u64,
bidx: u64,
span: BlockSpan,
cache_blocks: bool,
) -> BlockResult {
let key: BlockKey = (uid.clone(), mtime, fsize, bidx);
let key: BlockKey = (uid.clone(), mtime, fsize, span);
let claim = {
let mut inflight = self.block_flight.inflight.lock();
match inflight.get(&key) {
@@ -542,12 +591,12 @@ impl Core {
}
}
return self
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
.read_block(reader, uid, mtime, fsize, span, cache_blocks)
.await;
}
};
let result = self
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
.read_block(reader, uid, mtime, fsize, span, cache_blocks)
.await;
// Retire the claim before publishing, so a racer that wakes and re-enters
// finds no stale entry to join.
@@ -563,36 +612,193 @@ impl Core {
/// The disk store is handed to a blocking thread — it is a 4 MiB write plus
/// a cache-index update, and neither the reactor nor the kernel read waiting
/// on these bytes should be held up by it.
///
/// A block that comes back shorter than the file's size says it must be is
/// never served, cached or promoted: it is repaired from the whole-file
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
async fn read_block(
&self,
reader: &Arc<RevisionReader>,
uid: &NodeUid,
mtime: i64,
fsize: u64,
bidx: u64,
span: BlockSpan,
cache_blocks: bool,
) -> BlockResult {
let bstart = bidx * BLOCK_SIZE;
let blen = BLOCK_SIZE.min(fsize - bstart);
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
warn!(%uid, bstart, blen, error = %e, "block read failed");
Errno::EIO
})?;
// The reader derives every block boundary from the revision's recorded
// block sizes. If they do not add up to the size the node reports, this
// file's boundaries are wrong, and the failure is not confined to short
// reads: sizes that *overstate* a block shift every block after it and
// return full-length reads of the wrong bytes. So a disagreement of any
// kind takes the file off the range path entirely (bugs.md B84).
let bytes = if reader.size() != fsize {
warn!(
%uid, bidx = span.idx, fsize,
reader_size = reader.size(),
reader_blocks = reader.block_sizes().len(),
"the revision's block table disagrees with the node size"
);
self.repair_block(uid, mtime, fsize, span).await?
} else {
// Learned here rather than on a path of its own: this is where a
// reader is open anyway, and recording the real geometry is what
// lets the *next* read plan its blocks on the revision's own
// boundaries instead of the 4 MiB fallback (bugs.md B85).
if cache_blocks {
self.cache
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
}
let bytes = reader.read_at(span.start, span.len).await.map_err(|e| {
warn!(%uid, start = span.start, len = span.len, error = %e, "block read failed");
Errno::EIO
})?;
if bytes.len() as u64 == span.len {
bytes
} else {
warn!(
%uid, bidx = span.idx, blen = span.len, got = bytes.len(), fsize,
"block came back shorter than the range asked for"
);
self.repair_block(uid, mtime, fsize, span).await?
}
};
let bytes = Arc::new(bytes);
if cache_blocks {
let cache = self.cache.clone();
let uid = uid.clone();
let bytes = bytes.clone();
tokio::task::spawn_blocking(move || {
let _ = cache.store_block(&uid, mtime, fsize, bidx, &bytes);
let _ = cache.store_block(&uid, mtime, fsize, span, &bytes);
});
}
self.block_ring
.lock()
.insert(uid, mtime, fsize, bidx, bytes.clone());
.insert(uid, mtime, fsize, span, bytes.clone());
Ok(bytes)
}
/// Recover the block at `span` of a file whose per-block reads cannot be
/// by fetching the whole revision through the manifest-verified download and
/// serving the block out of that.
///
/// The range path derives every block boundary from the revision's recorded
/// block sizes. When those understate the file, reads come back short with
/// no error anywhere, and the caller hands userspace a truncated file that
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
/// arithmetic — it streams the block list — so it returns the whole file
/// where the range path cannot, which is why pinning was the workaround.
/// This does the same thing on the user's behalf, once, and the resulting
/// blob answers every later read of the file before the block path is
/// consulted at all.
///
/// Bounded by [`REPAIR_MAX`]: past that, one unlucky read would pull down
/// gigabytes, and a clear `EIO` plus a `pdfs pin` is the better trade.
async fn repair_block(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
span: BlockSpan,
) -> Result<Vec<u8>, Errno> {
let (bstart, blen) = (span.start, span.len);
if fsize > REPAIR_MAX {
warn!(
%uid, fsize,
"file is too large to repair from a whole-file download; pin it to read it"
);
return Err(Errno::EIO);
}
// One download per file, not per block: every block of a file with a bad
// block table discovers the same problem at once.
let lock = {
let mut locks = self.content_repairs.lock().await;
locks.entry(uid.clone()).or_default().clone()
};
let guard = lock.clone().lock_owned().await;
let downloaded = match self.cache.read_range(uid, mtime, fsize, bstart, blen) {
// The first arrival did the work; the rest just read its blob.
Some(bytes) if bytes.len() as u64 == blen => Ok(Some(bytes)),
_ => self
.download_whole_revision(uid, mtime, fsize)
.await
.map(|()| None),
};
// Before releasing, so the map entry is only dropped once no waiter is
// still holding it.
drop(guard);
self.release_repair_lock(uid, lock).await;
if let Some(bytes) = downloaded? {
return Ok(bytes);
}
match self.cache.read_range(uid, mtime, fsize, bstart, blen) {
Some(bytes) if bytes.len() as u64 == blen => {
info!(%uid, bidx = span.idx, "served a short block from a repaired whole-file download");
Ok(bytes)
}
_ => {
error!(%uid, bidx = span.idx, fsize, "repairing a short block did not produce the block");
Err(Errno::EIO)
}
}
}
/// Download the whole of `uid`'s active revision to disk and adopt it as the
/// cached blob, so the range path is bypassed entirely for this file.
///
/// Streamed into a scratch file rather than memory: this runs for files up
/// to [`REPAIR_MAX`], and buffering that per concurrent reader is not a cost
/// a read should carry. A download that does not produce exactly `fsize`
/// bytes is discarded — a short blob would fail the cache's own validity
/// check anyway, and storing it would only hide the disagreement one layer
/// further down.
async fn download_whole_revision(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
) -> Result<(), Errno> {
let (mut file, path) = self.cache.create_scratch().map_err(|error| {
error!(%uid, %error, "creating a scratch file for a repair download failed");
Errno::EIO
})?;
let outcome = self.client.download_file_to(uid, &mut file).await;
let stored = (|| {
outcome.map_err(|error| {
warn!(%uid, %error, "repair download failed");
Errno::EIO
})?;
file.sync_all().map_err(|error| {
warn!(%uid, %error, "syncing a repair download failed");
Errno::EIO
})?;
let got = file.metadata().map(|m| m.len()).unwrap_or(0);
if got != fsize {
warn!(%uid, fsize, got, "repair download did not match the node size");
return Err(Errno::EIO);
}
self.cache
.store_file(uid, mtime, fsize, &path)
.map_err(|error| {
error!(%uid, %error, "adopting a repair download as cached content failed");
Errno::EIO
})
})();
// The blob is a hardlink to the same inode, so unlinking the scratch
// name leaves it intact.
let _ = std::fs::remove_file(&path);
stored
}
/// Drop a repair lock's map entry once nobody else holds it, so a file
/// repaired long ago does not keep a mutex alive forever.
async fn release_repair_lock(&self, uid: &NodeUid, lock: Arc<tokio::sync::Mutex<()>>) {
let mut locks = self.content_repairs.lock().await;
// Two: the map's and ours. Anything more means a waiter still needs it.
if Arc::strong_count(&lock) <= 2 {
locks.remove(uid);
}
}
/// Warm the blocks after a sequential read into the caches, in the
/// background, so the next read finds them instead of paying a round-trip.
///
@@ -619,17 +825,19 @@ impl Core {
if depth == 0 {
return;
}
let last_block = fsize.saturating_sub(1) / BLOCK_SIZE;
let wanted: Vec<u64> = ((last + 1)..=(last + depth).min(last_block))
.filter(|&bidx| {
let geometry = self.block_geometry(uid, mtime, fsize);
let last_block = geometry.blocks().saturating_sub(1);
let wanted: Vec<BlockSpan> = ((last + 1)..=(last + depth).min(last_block))
.filter_map(|bidx| geometry.span(bidx))
.filter(|&span| {
// Blocks already in memory need nothing; blocks already on disk
// are cheap enough to serve that spending a round-trip to move
// them into the ring would be the wrong trade.
self.block_ring
.lock()
.get(uid, mtime, fsize, bidx)
.get(uid, mtime, fsize, span)
.is_none()
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, bidx))
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, span))
})
.collect();
if wanted.is_empty() {
@@ -646,7 +854,7 @@ impl Core {
// round-trips, and the reader catches up with the window before it
// finishes.
let mut set = tokio::task::JoinSet::new();
for bidx in wanted {
for span in wanted {
let Ok(permit) = core.prefetch_budget.clone().try_acquire_owned() else {
break; // the budget is spent; the demand path can have it
};
@@ -654,10 +862,10 @@ impl Core {
set.spawn(async move {
let _permit = permit;
if let Err(error) = core
.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
.await
{
debug!(%uid, bidx, ?error, "prefetch failed");
debug!(%uid, bidx = span.idx, ?error, "prefetch failed");
}
});
}
@@ -687,8 +895,13 @@ impl Core {
}
let end = offset.saturating_add(len).min(fsize);
let mut out = Vec::with_capacity((end - offset) as usize);
let first = offset / BLOCK_SIZE;
let last = (end - 1) / BLOCK_SIZE;
// On the revision's own boundaries where they are known, and on the
// 4 MiB fallback where they are not (bugs.md B85).
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
return Ok(out);
};
let (first, last) = (head.idx, tail.idx);
// Collect the blocks overlapping the request, serving any already cached
// and fetching the rest concurrently. A multi-block read (e.g. a media
@@ -696,31 +909,31 @@ impl Core {
// would otherwise stall on each block round-trip in turn; downloading the
// misses in parallel saturates the connection and bounds latency at the
// slowest single block instead of their sum.
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
let mut misses: Vec<u64> = Vec::new();
for bidx in first..=last {
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity(wanted.len());
let mut misses: Vec<BlockSpan> = Vec::new();
for &span in &wanted {
// The in-memory ring comes first on both paths: a streaming read has
// no other cache at all, and a cached one would otherwise pay two
// opens, a JSON parse and a 4 MiB read+alloc for every 128 KiB the
// kernel asks for (audit T3).
let hit = self.block_ring.lock().get(uid, mtime, fsize, bidx);
let hit = self.block_ring.lock().get(uid, mtime, fsize, span);
let hit = hit.or_else(|| {
if !cache_blocks {
return None;
}
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?);
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, span)?);
// Promote it, so the other ~31 reads of this block are answered
// from memory.
self.block_ring
.lock()
.insert(uid, mtime, fsize, bidx, bytes.clone());
.insert(uid, mtime, fsize, span, bytes.clone());
Some(bytes)
});
match hit {
Some(b) => blocks.push(Some(b)),
None => {
blocks.push(None);
misses.push(bidx);
misses.push(span);
}
}
}
@@ -733,12 +946,12 @@ impl Core {
let reader = self.revision_reader(uid, mtime, fsize).await?;
let mut set = tokio::task::JoinSet::new();
for &bidx in &misses {
for &span in &misses {
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
set.spawn(async move {
core.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
core.fetch_block(&reader, &uid, mtime, fsize, span, cache_blocks)
.await
.map(|bytes| (bidx, bytes))
.map(|bytes| (span.idx, bytes))
});
}
let mut out = Vec::with_capacity(misses.len());
@@ -756,11 +969,22 @@ impl Core {
// ahead of a sequential reader instead of stalling once it catches up.
self.prefetch_ahead(uid, mtime, fsize, first, last, cache_blocks);
for (i, block) in blocks.into_iter().enumerate() {
let bidx = first + i as u64;
let bstart = bidx * BLOCK_SIZE;
for (span, block) in wanted.into_iter().zip(blocks) {
let bstart = span.start;
// Every slot is populated: cache hits up front, misses by the fetch above.
let block = block.expect("block fetched or cached");
// Both sources guarantee the length the geometry says the block has
// — the cache validates it on read, the network path in `read_block`
// — so this refuses rather than quietly contributing fewer bytes
// than the range covers, which is what turned a bad block into a
// truncated file (bugs.md B84).
if block.len() as u64 != span.len {
error!(
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
"assembling a read hit a short block"
);
return Err(Errno::EIO);
}
let s = (offset.max(bstart) - bstart) as usize;
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
if s < e {
@@ -850,13 +1074,13 @@ impl Core {
return None;
}
let mut out = Vec::with_capacity((end - offset) as usize);
for bidx in (offset / BLOCK_SIZE)..=((end - 1) / BLOCK_SIZE) {
let ring = self.block_ring.lock().get(uid, mtime, fsize, bidx);
for span in self.block_geometry(uid, mtime, fsize).spans(offset, end) {
let ring = self.block_ring.lock().get(uid, mtime, fsize, span);
let block = match ring {
Some(bytes) => bytes,
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?),
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, span)?),
};
let bstart = bidx * BLOCK_SIZE;
let bstart = span.start;
let s = (offset.max(bstart) - bstart) as usize;
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
if s >= e {
@@ -976,31 +1200,42 @@ mod block_ring_tests {
RING_BYTES / BLOCK_SIZE
}
/// Block `idx` under the uniform geometry — the shape these tests are
/// written against, since what they exercise is tagging and eviction rather
/// than block layout.
fn span(idx: u64) -> BlockSpan {
BlockSpan {
idx,
start: idx * BLOCK_SIZE,
len: BLOCK_SIZE,
}
}
#[test]
fn a_stored_block_is_served_back() {
let mut ring = BlockRing::default();
let node = uid("a");
ring.insert(&node, 7, 100, 3, block(0xab));
assert_eq!(ring.get(&node, 7, 100, 3).map(|b| b[0]), Some(0xab));
ring.insert(&node, 7, 100, span(3), block(0xab));
assert_eq!(ring.get(&node, 7, 100, span(3)).map(|b| b[0]), Some(0xab));
}
#[test]
fn a_block_of_another_revision_is_never_served() {
let mut ring = BlockRing::default();
let node = uid("a");
ring.insert(&node, 7, 100, 0, block(1));
ring.insert(&node, 7, 100, span(0), block(1));
// Same node, newer revision: the tag moved, so the old bytes go.
assert!(ring.get(&node, 8, 100, 0).is_none());
assert!(ring.get(&node, 7, 101, 0).is_none());
assert!(ring.get(&node, 8, 100, span(0)).is_none());
assert!(ring.get(&node, 7, 101, span(0)).is_none());
}
#[test]
fn a_new_revision_drops_what_the_old_one_left() {
let mut ring = BlockRing::default();
let node = uid("a");
ring.insert(&node, 7, 100, 0, block(1));
ring.insert(&node, 8, 100, 0, block(2));
assert_eq!(ring.get(&node, 8, 100, 0).map(|b| b[0]), Some(2));
ring.insert(&node, 7, 100, span(0), block(1));
ring.insert(&node, 8, 100, span(0), block(2));
assert_eq!(ring.get(&node, 8, 100, span(0)).map(|b| b[0]), Some(2));
assert_eq!(ring.bytes, BLOCK_SIZE);
}
@@ -1009,13 +1244,13 @@ mod block_ring_tests {
let mut ring = BlockRing::default();
let node = uid("a");
for idx in 0..capacity() {
ring.insert(&node, 1, u64::MAX, idx, block(idx as u8));
ring.insert(&node, 1, u64::MAX, span(idx), block(idx as u8));
}
// Re-reading block 0 makes block 1 the oldest instead.
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
ring.insert(&node, 1, u64::MAX, capacity(), block(0xff));
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
assert!(ring.get(&node, 1, u64::MAX, 1).is_none());
assert!(ring.get(&node, 1, u64::MAX, span(0)).is_some());
ring.insert(&node, 1, u64::MAX, span(capacity()), block(0xff));
assert!(ring.get(&node, 1, u64::MAX, span(0)).is_some());
assert!(ring.get(&node, 1, u64::MAX, span(1)).is_none());
assert_eq!(ring.bytes, RING_BYTES);
}
@@ -1023,9 +1258,9 @@ mod block_ring_tests {
fn a_node_whose_last_block_ages_out_keeps_no_tag() {
let mut ring = BlockRing::default();
let (old, new) = (uid("old"), uid("new"));
ring.insert(&old, 1, u64::MAX, 0, block(1));
ring.insert(&old, 1, u64::MAX, span(0), block(1));
for idx in 0..capacity() {
ring.insert(&new, 1, u64::MAX, idx, block(2));
ring.insert(&new, 1, u64::MAX, span(idx), block(2));
}
assert!(!ring.tags.contains_key(&old));
assert!(ring.tags.contains_key(&new));
@@ -1035,11 +1270,11 @@ mod block_ring_tests {
fn dropping_a_node_frees_only_its_own_bytes() {
let mut ring = BlockRing::default();
let (a, b) = (uid("a"), uid("b"));
ring.insert(&a, 1, 100, 0, block(1));
ring.insert(&b, 1, 100, 0, block(2));
ring.insert(&a, 1, 100, span(0), block(1));
ring.insert(&b, 1, 100, span(0), block(2));
ring.drop_node(&a);
assert!(ring.get(&a, 1, 100, 0).is_none());
assert_eq!(ring.get(&b, 1, 100, 0).map(|x| x[0]), Some(2));
assert!(ring.get(&a, 1, 100, span(0)).is_none());
assert_eq!(ring.get(&b, 1, 100, span(0)).map(|x| x[0]), Some(2));
assert_eq!(ring.bytes, BLOCK_SIZE);
}
}
+332
View File
@@ -0,0 +1,332 @@
//! 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(),
};
match self.rt.block_on(photos.update_photos(&[update])) {
// As in `set_photo_favorite`: claim our own echo, so the tag we
// just set does not come back as a remote change.
Ok(_) => self.note_self_change(&new_uid),
Err(e) => {
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 crate::QUOTA_TTL;
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind};
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
.map(role_to_str)
.unwrap_or_default()
.to_string();
let invitation = n.membership.as_ref();
let shared_by = invitation
.and_then(|m| m.inviter_email.clone())
.unwrap_or_default();
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
// Only a signature that is present and wrong is a warning. An invitation
// that could not be checked — unsigned, or no keys for the claimed inviter —
// is not evidence of forgery, and flagging every such share would teach the
// user to ignore the flag.
let shared_by_unverified = invitation
.and_then(|m| m.inviter_verification)
.is_some_and(|status| status == VerificationStatus::Failed);
DirEntry {
name: n.name,
is_dir,
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(),
role,
shared_by,
shared_at,
shared_by_unverified,
}
}
@@ -609,6 +625,7 @@ impl Core {
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
modified: n.modification_time,
name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members,
+96
View File
@@ -0,0 +1,96 @@
//! One shutdown signal shared by every long-lived worker in the daemon.
//!
//! The mount used to end by unmounting and returning, leaving the drain
//! workers, the sync engine, the conflict sweep, the online probe and the local
//! indexer running on `Core` clones. In a process that exits immediately that is
//! invisible; in an in-process remount — which is how the daemon recovers from a
//! mount going away underneath it — it leaks a whole generation of threads that
//! keep writing to the database and calling the API on behalf of a mount that no
//! longer exists (bugs.md B44).
//!
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
//! called, so teardown does not have to wait out a five-minute sweep interval to
//! join a thread.
use std::time::Duration;
use parking_lot::{Condvar, Mutex};
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
/// told to stop belongs to a mount that is going away.
#[derive(Default)]
pub(crate) struct Shutdown {
stopping: Mutex<bool>,
changed: Condvar,
}
impl Shutdown {
/// Whether teardown has begun. Loops check this where they would otherwise
/// start another unit of work.
pub(crate) fn is_stopping(&self) -> bool {
*self.stopping.lock()
}
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
/// Idempotent, because more than one path can reach teardown (a signal, the
/// kernel mount ending, a failed startup).
pub(crate) fn stop(&self) {
let mut stopping = self.stopping.lock();
*stopping = true;
self.changed.notify_all();
}
/// Wait up to `duration`, or until teardown starts.
///
/// Returns `false` if the daemon is stopping — including when it was already
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
/// correct loop condition and no separate check is needed at the top.
pub(crate) fn sleep(&self, duration: Duration) -> bool {
let mut stopping = self.stopping.lock();
if !*stopping {
self.changed.wait_for(&mut stopping, duration);
}
!*stopping
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::Instant;
#[test]
fn sleeping_returns_early_once_stopped() {
let shutdown = Arc::new(Shutdown::default());
assert!(!shutdown.is_stopping());
let waker = shutdown.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(20));
waker.stop();
});
let started = Instant::now();
// An hour's worth of interval, cut short by the stop.
assert!(!shutdown.sleep(Duration::from_secs(3600)));
assert!(
started.elapsed() < Duration::from_secs(5),
"stop must interrupt the wait, not wait it out"
);
assert!(shutdown.is_stopping());
// Already stopping: the next wait does not sleep at all.
let started = Instant::now();
assert!(!shutdown.sleep(Duration::from_secs(3600)));
assert!(started.elapsed() < Duration::from_secs(5));
}
#[test]
fn sleeping_runs_to_the_timeout_while_running() {
let shutdown = Shutdown::default();
assert!(shutdown.sleep(Duration::from_millis(10)));
assert!(!shutdown.is_stopping());
}
}
+21
View File
@@ -141,6 +141,11 @@ pub(crate) struct PendingRevision {
pub(crate) meta: StagedWrite,
}
/// One directory's entries as `readdir` reports them: `(ino, is_dir, name)` per
/// entry, in reply order. The kernel's file-type enum is mapped in at reply
/// time so this module stays free of `fuser`.
pub(crate) type DirListing = Vec<(u64, bool, String)>;
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
pub(crate) struct State {
@@ -158,6 +163,16 @@ pub(crate) struct State {
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
pub(crate) next_fh: u64,
/// One frozen listing per open directory handle, taken at `opendir`.
///
/// `readdir` resumes from a byte offset into the listing it is walking, so
/// that listing has to stay the same across the calls of one `getdents`
/// sequence. Rebuilding it live meant a create or a trash landing between
/// two pages shifted every later entry, and the caller silently skipped or
/// repeated whatever crossed the page boundary. Each entry is
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
/// module stays free of `fuser` (bugs.md B43).
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
/// Resident inode attributes whose effective access changed during an
/// intern/root refresh. Core drains this set and notifies the matching
/// kernel only after releasing the State lock.
@@ -317,6 +332,7 @@ impl State {
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
dir_snapshots: HashMap::new(),
access_changes: HashSet::new(),
share_access,
db,
@@ -1104,6 +1120,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
@@ -1112,6 +1130,9 @@ mod tests {
share_id: ShareId::from("share"),
membership_id: ShareMembershipId::from("membership"),
permissions,
invite_time: None,
inviter_email: None,
inviter_verification: None,
}
}
+229
View File
@@ -0,0 +1,229 @@
//! The thread that watches the daemon for the thing the daemon cannot report
//! about itself: that it has stopped making progress.
//!
//! Three jobs, all cheap, all on one timer:
//!
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
//! queue that is not draining, a control request that never returned — each
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
//! all; this is what turns the next one into a one-line diagnosis.
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
//! control socket. Pinging unconditionally from a thread that does nothing
//! else would keep the unit "healthy" through exactly the hang it is meant to
//! catch: the probe is what makes the ping mean "a client can still talk to
//! this daemon".
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
//! until it was over.
use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use pdfs_core::control::Request as CtlRequest;
use tracing::{info, warn};
use super::Core;
use super::systemd;
/// How often the supervisor looks. Fast enough that a stall is named while the
/// user is still watching it, slow enough to cost nothing.
const TICK: Duration = Duration::from_secs(15);
/// How long one job may hold a worker, or one control request may run, before it
/// is called out. Generous: a large upload chunk or a cold folder listing on a
/// slow link is minutes of legitimate work, and a false WARN every few minutes
/// would train everyone to ignore the real one.
const STALL_AFTER: Duration = Duration::from_secs(120);
/// How often resident size goes into the log.
const RSS_EVERY: Duration = Duration::from_secs(300);
/// How long the liveness probe waits for the daemon to answer its own control
/// socket. Well under the watchdog period, so a probe that times out still
/// leaves time for the manager to hear nothing and act.
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
pub(crate) fn run(core: Core, control_socket: PathBuf) {
let watchdog = systemd::watchdog_period();
if let Some(period) = watchdog {
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
}
let mut last_rss = Instant::now();
let mut last_ping = Instant::now();
// Per-lane completion counts from the previous tick, to tell a queue that is
// being worked through from one that is not moving at all.
let mut last_completed = (0_u64, 0_u64);
while core.shutdown.sleep(TICK) {
let report = core.diagnostics();
let stalled = report_stalls(&report, last_completed);
last_completed = (report.meta_completed, report.transfer_completed);
if last_rss.elapsed() >= RSS_EVERY {
info!(
rss_bytes = report.rss_bytes,
pending_ops = report.pending_ops,
meta_queued = report.meta_queued,
transfer_queued = report.transfer_queued,
stalled,
"daemon resource sample"
);
last_rss = Instant::now();
}
// A stalled lane is deliberately *not* a reason to withhold the ping:
// the mount may still be answering everything else, and a restart would
// throw away in-flight uploads. The probe decides that; a stall only
// gets said out loud.
if let Some(period) = watchdog
&& last_ping.elapsed() >= period
{
if probe(&control_socket) {
systemd::watchdog_ping();
} else {
warn!(
"daemon did not answer its own control socket; withholding the watchdog ping"
);
}
last_ping = Instant::now();
}
}
}
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
/// anything was reported, so the caller can weigh it.
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
let threshold = STALL_AFTER.as_secs();
let mut stalled = false;
for worker in &report.workers {
if worker.busy && worker.age_secs >= threshold {
stalled = true;
warn!(
worker = %worker.name,
job = %worker.label,
age_secs = worker.age_secs,
"a fuse worker has held the same job for a long time"
);
}
}
if let Some(oldest) = report.inflight.first()
&& oldest.age_secs >= threshold
{
stalled = true;
warn!(
request = %oldest.kind,
age_secs = oldest.age_secs,
in_flight = report.inflight.len(),
"a control request has been running for a long time"
);
}
// A backed-up lane is normal; a backed-up lane that finished nothing since
// the last tick is not.
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
stalled = true;
warn!(
queued = report.meta_queued,
"the metadata lane has not completed a job since the last check"
);
}
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
stalled = true;
warn!(
queued = report.transfer_queued,
"the transfer lane has not completed a job since the last check"
);
}
stalled
}
/// One round trip over the control socket: connect, ask for the cheapest
/// request there is, read the answer.
///
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
/// depends on — without touching the network or the database.
fn probe(control_socket: &Path) -> bool {
let Ok(stream) = UnixStream::connect(control_socket) else {
return false;
};
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
{
return false;
}
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
return false;
};
let mut stream = stream;
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
return false;
}
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
fn busy_worker(age_secs: u64) -> WorkerState {
WorkerState {
name: "pdfs-fuse-3".into(),
busy: true,
label: "read".into(),
age_secs,
}
}
/// A worker doing normal work must not be reported: a WARN every tick is a
/// WARN nobody reads.
#[test]
fn ordinary_work_is_not_a_stall() {
let report = Diagnostics {
workers: vec![busy_worker(5)],
..Default::default()
};
assert!(!report_stalls(&report, (0, 0)));
}
/// A worker on the same job past the threshold is the shape of the hang.
#[test]
fn a_long_held_job_is_a_stall() {
let report = Diagnostics {
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
..Default::default()
};
assert!(report_stalls(&report, (0, 0)));
}
/// So is a control request that never returned.
#[test]
fn a_long_running_request_is_a_stall() {
let report = Diagnostics {
inflight: vec![InflightRequest {
kind: "OpenFile".into(),
age_secs: STALL_AFTER.as_secs() + 1,
}],
..Default::default()
};
assert!(report_stalls(&report, (0, 0)));
}
/// A queue that is draining is busy, not stalled — that is what comparing
/// the completion counts is for.
#[test]
fn a_draining_queue_is_not_a_stall() {
let report = Diagnostics {
transfer_queued: 12,
transfer_completed: 40,
..Default::default()
};
assert!(!report_stalls(&report, (0, 30)));
assert!(report_stalls(&report, (0, 40)));
}
}
+10 -2
View File
@@ -67,10 +67,16 @@ impl Core {
/// Sweep for stale conflict copies forever, on its own thread. See the module
/// docs for the identical/divergent policy.
pub(crate) fn run_conflict_sweep_loop(&self) {
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
// Both waits are interruptible, so teardown does not have to sit out a
// five-minute interval to join this thread (bugs.md B44).
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
return;
}
loop {
self.sweep_conflicts_once();
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
return;
}
}
}
@@ -362,6 +368,8 @@ mod tests {
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
+585 -77
View File
@@ -54,7 +54,7 @@ use engine::{classify, settle_with};
#[cfg(test)]
use local_scan::is_write_mode;
use local_scan::open_for_write_set;
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
use model::{LocalItem, LocalSig, Outcome, PassAbort, Pending, RemoteItem};
pub(crate) use planner::base_name;
#[cfg(test)]
use planner::conflict_path;
@@ -525,6 +525,9 @@ impl Core {
continue;
}
trashed.push(rel.clone());
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
self.invalidate_children_of(&parent);
}
let _ = self.db.sync_entry_remove(folder_id, rel);
self.log_activity(
ActivityKind::Trash,
@@ -687,6 +690,7 @@ impl Core {
rel_path: rel.clone(),
remote_uid: Some(remote.uid.to_string()),
local_mtime: local.mtime,
local_mtime_ns: local.mtime_ns,
local_size: local.size,
remote_rev: Some(remote.mtime.to_string()),
remote_hash: Some(remote.size.to_string()),
@@ -717,6 +721,9 @@ impl Core {
uid: remote.uid.clone(),
mtime: remote.mtime,
size: remote.size,
// What the walk saw here, so the apply step can tell a
// stale plan from a live one before it overwrites.
local: l.map(LocalSig::from),
});
}
FilePlan::Conflict => {
@@ -739,7 +746,9 @@ impl Core {
}
},
FilePlan::DeleteLocal => {
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
if let Err(e) =
removed_locally(std::fs::remove_file(local_root.join(rel_to_path(rel))))
{
warn!(rel, error = %e, "sync: remove local file failed");
self.log_activity(
ActivityKind::Trash,
@@ -771,6 +780,9 @@ impl Core {
outcome.errors += 1;
continue;
}
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
self.invalidate_children_of(&parent);
}
let _ = self.db.sync_entry_remove(folder_id, rel);
self.log_activity(
ActivityKind::Trash,
@@ -797,7 +809,9 @@ impl Core {
// Deferred folder deletions, deepest first.
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
for rel in delete_local_dirs {
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
if let Err(e) =
removed_locally(std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))))
{
warn!(rel, error = %e, "sync: remove local folder failed");
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
outcome.errors += 1;
@@ -824,6 +838,9 @@ impl Core {
outcome.errors += 1;
continue;
}
if let Some(parent) = parent_uid_of(&rel, &remote_dirs, &baseline) {
self.invalidate_children_of(&parent);
}
let _ = self.db.sync_entry_remove(folder_id, &rel);
self.log_activity(
ActivityKind::Trash,
@@ -849,68 +866,9 @@ impl Core {
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| {
format!(
"local path {} escaped sync root {}: {e}",
path.display(),
root.display()
)
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
})?;
// Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") {
continue;
}
// Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or
// counted against scan progress.
if rules.is_ignored(rel, meta.is_dir()) {
continue;
}
if meta.is_dir() {
out.insert(
rel.to_string(),
LocalItem {
is_dir: true,
mtime: 0,
size: 0,
open_for_write: false,
},
);
self.progress_scanned(folder_id, base_name(rel));
self.walk_local(folder_id, root, &path, rules, writing, out)?;
} else if meta.is_file() {
// Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve
// to canonical targets.
let ofw = std::fs::canonicalize(&path)
.map(|canon| writing.contains(&canon))
.unwrap_or(false);
out.insert(
rel.to_string(),
LocalItem {
is_dir: false,
mtime: system_mtime(&meta),
size: meta.len() as i64,
open_for_write: ofw,
},
);
self.progress_scanned(folder_id, base_name(rel));
}
}
Ok(())
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
self.progress_scanned(folder_id, base_name(rel))
})
}
/// Map every remote folder under `folder` to its uid by rel path, ignoring
@@ -1069,6 +1027,7 @@ impl Core {
rel_path: rel.to_string(),
remote_uid: Some(uid.to_string()),
local_mtime: 0,
local_mtime_ns: None,
local_size: 0,
remote_rev: None,
remote_hash: None,
@@ -1175,6 +1134,9 @@ impl Core {
.create_folder(parent, base_name(rel), Some(now_secs()))
.await
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
// The parent has gained a child that no cached listing of it
// knows about (B86).
self.invalidate_children_of(parent);
self.baseline_dir(folder_id, rel, &uid)?;
Ok(Applied::Dir(rel.clone(), uid))
}
@@ -1192,8 +1154,9 @@ impl Core {
uid,
mtime,
size,
local,
} => {
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, *local)
.await?;
Ok(Applied::Downloaded)
}
@@ -1211,7 +1174,11 @@ impl Core {
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
info!(rel, "sync: kept local changes as a conflict copy");
}
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
// The local copy has just been moved aside, so the state this
// download expects to find at the path is "nothing" — anything
// that appears while it runs is a *new* racing write, and gets
// its own conflict copy rather than being overwritten (B39).
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, None)
.await?;
Ok(Applied::Conflict)
}
@@ -1250,6 +1217,9 @@ impl Core {
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta);
// Taken from the descriptor being streamed, so it names the content that
// actually goes up rather than whatever is at the path when we finish.
let streamed = LocalSig::from(&meta);
// Count the bytes as they are read, so this upload shows up in
// `GetQueueStatus` next to a manual one. The uid isn't known until the
// draft is sealed, so the transfer registers without one.
@@ -1276,7 +1246,13 @@ impl Core {
)
.await
.map_err(|e| format!("upload {rel}: {e}"))?;
self.record_file_baseline(folder_id, rel, &path, &uid).await
// The parent has gained a child that no cached listing of it knows
// about. Without this, a folder later switched to on-demand serves a
// snapshot that predates this upload (B86).
self.invalidate_children_of(parent);
warn_if_torn(rel, &path, streamed);
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
.await
}
/// Upload a changed local file as a new revision of an existing remote node.
@@ -1291,6 +1267,7 @@ impl Core {
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
let mtime = system_mtime(&meta);
let streamed = LocalSig::from(&meta);
let reader = OwnedCountingReader::new(
file,
self.transfers.begin(
@@ -1304,11 +1281,20 @@ impl Core {
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
.await
.map_err(|e| format!("upload revision {rel}: {e}"))?;
self.record_file_baseline(folder_id, rel, &path, uid).await
warn_if_torn(rel, &path, streamed);
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
.await
}
/// Download a remote file to its local path (atomically via a temp file),
/// stamp the local mtime to match the remote, then record baseline.
///
/// `expected_local` is the identity planning saw at the destination. The
/// rename below is the destructive step, and the download in between can run
/// for minutes, so the destination is re-examined right before it: a local
/// file that no longer matches the plan is set aside as a conflict copy
/// instead of being replaced (bugs.md B39).
#[allow(clippy::too_many_arguments)]
async fn download_file(
&self,
folder_id: i64,
@@ -1317,6 +1303,7 @@ impl Core {
uid: &NodeUid,
mtime: i64,
size: i64,
expected_local: Option<LocalSig>,
) -> Result<(), String> {
let path = local_root.join(rel_to_path(rel));
if let Some(parent) = path.parent() {
@@ -1351,6 +1338,13 @@ impl Core {
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
}
// The last moment at which the destination can still be saved. Anything
// other than the state planning classified means a writer got there
// first, and its bytes are the ones nothing else has a copy of.
if let Err(e) = keep_racing_local_edit(&path, expected_local) {
let _ = std::fs::remove_file(&tmp);
return Err(format!("set aside racing local edit for {rel}: {e}"));
}
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
// Match local mtime to the remote's so neither side looks "changed" next pass.
let f = std::fs::File::options()
@@ -1366,23 +1360,36 @@ impl Core {
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
}
self.record_file_baseline(folder_id, rel, &path, uid).await
// Here the file on disk *is* the transferred content: it was published
// by a rename and stamped just above, so its stat is the identity.
self.record_file_baseline(folder_id, rel, local_sig(&path), uid)
.await
}
/// Refresh a file's baseline from ground truth: the local stat and the
/// remote node's reported signature. Called after every upload/download so
/// the very next reconcile sees the path as unchanged.
/// Refresh a file's baseline from ground truth: the local identity these
/// bytes came from and the remote node's reported signature. Called after
/// every upload/download so the very next reconcile sees the path as
/// unchanged.
///
/// `local` is the identity the *transferred content* corresponds to, which
/// is not always the file's current stat. An upload streams the live file,
/// so it passes the identity it read the first byte at: if a writer changed
/// the file mid-stream, the remote now holds a torn revision, and recording
/// the pre-upload identity is what makes the next pass see the path as
/// locally changed and upload a whole, clean revision over it. Recording the
/// post-upload stat instead declared the torn revision synchronized and left
/// it as the file's content on Drive (bugs.md B40).
async fn record_file_baseline(
&self,
folder_id: i64,
rel: &str,
local_path: &Path,
local: Option<LocalSig>,
uid: &NodeUid,
) -> Result<(), String> {
let (lmtime, lsize) = match std::fs::metadata(local_path) {
Ok(m) => (system_mtime(&m), m.len() as i64),
Err(e) => return Err(format!("stat {rel}: {e}")),
let Some(local) = local else {
return Err(format!("stat {rel}: no local identity to baseline"));
};
let (lmtime, lsize) = (local.mtime, local.size);
// Re-fetch the node so the stored remote signature is exactly what a walk
// will report next time.
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
@@ -1402,6 +1409,7 @@ impl Core {
rel_path: rel.to_string(),
remote_uid: Some(uid.to_string()),
local_mtime: lmtime,
local_mtime_ns: local.mtime_ns,
local_size: lsize,
remote_rev: Some(rmtime.to_string()),
remote_hash: Some(rsize.to_string()),
@@ -1411,6 +1419,177 @@ impl Core {
}
}
/// Say so when a file changed under an upload that was streaming it.
///
/// Purely diagnostic — the correction is already in place, because the baseline
/// records the identity the upload *read* rather than the one on disk now, so
/// the next pass classifies the path as locally changed and replaces the torn
/// revision. Without the log there is nothing in the journal to explain the
/// extra revision (bugs.md B40).
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
let now = local_sig(path);
if !now.is_some_and(|now| now.same_content(&streamed)) {
warn!(
rel,
?streamed,
?now,
"sync: file changed while uploading; the remote revision may be torn \
and will be replaced on the next pass"
);
}
}
/// The identity a reconcile pass uses to recognise a local file it has already
/// looked at: `None` when nothing is at the path.
fn local_sig(path: &Path) -> Option<LocalSig> {
std::fs::metadata(path).ok().map(|m| LocalSig {
mtime: system_mtime(&m),
mtime_ns: system_mtime_ns(&m),
size: m.len() as i64,
})
}
/// Guard the destination of a download: if what is at `path` is no longer what
/// planning classified, move it aside as a conflict copy before the caller
/// renames over it.
///
/// A reconcile pass decides what to do from a walk that finished before the
/// transfer started, and a download of a large file can run for minutes. Without
/// this, a local edit completed inside that window was replaced by the remote
/// version with no copy of it kept anywhere — the one shape of loss a sync
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
/// new file on the next pass, which is the same resolution a both-sides-changed
/// conflict already gets.
///
/// A path that is empty when it was expected to be is not a conflict: the
/// download is about to recreate it, and there is nothing to preserve.
fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::Result<()> {
let actual = local_sig(path);
let unchanged = match (actual, expected) {
(Some(a), Some(e)) => a.same_content(&e),
// Nothing there: the download is about to create it, and there is
// nothing to preserve either way.
(None, _) => true,
(Some(_), None) => false,
};
if unchanged {
return Ok(());
}
warn!(
path = %path.display(),
?expected,
?actual,
"sync: local file changed while downloading; keeping it as a conflict copy"
);
preserve_conflict_copy(path, now_secs()).map(|_| ())
}
/// Read a local removal as the sync engine has to read it: the *state* it was
/// asking for is "this path is gone", so a path that was already gone is a
/// success, not a failure.
///
/// It matters because the baseline is only dropped on success. Treating
/// `NotFound` as an error left the baseline in place for a file that no longer
/// exists, so the same removal was re-attempted and re-reported as an error on
/// every pass, forever. Every other error is still an error, and keeping the
/// baseline is exactly right there: the surviving file must not be mistaken for
/// a new local creation on the next pass and uploaded back (bugs.md B42).
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
///
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
/// on disk. Reconciliation reads a path present in the baseline and absent from
/// the scan as "the user deleted this", and propagates that to Drive. So an
/// unreadable subdirectory would trash its remote counterpart, and the deeper
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
/// whole pass keeps the previous baseline and leaves the remote alone; the next
/// pass retries once the transient condition clears.
///
/// Split out from the method so exactly that can be tested against a real tree
/// with a real unreadable directory in it, which needs no `Core` at all.
fn walk_local_tree(
root: &Path,
dir: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
scanned: &mut dyn FnMut(&str),
) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| {
format!(
"local path {} escaped sync root {}: {e}",
path.display(),
root.display()
)
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
})?;
// Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") {
continue;
}
// Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or
// counted against scan progress.
if rules.is_ignored(rel, meta.is_dir()) {
continue;
}
if meta.is_dir() {
out.insert(
rel.to_string(),
LocalItem {
is_dir: true,
mtime: 0,
mtime_ns: None,
size: 0,
open_for_write: false,
},
);
scanned(rel);
walk_local_tree(root, &path, rules, writing, out, scanned)?;
} else if meta.is_file() {
// Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve
// to canonical targets.
let ofw = std::fs::canonicalize(&path)
.map(|canon| writing.contains(&canon))
.unwrap_or(false);
out.insert(
rel.to_string(),
LocalItem {
is_dir: false,
mtime: system_mtime(&meta),
mtime_ns: system_mtime_ns(&meta),
size: meta.len() as i64,
open_for_write: ofw,
},
);
scanned(rel);
}
}
Ok(())
}
/// Publish a conflict copy without overwriting an earlier conflict bearing the
/// same timestamp. The original is removed only after the new copy is durable.
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
@@ -1444,6 +1623,26 @@ fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
unreachable!("u32 conflict suffix space exhausted")
}
/// The remote uid of `rel`'s parent folder: from the pass's folder-uid map
/// first, and from the baseline second for a parent the map never covered.
///
/// Used to name the folder whose cached listing a remote mutation has just
/// invalidated (B86). `None` is not an error — it only means this pass cannot
/// say which folder to invalidate, and the caller skips it.
fn parent_uid_of(
rel: &str,
remote_dirs: &HashMap<String, NodeUid>,
baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<NodeUid> {
let parent = parent_rel(rel);
remote_dirs.get(parent).cloned().or_else(|| {
baseline
.get(parent)
.and_then(|e| e.remote_uid.as_deref())
.and_then(parse_uid)
})
}
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
match path.parent() {
Some(parent) => std::fs::File::open(parent)?.sync_all(),
@@ -1476,10 +1675,77 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// The same modification time in nanoseconds, when the filesystem reports one.
///
/// Whole seconds cannot distinguish an edit made in the same second as the last
/// sync from no edit at all, and if the size did not change either, that file
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
/// cannot answer, which falls the comparison back to seconds rather than
/// asserting an mtime of zero.
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
meta.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.and_then(|d| i64::try_from(d.as_nanos()).ok())
}
#[cfg(test)]
mod tests {
use super::*;
/// A removal of something already gone has produced the state the sync
/// engine wanted, so its baseline is dropped rather than retried forever.
/// Anything else stays an error and keeps the baseline (bugs.md B42).
#[test]
fn an_already_absent_path_counts_as_removed() {
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
let missing = root.join("never-existed");
assert!(removed_locally(std::fs::remove_file(&missing)).is_ok());
assert!(removed_locally(std::fs::remove_dir_all(&missing)).is_ok());
// A directory in the way of `remove_file` is a real failure, and must
// stay one: the file survives, and its baseline has to survive with it.
let occupied = root.join("a-directory");
std::fs::create_dir_all(&occupied).unwrap();
assert!(removed_locally(std::fs::remove_file(&occupied)).is_err());
let _ = std::fs::remove_dir_all(&root);
}
/// The download guard: a destination that still matches the plan is
/// replaced silently, one that has moved on since is kept (bugs.md B39).
#[test]
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let path = root.join("report.txt");
// Nothing there, nothing expected: the download simply creates it.
assert!(keep_racing_local_edit(&path, None).is_ok());
assert!(!path.exists());
// Unchanged since planning: no copy, the caller overwrites it.
std::fs::write(&path, b"planned").unwrap();
let planned = local_sig(&path).unwrap();
keep_racing_local_edit(&path, Some(planned)).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"planned");
// Changed since planning: the racing bytes are the only copy of
// themselves, so they must survive the overwrite as a conflict copy.
std::fs::write(&path, b"a much longer local edit").unwrap();
keep_racing_local_edit(&path, Some(planned)).unwrap();
let kept: Vec<_> = std::fs::read_dir(&root)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| std::fs::read(e.path()).unwrap_or_default())
.collect();
assert!(
kept.iter()
.any(|bytes| bytes == b"a much longer local edit"),
"the racing edit must still exist somewhere: {kept:?}"
);
let _ = std::fs::remove_dir_all(&root);
}
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
/// immediately. Returns the sender's join handle so the test can keep it
/// alive (dropping the sender early would look like a disconnect).
@@ -1565,6 +1831,7 @@ mod tests {
fn local_item(is_dir: bool) -> LocalItem {
LocalItem {
mtime_ns: None,
is_dir,
mtime: 1,
size: 1,
@@ -1722,6 +1989,44 @@ mod tests {
assert!(guard_local_wipe(&base, &fresh).is_err());
}
/// B54's remaining case, end to end: a folder whose local root is still
/// *readable* but is no longer the tree it was — an empty directory left
/// where a mount used to be, or a different device mounted over it.
///
/// Nothing fails here. The scan succeeds and honestly reports an empty
/// directory, so the only thing standing between the user and a trashed
/// remote folder is the guard, and it has to fire for a one-file baseline
/// exactly as it does for a large one.
#[test]
fn a_replaced_mountpoint_trips_the_guard_at_every_baseline_size() {
let root = sync_test_dir("wipe-replaced-mount");
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut progress = no_progress();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
assert!(local.is_empty(), "the replacement root is genuinely empty");
for size in 1..=3 {
let baseline: HashMap<String, ()> =
(0..size).map(|i| (format!("file{i}.txt"), ())).collect();
assert!(
guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_err(),
"a {size}-path baseline against an empty root is not a deletion"
);
}
// And the guard steps aside the moment the real tree is back, so a
// remounted folder resumes rather than staying wedged.
std::fs::write(root.join("file0.txt"), b"back").unwrap();
let mut local: HashMap<String, LocalItem> = HashMap::new();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
std::fs::remove_dir_all(&root).unwrap();
}
/// A restored folder (features.md 5.2) starts with an empty baseline against
/// an empty local directory and a full remote. That must reconcile as
/// "download everything" rather than tripping the wipe guard — otherwise
@@ -1764,6 +2069,7 @@ mod tests {
remote_uid: Some("v~l".into()),
local_mtime: 0,
local_size: 0,
local_mtime_ns: None,
remote_rev: Some(mtime.to_string()),
remote_hash: Some(size.to_string()),
},
@@ -1801,6 +2107,7 @@ mod tests {
remote_uid: Some("v~l".into()),
local_mtime: 0,
local_size: 0,
local_mtime_ns: None,
remote_rev: None,
remote_hash: None,
},
@@ -1815,6 +2122,7 @@ mod tests {
remote_uid: Some("v~l".into()),
local_mtime: 10,
local_size: 20,
local_mtime_ns: None,
remote_rev: Some("1700".into()),
remote_hash: Some("4096".into()),
};
@@ -1828,6 +2136,7 @@ mod tests {
remote_uid: Some("v~l".into()),
local_mtime: 0,
local_size: 0,
local_mtime_ns: None,
remote_rev: None,
remote_hash: None,
};
@@ -1863,6 +2172,7 @@ mod tests {
uid: uid(),
mtime: 0,
size: 10,
local: None,
};
assert_eq!(op.kind(), ActivityKind::Download);
}
@@ -1918,6 +2228,204 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// B49's remaining cases. Preservation is the step that makes a download
/// safe to publish, so every way it can fail has to leave the local file
/// where it was — the download is aborted, not the file replaced.
#[test]
fn conflict_preservation_failures_all_keep_the_local_file() {
use std::os::unix::fs::PermissionsExt as _;
// An unwritable directory: the conflict copy cannot be created at all.
let dir = sync_test_dir("conflict-eacces");
let source = dir.join("notes.txt");
std::fs::write(&source, b"the only copy").unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let denied = preserve_conflict_copy(&source, 1700);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(denied.is_err(), "a read-only directory must fail the copy");
assert_eq!(
std::fs::read(&source).unwrap(),
b"the only copy",
"the local file survives byte-identical"
);
std::fs::remove_dir_all(&dir).unwrap();
// A source that vanished between planning and preservation. The pass
// must report it rather than publish the download over the gap.
let dir = sync_test_dir("conflict-enoent");
assert!(preserve_conflict_copy(&dir.join("gone.txt"), 1700).is_err());
std::fs::remove_dir_all(&dir).unwrap();
// A write error partway through the copy — induced by making the
// destination name a directory, so `create_new` fails as something
// other than `AlreadyExists`.
let dir = sync_test_dir("conflict-eisdir");
let source = dir.join("notes.txt");
std::fs::write(&source, b"still the only copy").unwrap();
std::fs::create_dir(conflict_path(&source, 1700)).unwrap();
// Suffix 0 is taken by a directory; `create_new` on it reports
// `AlreadyExists`, so preservation moves on to suffix 1 and succeeds.
// What must not happen is the source being removed without a copy.
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
assert!(preserved.is_file());
assert_eq!(std::fs::read(&preserved).unwrap(), b"still the only copy");
assert!(!source.exists());
std::fs::remove_dir_all(&dir).unwrap();
}
/// A conflict copy keeps the bytes *and* the metadata that identifies them,
/// so the next pass uploads the user's version rather than a file that looks
/// freshly written.
#[test]
fn a_conflict_copy_is_byte_and_mtime_identical() {
use std::os::unix::fs::PermissionsExt as _;
let dir = sync_test_dir("conflict-identity");
let source = dir.join("notes.txt");
std::fs::write(&source, b"local edit that must survive").unwrap();
let before = std::fs::metadata(&source).unwrap();
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
let after = std::fs::metadata(&preserved).unwrap();
assert_eq!(
std::fs::read(&preserved).unwrap(),
b"local edit that must survive"
);
assert_eq!(after.len(), before.len());
assert_eq!(after.modified().unwrap(), before.modified().unwrap());
assert_eq!(after.permissions().mode(), before.permissions().mode());
std::fs::remove_dir_all(&dir).unwrap();
}
// ---- B55: an incomplete local scan is never a set of deletions ---------
fn no_progress() -> impl FnMut(&str) {
|_: &str| {}
}
/// A directory the pass cannot read must fail the pass. Omitting it would
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
/// having deleted that subtree and propagates to Drive (B55).
#[test]
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
let locked = root.join("locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("an unreadable directory must abort the pass");
assert!(
error.contains("locked"),
"the error should name the subtree it could not read: {error}"
);
}
/// The same rule one level up: if the sync root itself cannot be read, the
/// pass sees nothing, and "nothing" must never mean "delete everything".
#[test]
fn an_unreadable_root_fails_the_scan() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-root-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_err());
assert!(out.is_empty());
}
/// A root that is gone entirely — an unmounted device folder, a removed
/// directory — is the sharpest version of the same case: every baseline path
/// is "missing", and the scan must refuse rather than report an empty tree.
#[test]
fn an_absent_root_fails_the_scan() {
let root = sync_test_dir("scan-absent");
std::fs::remove_dir_all(&root).unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
assert!(out.is_empty());
}
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
/// cannot be compared against the baseline either. Refusing the pass is the
/// only safe reading: skipping it would classify the file as deleted.
#[test]
fn a_non_utf8_name_fails_the_scan() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let root = sync_test_dir("scan-non-utf8");
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("a name the scan cannot key must abort the pass");
assert!(error.contains("UTF-8"), "{error}");
}
/// The complement: a scan that *can* read everything reports everything, so
/// the strictness above does not come at the cost of a working pass. A
/// symlink is skipped by design rather than by failure, and a dangling one
/// stats fine through `symlink_metadata`.
#[test]
fn a_readable_tree_scans_completely() {
let root = sync_test_dir("scan-complete");
std::fs::write(root.join("a.txt"), b"aa").unwrap();
std::fs::create_dir(root.join("sub")).unwrap();
std::fs::write(root.join("sub/b.txt"), b"bbb").unwrap();
std::os::unix::fs::symlink("nowhere", root.join("dangling")).unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut seen: Vec<String> = Vec::new();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
seen.push(rel.to_string())
});
std::fs::remove_dir_all(&root).unwrap();
result.unwrap();
let mut keys: Vec<&str> = out.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, vec!["a.txt", "sub", "sub/b.txt"]);
assert_eq!(out["a.txt"].size, 2);
assert_eq!(out["sub/b.txt"].size, 3);
assert!(out["sub"].is_dir);
assert_eq!(seen.len(), 3, "every reported entry ticks scan progress");
}
fn sync_test_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
+52 -16
View File
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
ReconcileAll,
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
Rewatch,
/// The daemon is tearing down: finish the current pass and end the engine
/// thread, so the mount can join it (bugs.md B44).
Stop,
}
/// Start the sync engine: a control thread that owns the filesystem watcher and
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
/// on `rx`; senders live in [`Core::sync_tx`].
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
if let Err(e) = std::thread::Builder::new()
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
match std::thread::Builder::new()
.name("pdfs-sync".into())
.spawn(move || engine_loop(core, rx))
{
warn!(error = %e, "failed to start sync engine");
Ok(handle) => Some(handle),
Err(e) => {
warn!(error = %e, "failed to start sync engine");
None
}
}
}
@@ -74,11 +81,11 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Periodic remote poll.
{
let tx = core.sync_tx.clone();
let shutdown = core.shutdown.clone();
std::thread::Builder::new()
.name("pdfs-sync-poll".into())
.spawn(move || {
loop {
std::thread::sleep(POLL_INTERVAL);
while shutdown.sleep(POLL_INTERVAL) {
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
@@ -94,8 +101,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
classify(msg, &mut ids, &mut all, &mut do_rewatch);
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
{
info!("sync engine stopping");
return;
}
if do_rewatch {
rewatch(&core, &mut watcher, &watched);
@@ -116,9 +127,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
{
let tx = core.sync_tx.clone();
let shutdown = core.shutdown.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(POLL_INTERVAL);
while shutdown.sleep(POLL_INTERVAL) {
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
@@ -130,8 +141,12 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
classify(msg, &mut ids, &mut all, &mut do_rewatch);
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
{
info!("sync engine stopping");
return;
}
if all {
reconcile_all(core);
} else {
@@ -158,7 +173,7 @@ pub(super) fn settle_with(
do_rewatch: &mut bool,
quiet: Duration,
cap: Duration,
) {
) -> bool {
let deadline = Instant::now() + cap;
loop {
let now = Instant::now();
@@ -170,28 +185,49 @@ pub(super) fn settle_with(
match rx.recv_timeout(wait) {
// Another event: the burst is still going, so the quiet window
// starts again from here.
Ok(m) => classify(m, ids, all, do_rewatch),
// A stop lands mid-burst: give up the rest of the burst rather
// than settling for it, and let the caller end the loop.
Ok(m) => {
if !classify(m, ids, all, do_rewatch) {
return false;
}
}
// Quiet for a full window: whatever was being written has stopped.
Err(RecvTimeoutError::Timeout) => break,
// Every sender is gone; the caller's loop is about to end anyway.
Err(RecvTimeoutError::Disconnected) => break,
}
}
true
}
/// [`settle_with`] at the real timings.
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
fn settle(
rx: &Receiver<SyncMsg>,
ids: &mut HashSet<i64>,
all: &mut bool,
do_rewatch: &mut bool,
) -> bool {
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
}
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
/// Fold one message into the pending work of the current burst. Returns `false`
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
pub(super) fn classify(
msg: SyncMsg,
ids: &mut HashSet<i64>,
all: &mut bool,
rewatch: &mut bool,
) -> bool {
match msg {
SyncMsg::Reconcile(id) => {
ids.insert(id);
}
SyncMsg::ReconcileAll => *all = true,
SyncMsg::Rewatch => *rewatch = true,
SyncMsg::Stop => return false,
}
true
}
/// Reconcile every mirror folder in turn.
+71
View File
@@ -2,9 +2,73 @@
use super::*;
/// What a reconcile pass remembers about the local side of one path: enough to
/// recognise the same content again, and no more.
///
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct LocalSig {
pub(super) mtime: i64,
pub(super) mtime_ns: Option<i64>,
pub(super) size: i64,
}
impl LocalSig {
/// Whether these two observations describe the same local content.
///
/// Nanoseconds decide it when both sides have them; otherwise the comparison
/// falls back to whole seconds, which is what a baseline row written before
/// schema 28 — or a filesystem that reports no sub-second time — can support
/// (bugs.md B25).
pub(super) fn same_content(&self, other: &Self) -> bool {
if self.size != other.size {
return false;
}
match (self.mtime_ns, other.mtime_ns) {
(Some(a), Some(b)) => a == b,
_ => self.mtime == other.mtime,
}
}
}
impl From<&LocalItem> for LocalSig {
fn from(item: &LocalItem) -> Self {
Self {
mtime: item.mtime,
mtime_ns: item.mtime_ns,
size: item.size,
}
}
}
impl From<&std::fs::Metadata> for LocalSig {
fn from(meta: &std::fs::Metadata) -> Self {
Self {
mtime: system_mtime(meta),
mtime_ns: system_mtime_ns(meta),
size: meta.len() as i64,
}
}
}
/// The baseline's record of the local side, as [`LocalSig`] compares it.
impl From<&StoredSyncEntry> for LocalSig {
fn from(entry: &StoredSyncEntry) -> Self {
Self {
mtime: entry.local_mtime,
mtime_ns: entry.local_mtime_ns,
size: entry.local_size,
}
}
}
pub(super) struct LocalItem {
pub(super) is_dir: bool,
pub(super) mtime: i64,
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
/// reports one.
pub(super) mtime_ns: Option<i64>,
pub(super) size: i64,
/// True when another process holds this file open for writing (detected via
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
@@ -91,11 +155,18 @@ pub(super) enum Pending {
UploadRevision { rel: String, uid: NodeUid },
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
/// remote's reported size, used as the transfer's expected total.
///
/// `local` is what the planning walk saw at this path, or `None` if nothing
/// was there. A download can run for minutes, so the apply step revalidates
/// against it immediately before it renames over the destination: a writer
/// that finished an edit in the meantime must not be overwritten without a
/// conflict copy (bugs.md B39).
Download {
rel: String,
uid: NodeUid,
mtime: i64,
size: i64,
local: Option<LocalSig>,
},
/// Both sides changed: set the local copy aside, then download remote `uid`.
Conflict {
+42 -1
View File
@@ -218,7 +218,10 @@ pub(super) fn plan_file(
}
let local_changed = local.is_some_and(|item| {
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
// could not see an edit made in the same second as the last sync that
// left the file the same length, so it was never uploaded.
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
});
let remote_changed = remote.is_some_and(|item| {
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
@@ -252,6 +255,7 @@ mod file_plan_tests {
fn local(mtime: i64, size: i64) -> LocalItem {
LocalItem {
mtime_ns: None,
is_dir: false,
mtime,
size,
@@ -278,12 +282,49 @@ mod file_plan_tests {
rel_path: "file".into(),
remote_uid: Some("v~l".into()),
local_mtime,
local_mtime_ns: None,
local_size,
remote_rev: Some(remote_mtime.to_string()),
remote_hash: Some(remote_size.to_string()),
}
}
/// Whole seconds cannot see an edit made inside the same second that leaves the
/// file the same length, so a mirror silently stopped uploading it. With a
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
#[test]
fn a_same_second_same_size_edit_is_still_a_local_change() {
let mut base = baseline(10, 20, 30, 40);
base.local_mtime_ns = Some(10_000_000_000);
let edited = LocalItem {
mtime_ns: Some(10_400_000_000),
..local(10, 20)
};
let remote = remote(30, 40);
assert_eq!(
plan_file(Some(&edited), Some(&remote), Some(&base)),
FilePlan::UploadRevision
);
// The same file untouched still reads as unchanged.
let same = LocalItem {
mtime_ns: Some(10_000_000_000),
..local(10, 20)
};
assert_eq!(
plan_file(Some(&same), Some(&remote), Some(&base)),
FilePlan::Unchanged
);
// And a row from before the column exists compares on seconds, exactly
// as it did when it was written — no mass re-upload on upgrade.
let old = baseline(10, 20, 30, 40);
assert_eq!(
plan_file(Some(&edited), Some(&remote), Some(&old)),
FilePlan::Unchanged
);
}
#[test]
fn file_plan_covers_every_presence_and_change_shape() {
let same_local = local(10, 20);
+99
View File
@@ -0,0 +1,99 @@
//! The `sd_notify` half of the service contract, without linking libsystemd.
//!
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
//! directly keeps the daemon buildable on a machine without systemd headers —
//! everything here degrades to "no socket, do nothing" off systemd.
use std::os::unix::net::UnixDatagram;
use std::time::Duration;
use tracing::debug;
/// Send one notification. `false` when there is no manager to send it to, or the
/// send failed — a caller has nothing useful to do about either.
pub(crate) fn notify(message: &str) -> bool {
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
return false;
};
if socket_path.is_empty() {
return false;
}
let Ok(socket) = UnixDatagram::unbound() else {
return false;
};
if let Some(name) = socket_path.strip_prefix('@') {
// Abstract namespace: the address is a NUL byte followed by the name,
// which `std`'s path-based API cannot express.
send_abstract(&socket, name, message)
} else {
socket.send_to(message.as_bytes(), &socket_path).is_ok()
}
}
/// `sendto` on an abstract-namespace address, which needs a hand-built
/// `sockaddr_un` because the first byte of the name is NUL.
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
use std::os::fd::AsRawFd;
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
let bytes = name.as_bytes();
// One byte for the leading NUL, and the path must still be addressable.
if bytes.len() + 1 > addr.sun_path.len() {
return false;
}
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
*slot = *byte as libc::c_char;
}
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
let sent = unsafe {
libc::sendto(
socket.as_raw_fd(),
message.as_ptr().cast(),
message.len(),
0,
std::ptr::from_ref(&addr).cast(),
len as libc::socklen_t,
)
};
sent >= 0
}
/// Tell the manager the daemon is up and serving. Called once the mount is live,
/// so `systemctl --user start` returns when the mount actually works rather than
/// when the process exists.
pub(crate) fn ready() {
if notify("READY=1\n") {
debug!("notified systemd: ready");
}
}
/// Tell the manager the daemon is still healthy.
pub(crate) fn watchdog_ping() {
let _ = notify("WATCHDOG=1\n");
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
let _ = notify("STOPPING=1\n");
}
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
/// the watchdog is off or belongs to another process.
///
/// Reports *half* the configured interval, the interval systemd documents for
/// pinging: a ping period equal to the deadline would restart the daemon on the
/// first late scheduling.
pub(crate) fn watchdog_period() -> Option<Duration> {
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
// in that case a ping from anyone else is not the one it is waiting for.
if let Ok(pid) = std::env::var("WATCHDOG_PID")
&& pid.parse::<u32>().ok() != Some(std::process::id())
{
return None;
}
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
(usec > 0).then(|| Duration::from_micros(usec / 2))
}
+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"
);
}
}
+33 -3
View File
@@ -19,7 +19,7 @@ use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Instant;
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
@@ -272,20 +272,50 @@ impl<R: Read> Read for OwnedCountingReader<R> {
}
}
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path),
/// and can be told to stop.
pub struct CountingReader<'a, R> {
inner: R,
guard: &'a TransferGuard,
cancel: Option<Arc<AtomicBool>>,
}
impl<'a, R: Read> CountingReader<'a, R> {
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
Self { inner, guard }
Self {
inner,
guard,
cancel: None,
}
}
/// Abandon the upload as soon as the SDK asks for its next bytes when
/// `cancel` is set.
///
/// This is the only cancellation point an upload has: the SDK takes a plain
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
/// hand over the next block is what stops it. Used when a newer revision of
/// the same file is queued — the bytes on the wire have already been
/// superseded, and finishing them only spends uplink on a revision the next
/// op replaces.
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
self.cancel = Some(cancel);
self
}
}
impl<R: Read> Read for CountingReader<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if let Some(cancel) = &self.cancel
&& cancel.load(Ordering::Relaxed)
{
// Not `Ok(0)`: a short read is how this reader says "end of file",
// and the SDK would seal a truncated revision from it.
return Err(std::io::Error::new(
std::io::ErrorKind::Interrupted,
"upload superseded by a newer write",
));
}
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
+2
View File
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
+333 -33
View File
@@ -12,6 +12,8 @@
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::Instant;
use parking_lot::{Condvar, Mutex};
use tracing::warn;
@@ -51,8 +53,25 @@ pub(crate) const FUSE_WORKERS: usize = 11;
/// exactly the blocking this split exists to prevent.
const META_WORKERS: usize = 3;
/// Queue depth that gets a line in the log, and the step at which it repeats.
/// Every job holds a `Reply` and a few fields, so depth is cheap — but a lane
/// this far behind is worth knowing about before the user reports a slow mount.
const QUEUE_WARN_STEP: usize = 256;
/// Whether a queue reaching `depth` is one of the depths worth logging.
fn crosses_warn_step(depth: usize) -> bool {
depth >= QUEUE_WARN_STEP && depth.is_multiple_of(QUEUE_WARN_STEP)
}
type Job = Box<dyn FnOnce() + Send + 'static>;
/// A queued job and the name its caller gave it, so a job that never finishes
/// can be named in the diagnostics instead of showing up as "a worker is busy".
struct Queued {
label: &'static str,
job: Job,
}
/// Which lane a job belongs in.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Lane {
@@ -65,12 +84,63 @@ pub(crate) enum Lane {
#[derive(Default)]
struct Queues {
meta: VecDeque<Job>,
transfer: VecDeque<Job>,
meta: VecDeque<Queued>,
transfer: VecDeque<Queued>,
/// Set when the pool is dropped, to wake and retire every worker.
closed: bool,
}
/// What one worker thread is doing, published for [`Workers::snapshot`].
///
/// Every field is an atomic (or a lock the worker only ever holds for the length
/// of a pointer write) because the whole point of the snapshot is to answer
/// *while the daemon is wedged*: a diagnostic that waits on the same lock the
/// stuck job holds reports nothing at exactly the moment it is needed.
struct Slot {
name: String,
/// True between picking a job up and finishing it.
busy: AtomicBool,
/// When the current state (busy or idle) began, as milliseconds since the
/// pool started. Monotonic, so an age is always a real duration.
since_ms: AtomicU64,
/// The label of the job in hand. Behind a mutex because a `&'static str` is
/// two words and there is no atomic for that; the worker holds it only to
/// swap the pointer, and the snapshot never blocks on it (see
/// [`Workers::snapshot`]).
label: Mutex<&'static str>,
}
impl Slot {
fn new(name: String, now_ms: u64) -> Self {
Self {
name,
busy: AtomicBool::new(false),
since_ms: AtomicU64::new(now_ms),
label: Mutex::new(""),
}
}
}
/// One worker's state at the moment [`Workers::snapshot`] read it.
pub(crate) struct WorkerSnapshot {
pub(crate) name: String,
pub(crate) busy: bool,
/// How long the worker has been in that state.
pub(crate) age_ms: u64,
/// The job in hand, or `""` when idle. `"?"` when the label could not be
/// read without blocking.
pub(crate) label: String,
}
/// The pool's state at the moment [`Workers::snapshot`] read it.
pub(crate) struct PoolSnapshot {
pub(crate) workers: Vec<WorkerSnapshot>,
pub(crate) meta_queued: usize,
pub(crate) transfer_queued: usize,
pub(crate) meta_completed: u64,
pub(crate) transfer_completed: u64,
}
/// The queues and the two wait sets over them.
///
/// Two condvars, not one, because the workers are not interchangeable: a
@@ -86,6 +156,22 @@ struct Pool {
meta_cv: Condvar,
/// Waited on by the general workers.
general_cv: Condvar,
/// Queue depths, mirrored outside the lock so a snapshot can read them
/// without waiting on whatever is holding it.
meta_queued: AtomicUsize,
transfer_queued: AtomicUsize,
/// Jobs finished per lane. A depth that stays high while this stays still is
/// a stalled lane rather than a busy one.
meta_completed: AtomicU64,
transfer_completed: AtomicU64,
slots: Vec<Slot>,
started: Instant,
}
impl Pool {
fn now_ms(&self) -> u64 {
self.started.elapsed().as_millis() as u64
}
}
/// Bounded thread pool behind the network-touching FUSE handlers.
@@ -94,25 +180,46 @@ struct Pool {
/// on-demand sync folder), so the bound is per daemon rather than per mount.
pub(crate) struct Workers {
inner: Arc<Pool>,
/// Kept so teardown can *join* the workers, not merely flag them. A worker
/// still inside a job when the process drops its tokio runtime panics with
/// "A Tokio 1.x context was found, but it is being shutdown" (seen
/// 2026-09-18 09:58:35), which is a teardown ordering bug rather than a real
/// failure.
handles: Mutex<Vec<std::thread::JoinHandle<()>>>,
}
impl Workers {
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
// Never leave the pool without a general worker, however small `n` is.
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
let names: Vec<String> = (0..n)
.map(|i| {
format!(
"pdfs-fuse-{}{i}",
if i < meta_workers { "meta-" } else { "" }
)
})
.collect();
let inner = Arc::new(Pool {
queues: Mutex::new(Queues::default()),
meta_cv: Condvar::new(),
general_cv: Condvar::new(),
meta_queued: AtomicUsize::new(0),
transfer_queued: AtomicUsize::new(0),
meta_completed: AtomicU64::new(0),
transfer_completed: AtomicU64::new(0),
slots: names
.iter()
.map(|name| Slot::new(name.clone(), 0))
.collect(),
started: Instant::now(),
});
// Never leave the pool without a general worker, however small `n` is.
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
for i in 0..n {
let mut handles = Vec::with_capacity(n);
for (i, name) in names.iter().enumerate() {
let inner = inner.clone();
let meta_only = i < meta_workers;
std::thread::Builder::new()
.name(format!(
"pdfs-fuse-{}{i}",
if meta_only { "meta-" } else { "" }
))
let handle = std::thread::Builder::new()
.name(name.clone())
.spawn(move || {
let cv = if meta_only {
&inner.meta_cv
@@ -121,17 +228,20 @@ impl Workers {
};
loop {
let mut q = inner.queues.lock();
let job = loop {
let picked = loop {
// A general worker takes transfers first: metadata
// already has threads of its own, so draining the
// bulk queue is the useful thing for it to do.
let picked = if meta_only {
q.meta.pop_front()
let taken = if meta_only {
q.meta.pop_front().map(|job| (Lane::Meta, job))
} else {
q.transfer.pop_front().or_else(|| q.meta.pop_front())
q.transfer
.pop_front()
.map(|job| (Lane::Transfer, job))
.or_else(|| q.meta.pop_front().map(|job| (Lane::Meta, job)))
};
if let Some(job) = picked {
break Some(job);
if let Some(taken) = taken {
break Some(taken);
}
if q.closed {
break None;
@@ -139,7 +249,15 @@ impl Workers {
cv.wait(&mut q);
};
drop(q);
let Some(job) = job else { break };
let Some((lane, queued)) = picked else { break };
match lane {
Lane::Meta => inner.meta_queued.fetch_sub(1, Ordering::Relaxed),
Lane::Transfer => inner.transfer_queued.fetch_sub(1, Ordering::Relaxed),
};
let slot = &inner.slots[i];
*slot.label.lock() = queued.label;
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
slot.busy.store(true, Ordering::Relaxed);
// A panicking handler must not cost the pool a worker
// for the rest of the run. The dropped `Reply` answers
// EIO on its own, so the caller of the failed op is
@@ -164,11 +282,70 @@ impl Workers {
// poisoning at all. Anything new reached from a job owes
// the same check — an SDK-side `.lock().unwrap()`
// silently invalidates this comment.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(queued.job));
slot.busy.store(false, Ordering::Relaxed);
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
*slot.label.lock() = "";
match lane {
Lane::Meta => inner.meta_completed.fetch_add(1, Ordering::Relaxed),
Lane::Transfer => {
inner.transfer_completed.fetch_add(1, Ordering::Relaxed)
}
};
}
})?;
handles.push(handle);
}
Ok(Self { inner })
Ok(Self {
inner,
handles: Mutex::new(handles),
})
}
/// Stop accepting work, wake every worker, and wait up to `deadline` for the
/// jobs in flight to finish.
///
/// Bounded because a join is only worth what it costs: a worker wedged on a
/// network call would otherwise hold the whole teardown, and the user asked
/// for the daemon to stop. What is stuck gets named in the log, and the
/// process continues shutting down around it.
pub(crate) fn stop_and_join(&self, deadline: std::time::Duration) {
self.close();
let handles: Vec<_> = std::mem::take(&mut *self.handles.lock());
if handles.is_empty() {
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let joiner = std::thread::Builder::new()
.name("pdfs-fuse-join".into())
.spawn(move || {
for handle in handles {
let _ = handle.join();
}
let _ = tx.send(());
});
if joiner.is_err() {
return;
}
if rx.recv_timeout(deadline).is_err() {
let busy: Vec<String> = self
.snapshot()
.workers
.into_iter()
.filter(|worker| worker.busy)
.map(|worker| format!("{}={}", worker.name, worker.label))
.collect();
warn!(
stuck = busy.join(","),
"fuse workers did not finish in time; shutting down around them"
);
}
}
fn close(&self) {
self.inner.queues.lock().closed = true;
self.inner.meta_cv.notify_all();
self.inner.general_cv.notify_all();
}
/// Queue `job` in `lane`.
@@ -179,7 +356,15 @@ impl Workers {
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
/// semaphore is what bounds how many of those exist at once.
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
/// The queue is *not* bounded, and the depth warning above is deliberately
/// all this does about it: the only caller able to feel backpressure here is
/// fuser's dispatch loop, and blocking that is the stall the whole module
/// exists to avoid. A depth that keeps climbing is a symptom to read in the
/// log, not something to fix by stopping the mount.
///
/// `label` names the work for the diagnostics (`pdfs diagnostics`) and for
/// the stall warnings: it is what a hung daemon reports instead of "busy".
pub(crate) fn run(&self, lane: Lane, label: &'static str, job: impl FnOnce() + Send + 'static) {
let mut q = self.inner.queues.lock();
if q.closed {
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
@@ -189,11 +374,31 @@ impl Workers {
job();
return;
}
match lane {
Lane::Meta => q.meta.push_back(Box::new(job)),
Lane::Transfer => q.transfer.push_back(Box::new(job)),
}
let queued = Queued {
label,
job: Box::new(job),
};
let depth = match lane {
Lane::Meta => {
q.meta.push_back(queued);
self.inner.meta_queued.fetch_add(1, Ordering::Relaxed) + 1
}
Lane::Transfer => {
q.transfer.push_back(queued);
self.inner.transfer_queued.fetch_add(1, Ordering::Relaxed) + 1
}
};
drop(q);
if crosses_warn_step(depth) {
warn!(
lane = if matches!(lane, Lane::Meta) {
"meta"
} else {
"transfer"
},
depth, label, "fuse worker queue is deep"
);
}
match lane {
// Either class can serve metadata, and only one of them needs to:
// whichever wakes first takes it, and the other finds the queue
@@ -209,13 +414,50 @@ impl Workers {
}
}
}
/// What every worker is doing right now, without taking the queue lock.
///
/// A diagnostic is worth having exactly when the daemon is stuck, so this
/// reads atomics only, and `try_lock`s each label rather than waiting for
/// it: a label is held for one pointer write, so failing to get it means a
/// racing worker, not a wedged one, and `"?"` is a better answer than a
/// diagnostics request that hangs too.
pub(crate) fn snapshot(&self) -> PoolSnapshot {
let now = self.inner.now_ms();
let workers = self
.inner
.slots
.iter()
.map(|slot| {
let label = slot
.label
.try_lock()
.map_or_else(|| "?".to_string(), |label| (*label).to_string());
WorkerSnapshot {
name: slot.name.clone(),
busy: slot.busy.load(Ordering::Relaxed),
age_ms: now.saturating_sub(slot.since_ms.load(Ordering::Relaxed)),
label,
}
})
.collect();
PoolSnapshot {
workers,
meta_queued: self.inner.meta_queued.load(Ordering::Relaxed),
transfer_queued: self.inner.transfer_queued.load(Ordering::Relaxed),
meta_completed: self.inner.meta_completed.load(Ordering::Relaxed),
transfer_completed: self.inner.transfer_completed.load(Ordering::Relaxed),
}
}
}
impl Drop for Workers {
/// A backstop for the paths that never call
/// [`stop_and_join`](Workers::stop_and_join) — tests, and a mount that fails
/// before teardown is wired up. Flag and wake only: a `Drop` that blocks on
/// a network call is worse than a worker outliving its pool by a moment.
fn drop(&mut self) {
self.inner.queues.lock().closed = true;
self.inner.meta_cv.notify_all();
self.inner.general_cv.notify_all();
self.close();
}
}
@@ -225,6 +467,64 @@ mod tests {
use std::sync::mpsc;
use std::time::Duration;
/// The depth warning fires on the step, and only on the step: a lane that
/// oscillates around the threshold must not log on every single job.
#[test]
fn queue_depth_warns_on_each_step() {
assert!(!crosses_warn_step(1));
assert!(!crosses_warn_step(QUEUE_WARN_STEP - 1));
assert!(crosses_warn_step(QUEUE_WARN_STEP));
assert!(!crosses_warn_step(QUEUE_WARN_STEP + 1));
assert!(crosses_warn_step(QUEUE_WARN_STEP * 3));
}
/// Teardown must return even while a job is running, and must not leave the
/// pool accepting work.
#[test]
fn stopping_is_bounded_and_closes_the_pool() {
let pool = Workers::new(2).unwrap();
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5)).expect("it runs");
pool.stop_and_join(Duration::from_secs(5));
// A job queued after the stop runs inline rather than disappearing.
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("a closed pool serves inline");
}
/// A diagnostic is only useful if it names the work. A running job must show
/// up under its label, and the queue behind it must show up as depth.
#[test]
fn a_snapshot_names_the_running_job_and_the_queue_behind_it() {
let pool = Workers::new(2).unwrap();
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Arc::new(Mutex::new(release_rx));
let (started_tx, started_rx) = mpsc::channel();
{
let release_rx = release_rx.clone();
pool.run(Lane::Transfer, "read", move || {
started_tx.send(()).unwrap();
let _ = release_rx.lock().recv();
});
}
started_rx
.recv_timeout(Duration::from_secs(5))
.expect("the job starts");
let snapshot = pool.snapshot();
assert!(
snapshot.workers.iter().any(|w| w.busy && w.label == "read"),
"the running job is named"
);
assert_eq!(snapshot.transfer_completed, 0);
let _ = release_tx.send(());
}
/// Audit A6. Transfers filling every general worker must not delay a
/// metadata job: that is what the reserved threads are for.
#[test]
@@ -240,7 +540,7 @@ mod tests {
for _ in 0..general {
let release_rx = release_rx.clone();
let started_tx = started_tx.clone();
pool.run(Lane::Transfer, move || {
pool.run(Lane::Transfer, "test", move || {
started_tx.send(()).unwrap();
let _ = release_rx.lock().recv();
});
@@ -255,13 +555,13 @@ mod tests {
// in the way that used to starve metadata.
for _ in 0..16 {
let release_rx = release_rx.clone();
pool.run(Lane::Transfer, move || {
pool.run(Lane::Transfer, "test", move || {
let _ = release_rx.lock().recv();
});
}
let (meta_tx, meta_rx) = mpsc::channel();
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
pool.run(Lane::Meta, "test", move || meta_tx.send(()).unwrap());
meta_rx
.recv_timeout(Duration::from_secs(5))
.expect("a metadata job runs while every transfer thread is stuck");
@@ -279,7 +579,7 @@ mod tests {
// must have been served by the general worker.
let pool = Workers::new(1).unwrap();
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, move || tx.send(()).unwrap());
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("a general worker takes metadata when no transfer is waiting");
}
@@ -288,9 +588,9 @@ mod tests {
#[test]
fn a_panicking_job_does_not_retire_its_worker() {
let pool = Workers::new(1).unwrap();
pool.run(Lane::Meta, || panic!("boom"));
pool.run(Lane::Meta, "test", || panic!("boom"));
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, move || tx.send(()).unwrap());
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("the pool still serves work after a panic");
}
+8 -4
View File
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
authors.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]]
name = "pdfs-tray"
@@ -24,10 +27,11 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
ksni = { version = "0.3", features = ["blocking"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.4"
webkit6 = "0.6"
+3
View File
@@ -22,6 +22,9 @@ fn main() {
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
);
println!(
"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
with the theme) exactly like a stock symbolic icon. -->
<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>
</gresource>
</gresources>
+377 -58
View File
@@ -14,15 +14,17 @@ use pages::photos::*;
use pages::shared::*;
use pages::shared_by_me::*;
use pages::status::*;
use pages::takeout::*;
use pages::trash::*;
use pages::verify::*;
use widgets::details::*;
use widgets::share_dialog::*;
use widgets::thumbnails::*;
use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
@@ -46,10 +48,10 @@ use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
send,
ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
TransferItem, pending_summary, send,
};
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -76,6 +78,15 @@ const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// reload by clearing the page's timestamp.
const PAGE_TTL: Duration = Duration::from_secs(30);
/// How often the gallery asks whether the timeline refresh it started has
/// finished.
const PHOTOS_REFRESH_POLL: Duration = Duration::from_secs(2);
/// How long the gallery keeps following one timeline refresh. A library of tens
/// of thousands of photos is re-read in batches over several minutes; past this
/// the daemon carries on and the page catches up on its next load.
const PHOTOS_REFRESH_FOLLOW_LIMIT: Duration = Duration::from_secs(15 * 60);
/// All widgets the periodic refresh and the action handlers mutate, plus the
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
/// closure share one instance.
@@ -106,6 +117,10 @@ struct Ui {
/// The sidebar/content split. Collapsed while signed out, so the login page
/// owns the whole window and no destination is reachable without a session.
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
// what more than one page genuinely shares.
@@ -120,6 +135,7 @@ struct Ui {
pub(crate) devices: DevicesState,
pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState,
pub(crate) takeout: TakeoutState,
}
impl Ui {
@@ -155,6 +171,22 @@ impl Ui {
}
}
}
/// Mark a cached thumbnail as freshly used, so eviction takes the tiles
/// nobody has looked at rather than the oldest ones.
///
/// Insertion order alone evicts the top of the timeline first — exactly what
/// a scroll back up then asks for again.
fn touch_texture(&self, uid: &str) {
let mut order = self.gallery.photo_tex_order.borrow_mut();
if order.back().is_some_and(|back| back == uid) {
return;
}
if let Some(at) = order.iter().position(|held| held == uid) {
order.remove(at);
order.push_back(uid.to_string());
}
}
}
fn main() -> glib::ExitCode {
@@ -205,10 +237,15 @@ fn load_proton_theme() {
.file-grid {{ padding: 6px; }}\n\
.file-tile {{ padding: 8px; border-radius: 10px; }}\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\
.badge-pinned {{ color: #f5c211; }}\n\
.badge-cached {{ color: #2ec27e; }}\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\
.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\
@@ -225,15 +262,19 @@ fn load_proton_theme() {
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; background: none; box-shadow: none; transition: box-shadow 180ms ease; }}\n\
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-thumb {{ transition: transform 220ms ease; }}\n\
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
.photo-check {{ color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }}\n\
.photo-check-on {{ background: {PROTON_PURPLE}; }}\n\
.photo-tile-selected {{ outline: 3px solid {PROTON_PURPLE}; outline-offset: -3px; }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
.photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
@@ -246,7 +287,10 @@ fn load_proton_theme() {
.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 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();
provider.load_from_string(&css);
@@ -285,6 +329,7 @@ fn build_window(app: &adw::Application) {
let (locations_page, locations_widgets) = build_locations_page();
let (activity_page, activity_widgets) = build_activity_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(&main_page, Some("main"));
stack.add_named(&browser_page, Some("browser"));
@@ -295,6 +340,7 @@ fn build_window(app: &adw::Application) {
stack.add_named(&locations_page, Some("locations"));
stack.add_named(&activity_page, Some("activity"));
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;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
@@ -338,6 +384,7 @@ fn build_window(app: &adw::Application) {
mounted: RefCell::new(false),
sidebar: sidebar_list.clone(),
nav: split.clone(),
file_thumbs: FileThumbnailState::new(),
login: LoginState {
email: login_widgets.0,
password: login_widgets.1,
@@ -362,6 +409,8 @@ fn build_window(app: &adw::Application) {
budget_row: main_widgets.budget_row.clone(),
mountpoint_row: main_widgets.mountpoint_row.clone(),
settings_suppress: Cell::new(false),
budget_source: RefCell::new(None),
pins_expanded: Cell::new(false),
pins_group: main_widgets.pins_group.clone(),
pin_rows: RefCell::new(Vec::new()),
pins_state: RefCell::new(None),
@@ -381,7 +430,29 @@ fn build_window(app: &adw::Application) {
new_folder: browser_widgets.new_folder.clone(),
upload: browser_widgets.upload.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),
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: browser_widgets.details,
@@ -401,12 +472,18 @@ fn build_window(app: &adw::Application) {
gallery: GalleryState {
model: gallery_widgets.model.clone(),
groups: gallery_widgets.groups.clone(),
tile: Cell::new(TILE_DEFAULT),
row_height: Cell::new(ROW_DEFAULT),
learned_ratios: RefCell::new(HashMap::new()),
scrolling_down: Cell::new(true),
scroll_offset: Cell::new(0.0),
assumed_ratios: RefCell::new(HashSet::new()),
content: gallery_widgets.content.clone(),
status: gallery_widgets.status.clone(),
retry: gallery_widgets.retry.clone(),
more: gallery_widgets.more.clone(),
upload: gallery_widgets.upload.clone(),
import: gallery_widgets.import.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
subtitle: gallery_widgets.subtitle.clone(),
albums: gallery_widgets.albums.clone(),
@@ -440,7 +517,14 @@ fn build_window(app: &adw::Application) {
decode_idle: Cell::new(false),
thumb_source: RefCell::new(None),
relayout_source: RefCell::new(None),
bound: RefCell::new(HashMap::new()),
bound: RefCell::new(BTreeMap::new()),
list: gallery_widgets.list.clone(),
selecting: Cell::new(false),
selected: RefCell::new(HashSet::new()),
select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(),
select_label: gallery_widgets.select_label.clone(),
select_trash: gallery_widgets.select_trash.clone(),
},
shared: SharedState {
content: shared_widgets.content.clone(),
@@ -494,6 +578,24 @@ fn build_window(app: &adw::Application) {
inflight: Cell::new(false),
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_logout(&ui, &main_widgets.logout_button);
@@ -501,18 +603,36 @@ fn build_window(app: &adw::Application) {
&ui,
&main_widgets.purge_button,
&main_widgets.mountpoint_button,
&main_widgets.import_row,
);
wire_sidebar(&ui);
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(
&ui,
&browser_widgets.new_folder,
&browser_widgets.upload,
&browser_widgets.upload_folder,
&browser_widgets.build_thumbnails,
);
wire_details(&ui);
wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
wire_gallery(
&ui,
&gallery_widgets.list,
&gallery_widgets.scroll,
&gallery_widgets.select_done,
);
wire_gallery_empty(
&ui,
&gallery_widgets.empty_upload,
&gallery_widgets.empty_import,
);
wire_albums(&ui);
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
@@ -520,6 +640,7 @@ fn build_window(app: &adw::Application) {
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
wire_activity(&ui, &activity_widgets.retry);
wire_takeout(&ui, &takeout_widgets);
wire_refresh(
&ui,
&[
@@ -539,6 +660,10 @@ fn build_window(app: &adw::Application) {
// network round-trip only happens on demand rather than on every refresh.
let ui_nav = ui.clone();
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);
match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav),
@@ -559,6 +684,9 @@ fn build_window(app: &adw::Application) {
// every visit to stay live.
Some("activity") => load_activity(&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 +772,17 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
(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
/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler
/// only ever writes the stack.
@@ -654,7 +793,11 @@ fn wire_sidebar(ui: &Rc<Ui>) {
let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else {
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);
}
});
@@ -705,7 +848,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
}
// One scope covers the whole photos view, so Refresh reloads whichever of
// the two — album grid or timeline/album — is actually on screen.
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| {
Some("gallery") => refresh_then(ui, RefreshScope::Photos { full: false }, |ui| {
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
load_albums(ui);
} else {
@@ -740,11 +883,54 @@ fn reload_current_page(ui: &Rc<Ui>) {
/// how to report an unreachable daemon on its own page, and a refresh that fails
/// silently would read as a dead button.
fn refresh_then(ui: &Rc<Ui>, scope: RefreshScope, load: fn(&Rc<Ui>)) {
// A photos refresh answers as soon as it has started, because re-reading a
// large library takes minutes. The page is therefore loaded twice: once from
// what the daemon already holds, and again when the refresh has landed.
let follow = matches!(scope, RefreshScope::Photos { .. });
let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope });
let ui = ui.clone();
glib::spawn_future_local(async move {
let _ = rx.recv().await;
load(&ui);
if follow {
follow_photos_refresh(&ui, load);
}
});
}
/// Watch the timeline refresh the daemon just started, and load the page again
/// when it finishes.
///
/// Giving up after [`PHOTOS_REFRESH_FOLLOW_LIMIT`] is not a failure: the refresh
/// keeps running in the daemon and the next visit to the page shows its result.
/// It only stops this front-end from polling a daemon forever.
fn follow_photos_refresh(ui: &Rc<Ui>, load: fn(&Rc<Ui>)) {
let ui = ui.clone();
let give_up_at = Instant::now() + PHOTOS_REFRESH_FOLLOW_LIMIT;
glib::spawn_future_local(async move {
loop {
glib::timeout_future(PHOTOS_REFRESH_POLL).await;
// The person may have left the gallery in the meantime; reloading a
// page nobody is looking at would only cost the daemon work.
if ui.stack.visible_child_name().as_deref() != Some("gallery") {
return;
}
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotosRefreshStatus);
match rx.recv().await {
Ok(Ok(Response::PhotosRefresh { running: true })) => {
if Instant::now() >= give_up_at {
return;
}
}
Ok(Ok(Response::PhotosRefresh { running: false })) => {
load(&ui);
return;
}
// An older daemon does not know the request, and anything else
// means we cannot tell — either way, stop asking.
_ => return,
}
}
});
}
@@ -755,6 +941,7 @@ fn sync_sidebar(ui: &Rc<Ui>) {
let Some(current) = ui.stack.visible_child_name() else {
return;
};
let current = destination_of(&current);
let index = DESTINATIONS
.iter()
.position(|(page, _, _)| *page == current);
@@ -776,6 +963,22 @@ fn toast(ui: &Rc<Ui>, message: &str) {
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
/// prefixed with what was being attempted, since a bare daemon error ("no such
/// file") reads as noise without it.
@@ -830,7 +1033,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
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()
.icon_name("open-menu-symbolic")
.tooltip_text("Main menu")
@@ -849,11 +1052,17 @@ fn install_window_actions(window: &adw::ApplicationWindow) {
let win = window.clone();
about.connect_activate(move |_, _| {
let dialog = adw::AboutDialog::builder()
.application_name("Proton Drive")
.application_icon("folder-remote-symbolic")
.application_name("Proton Drive for Linux")
.application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION)
.developer_name("proton-drive-linux")
.comments("On-demand Proton Drive sync for Linux.")
.developer_name("Nils Pukropp")
.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();
dialog.present(Some(&win));
});
@@ -913,7 +1122,13 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// browser-only bindings.
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::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();
}
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
@@ -923,12 +1138,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
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 => {
if let Some(entry) = selected_entry(&ui) {
prompt_delete(&ui, &entry);
let entries = selected_entries(&ui);
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);
}
_ => return glib::Propagation::Proceed,
@@ -1033,54 +1257,149 @@ fn human_bytes(bytes: u64) -> String {
mod tests {
use super::*;
/// The full width one row of `columns` tiles occupies, gaps included.
fn row_width(columns: usize, edge: i32) -> i32 {
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
/// The full width a row occupies, gaps included.
fn row_width(widths: &[i32]) -> i32 {
widths.iter().sum::<i32>() + TILE_GAP * (widths.len() as i32 - 1)
}
/// Ratios of a typical mixed day: landscape phone shots, a portrait, a
/// square crop.
fn mixed() -> Vec<f64> {
vec![
1.5, 1.5, 0.75, 1.0, 1.33, 1.5, 0.75, 1.78, 1.0, 1.5, 1.5, 1.33,
]
}
#[test]
fn the_grid_spans_the_content_width() {
// Whatever the width, the columns plus their gaps land on it (bar the
// few px integer division cannot divide), so no ragged right margin.
fn every_full_row_spans_the_content_width() {
// The point of justifying: no ragged right margin at any width, at any
// zoom, for any mix of shapes.
for width in [640, 900, 1000, 1440, 1920, 2560] {
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
let used = row_width(columns, edge);
assert!(
used <= width && width - used < columns as i32,
"{columns} x {edge}px = {used}px does not span {width}px"
);
let rows = plan_rows(&mixed(), width, ROW_DEFAULT);
// The last row is deliberately short, so it is not part of this.
for (_, widths) in rows.iter().take(rows.len() - 1) {
assert_eq!(row_width(widths), width, "row does not span {width}px");
}
}
}
#[test]
fn tiles_stay_near_the_target_size() {
// The column count rounds and the edge absorbs the remainder, so a tile
// is never more than one gap-and-a-bit away from what was asked for.
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
assert!(columns >= 7, "1920px should hold several 220px tiles");
assert!(
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
"{edge}px is not near the {TILE_DEFAULT}px target"
fn a_tile_keeps_its_photos_shape() {
// Each tile is its photo's ratio at the row's height, which is what
// makes cropping unnecessary.
let ratios = mixed();
let rows = plan_rows(&ratios, 1440, ROW_DEFAULT);
let (height, widths) = &rows[0];
for (width, ratio) in widths.iter().zip(&ratios).take(widths.len() - 1) {
let laid_out = *width as f64 / *height as f64;
assert!((laid_out - ratio).abs() < 0.02, "{laid_out} is not {ratio}");
}
}
#[test]
fn rows_land_near_the_target_height() {
// A row is closed as soon as it no longer fits at the target, so it is
// never taller than the target and never far below it.
for target in [ROW_MIN, ROW_DEFAULT, ROW_MAX] {
let rows = plan_rows(&mixed(), 1600, target);
for (height, _) in rows.iter().take(rows.len() - 1) {
assert!(
*height <= target,
"{height}px exceeds the {target}px target"
);
assert!(*height > target / 2, "{height}px is far under {target}px");
}
}
}
#[test]
fn a_short_day_is_not_stretched_across_the_window() {
// Two photos are two photos, not two half-window tiles.
let rows = plan_rows(&[1.5, 1.5], 1920, ROW_DEFAULT);
assert_eq!(rows.len(), 1);
let (height, widths) = &rows[0];
assert_eq!(*height, ROW_DEFAULT);
assert!(row_width(widths) < 1920 / 2);
}
#[test]
fn zooming_in_puts_fewer_photos_in_a_row() {
let small = plan_rows(&mixed(), 1200, ROW_MIN);
let big = plan_rows(&mixed(), 1200, ROW_MAX);
assert!(small[0].1.len() > big[0].1.len());
}
#[test]
fn every_photo_is_laid_out_exactly_once() {
let ratios = mixed();
let placed: usize = plan_rows(&ratios, 1000, ROW_DEFAULT)
.iter()
.map(|(_, widths)| widths.len())
.sum();
assert_eq!(placed, ratios.len());
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
}
/// One file of a shot, for the group-switch tests.
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
PhotoItem {
uid: uid.into(),
capture_time: 0,
thumb_path: None,
name: name.map(str::to_string),
ratio: None,
no_thumb: false,
kind,
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
}
}
/// The lightbox steps through a shot's files and comes back round, so a
/// burst of three is as reachable as a RAW+JPEG pair.
#[test]
fn the_group_switch_cycles_through_every_file() {
let members = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
member("clip", None, PhotoKind::Video),
];
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
assert_eq!(next("jpeg"), Some("raw"));
assert_eq!(next("raw"), Some("clip"));
assert_eq!(next("clip"), Some("jpeg"));
// A photo that is not in the group leaves the button alone rather than
// jumping somewhere arbitrary.
assert_eq!(next("elsewhere"), None);
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
fn a_group_member_without_a_name_is_still_described() {
assert_eq!(
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
"IMG_1.CR2"
);
assert_eq!(
member_label(&member("raw", None, PhotoKind::Raw)),
"the raw file"
);
assert_eq!(
member_label(&member("clip", Some(""), PhotoKind::Video)),
"the video"
);
}
#[test]
fn zooming_in_widens_the_tiles_and_drops_columns() {
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
assert!(wide_columns > few_columns);
assert!(big_edge > small_edge);
}
#[test]
fn a_window_narrower_than_one_tile_still_gets_a_column() {
// A single column at whatever fits, rather than zero columns (which would
// divide by zero) or a zero-px tile.
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
assert_eq!(columns, 1);
assert!(edge > 0);
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
assert_eq!(columns, 1);
assert!(edge > 0);
fn a_window_narrower_than_one_photo_still_lays_it_out() {
// A tile of at least one px, rather than a zero-width widget or a
// division by zero.
for width in [0, 40] {
let rows = plan_rows(&[1.5, 1.5, 1.5], width, ROW_DEFAULT);
assert!(rows.iter().all(|(h, w)| *h > 0 && w.iter().all(|w| *w > 0)));
}
}
}
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_by_me;
pub(crate) mod status;
pub(crate) mod takeout;
pub(crate) mod trash;
pub(crate) mod verify;
@@ -24,6 +24,11 @@ pub(crate) struct Viewer {
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton,
pub(crate) favorite_suppress: Cell<bool>,
/// The button that switches between the files of one shot, and the files it
/// switches between (the JPEG and the RAW of a photo, a live photo and its
/// clip). Empty, and the button hidden, for a photo stored as one file.
pub(crate) group_btn: gtk4::Button,
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
@@ -81,6 +86,59 @@ fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands.
/// Ask the daemon which files this shot was stored as, and offer a switch when
/// there is more than one. The grid shows such a shot as a single tile, so the
/// lightbox is the only place the RAW is reachable.
fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
viewer.group.borrow_mut().clear();
viewer.group_btn.set_visible(false);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::PhotoGroup {
uid: uid.to_string(),
},
);
let viewer = viewer.clone();
let uid = uid.to_string();
glib::spawn_future_local(async move {
let Ok(Ok(Response::Photos { items, .. })) = rx.recv().await else {
return;
};
// A late reply for a photo the user has already left must not relabel
// the button under the one now on screen.
if *viewer.uid.borrow() != uid || items.len() < 2 {
return;
}
if let Some(next) = next_group_member(&items, &uid) {
viewer
.group_btn
.set_tooltip_text(Some(&format!("Show {}", member_label(next))));
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
});
}
/// The member after `uid`, wrapping round — the button steps through a shot's
/// files rather than toggling two of them, so a burst of three works too.
pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Option<&'a PhotoItem> {
let at = members.iter().position(|item| item.uid == uid)?;
members.get((at + 1) % members.len())
}
/// What to call one file of a shot: its name, or its kind when the daemon has
/// not resolved a name for it.
pub(crate) fn member_label(item: &PhotoItem) -> String {
match item.name.as_deref() {
Some(name) if !name.is_empty() => name.to_string(),
_ => match item.kind {
PhotoKind::Raw => "the raw file".to_string(),
PhotoKind::Video => "the video".to_string(),
PhotoKind::Photo => "the photo".to_string(),
},
}
}
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
@@ -96,6 +154,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
.and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
show_favorite(viewer, favorite);
load_photo_group(ui, viewer, &uid);
// The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second.
@@ -503,7 +562,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", "Other files of this photo");
group_btn.set_visible(false);
let download_btn = action("document-save-symbolic", "Save a copy…");
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
close_btn.add_css_class("viewer-close-btn");
@@ -513,9 +578,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
@@ -614,6 +681,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_map: info_map.clone(),
favorite: favorite_btn.clone(),
favorite_suppress: Cell::new(false),
group_btn: group_btn.clone(),
group: RefCell::new(Vec::new()),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
@@ -695,6 +764,30 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
});
});
// Deleting the photo on screen closes the lightbox: what it was showing is
// gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see.
let ui_delete = ui.clone();
let viewer_delete = viewer.clone();
let w_delete = window.clone();
delete_btn.connect_clicked(move |_| {
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid]);
});
let ui_group = ui.clone();
let viewer_group = viewer.clone();
group_btn.connect_clicked(move |_| {
let uid = viewer_group.uid.borrow().clone();
let next =
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
if let Some(next) = next {
*viewer_group.uid.borrow_mut() = next.clone();
load_photo(&ui_group, &viewer_group, next);
}
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
@@ -757,6 +850,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
w_key.fullscreen();
}
}
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid]);
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
_ => return glib::Propagation::Proceed,
File diff suppressed because it is too large. Load diff
+50 -9
View File
@@ -151,6 +151,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() {
return;
}
cancel_file_thumbnails(ui);
let current = ui.shared.nav.borrow().last().cloned();
if let Some((uid, _)) = current {
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) {
row.add_suffix(&badge);
}
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
// The "shared by" in the subtitle is what the invitation claims. When its
// signature does not check out against that person's keys, the claim must
// not read as fact — someone may be impersonating a contact to get a file
// opened.
if entry.shared_by_unverified {
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
warning.add_css_class("warning");
warning.set_valign(gtk4::Align::Center);
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
row.add_suffix(&warning);
}
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
let ui_act = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
@@ -332,12 +340,29 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
row
}
/// A shared row's subtitle: its size (files only), and where it can be reached
/// in the local mount once the synthetic `Shared with me/` directory has interned
/// it. An unlisted share has no local path yet, and says nothing rather than
/// pointing at a directory that is not there.
/// Tooltip on the warning shown next to a share whose invitation did not verify.
const UNVERIFIED_INVITER_TOOLTIP: &str = "The invitation's signature doesn't match the sender's keys. \
It may not really be from them.";
/// A shared row's subtitle: who shared it and when (share roots only — that is
/// where the invitation lives), its size (files only), and where it can be
/// reached in the local mount once the synthetic `Shared with me/` directory has
/// interned it. An unlisted share has no local path yet, and says nothing rather
/// than pointing at a directory that is not there.
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
let mut parts: Vec<String> = Vec::new();
if !entry.shared_by.is_empty() {
let date = if entry.shared_at > 0 {
format_modified(entry.shared_at)
} else {
String::new()
};
parts.push(if date.is_empty() {
format!("Shared by {}", entry.shared_by)
} else {
format!("Shared by {} on {date}", entry.shared_by)
});
}
if !entry.is_dir {
parts.push(human_bytes(entry.size));
}
@@ -726,9 +751,25 @@ mod tests {
uid: "vol~link".into(),
path: path.into(),
role: role.into(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}
}
#[test]
fn a_share_root_says_who_shared_it() {
let mut root = entry(true, "", "viewer");
root.shared_by = "alice@proton.me".into();
assert_eq!(shared_row_subtitle(&root), "Shared by alice@proton.me");
root.shared_at = 1_700_000_000;
let subtitle = shared_row_subtitle(&root);
assert!(
subtitle.starts_with("Shared by alice@proton.me on "),
"{subtitle}"
);
}
#[test]
fn a_share_with_no_local_path_says_nothing_about_one() {
// Before the synthetic directory has been listed, the daemon cannot
@@ -113,6 +113,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
if ui.shared_by_me.inflight.get() {
return;
}
cancel_file_thumbnails(ui);
ui.shared_by_me.inflight.set(true);
shared_by_me_status(
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");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items {
let entry = shared_item_as_entry(item);
let row = adw::ActionRow::builder()
.title(&item.name)
.subtitle(shared_item_summary(item))
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if item.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
// 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()) {
@@ -245,7 +243,6 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
.build();
manage.add_css_class("flat");
let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage);
@@ -289,12 +286,15 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
name: item.name.clone(),
is_dir: item.is_dir,
size: 0,
modified: 0,
modified: item.modified,
pinned: false,
cached: false,
uid: item.uid.clone(),
path: item.path.clone(),
// Shared *by* me: I own it, so there is no role of mine to report.
role: String::new(),
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
}
}
+220 -35
View File
@@ -37,7 +37,16 @@ pub(crate) struct StatusState {
/// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect.
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,
/// 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
/// diff against them and only rebuild when the pin set actually changes.
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
@@ -104,6 +113,8 @@ pub(crate) struct MainWidgets {
/// which is where the folder is changed.
pub(crate) mountpoint_row: adw::ActionRow,
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,
@@ -233,6 +244,22 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
mountpoint_row.add_suffix(&mountpoint_button);
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.
let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files")
@@ -265,6 +292,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
inner.append(&import_group);
inner.append(&pins_group);
inner.append(&dev_group);
@@ -292,10 +320,20 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
purge_button,
mountpoint_row,
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.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
@@ -308,6 +346,7 @@ pub(crate) fn wire_settings(
ui: &Rc<Ui>,
purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button,
import_row: &adw::ActionRow,
) {
let config = ui.dirs.load_config();
@@ -329,13 +368,22 @@ pub(crate) fn wire_settings(
if ui_budget.status.settings_suppress.get() {
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;
settings_request(
&ui_budget,
Request::SetCacheBudget { bytes },
"Cache budget updated",
"Couldn't set cache budget",
);
let ui_fire = ui_budget.clone();
let src = glib::timeout_add_local_once(BUDGET_DEBOUNCE, move || {
ui_fire.status.budget_source.borrow_mut().take();
settings_request(
&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.
@@ -382,6 +430,10 @@ pub(crate) fn wire_settings(
// local path; this button is the way there.
let ui_mp = ui.clone();
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,
@@ -513,8 +565,14 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
refresh_transfers(ui);
// 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.
// 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() {
Some("main") => refresh_quota(ui),
Some("main" | "browser") => refresh_quota(ui),
Some("locations") => refresh_locations(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
/// 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);
/// Fetch the account quota (if the last reading is stale) and paint the Account
/// storage group. Runs only while Settings is on screen. The group stays hidden
/// 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
/// Fetch the account quota (if the last reading is stale) and paint both the
/// Settings storage group and Files status bar. A failed fetch leaves the last
/// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() {
@@ -551,28 +607,71 @@ pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
})) = result
{
ui.status.quota_checked_at.set(Some(Instant::now()));
let used = used_space.max(0) as u64;
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)));
}
paint_account_quota(&ui, max_space, used_space);
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
/// 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.upload.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.details.details.pin_row.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.
// 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.
// 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());
if items.is_empty() && previous > 0 {
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)
};
ui.status.cache_bar.set_fraction(fraction);
ui.status.cache_label.set_text(&format!(
"{} of {} used",
human_bytes(used),
human_bytes(budget)
));
// A 0 budget means *unlimited*, not a zero-byte cap — "of 0 B
// used" reads as a broken read-out, and there is no fraction to
// draw against no limit.
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);
}
// 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;
}
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)
.file_name()
.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),
});
}
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| {
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()
.halign(gtk4::Align::Start)
.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_start(6);
row.set_margin_end(6);
row.append(&icon);
row.append(&thumbnail);
row.append(&text);
row.append(&restore);
row.append(&purge);
item.set_child(Some(&row));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
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)
factory.connect_bind({
let ui = ui.clone();
move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
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));
@@ -216,6 +219,7 @@ pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
/// Fetch the trash listing and repaint the page.
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
// that may already be gone.
ui.trash.model.remove_all();
+15 -16
View File
@@ -11,8 +11,8 @@ pub(crate) struct DetailsState {
pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so
/// a selection in either drives the details pane.
pub(crate) grid_selection: gtk4::SingleSelection,
pub(crate) list_selection: gtk4::SingleSelection,
pub(crate) grid_selection: gtk4::MultiSelection,
pub(crate) list_selection: gtk4::MultiSelection,
}
/// 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(),
] {
let ui_sel = ui.clone();
selection.connect_selection_changed(move |sel, _, _| {
match entry_at(sel.model().as_ref(), sel.selected()) {
Some(entry) => show_details(&ui_sel, &entry),
None => hide_details(&ui_sel),
selection.connect_selection_changed(move |_, _, _| {
// The details pane describes *one* entry. A batch is the bulk bar's
// business, so the pane steps aside rather than picking a member of
// 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();
ui.details.details.close_button.connect_clicked(move |_| {
ui_close
.details
.grid_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
ui_close
.details
.list_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
clear_selection(&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;
}
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
/// F2 / Delete shortcuts.
/// The single entry the details pane is showing, if any. Backs the actions that
/// 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> {
ui.details.details_entry.borrow().clone()
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
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.
//!
//! Everything here is blocking and GTK-free — the process exists for one
//! round-trip and then exits, so there is no main loop to keep off. It reuses
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
//! activation policy, so the two front ends cannot drift on what "open" means.
//! round-trip and then exits, so there is no main loop to keep off. Talking to
//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
//! fzf front end and built on the GTK prompt's own [`Hit`](crate::Hit) model
//! and activation policy; this module is only presentation.
//!
//! Launchers filter a static list; they cannot call back for a new one per
//! keystroke. So the interaction is a loop: the first menu shows pinned files
//! and accepts free text, and any text that matches no entry becomes the next
//! search. Escape (an empty selection) ends it.
use std::path::{Path, PathBuf};
//! search. Escape (an empty selection) ends it. `--fzf` ([`crate::fzf`]) is the
//! front end for users who want results to appear while they type instead.
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
use pdfs_core::opener::{self, OpenWith};
use pdfs_core::opener::OpenWith;
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
use crate::Hit;
use crate::query;
/// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever.
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.to_string()
})?;
let mountpoint = match request(&socket, Request::Status)? {
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
_ => dirs.default_mountpoint(),
};
let mountpoint = query::mountpoint(&socket, &dirs);
let mut query = options
.query
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.filter(|q| !q.is_empty());
for _ in 0..MAX_ROUNDS {
let hits = match &query {
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
None => pins(&socket)?,
Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
None => query::pins(&socket)?,
};
let items: Vec<MenuItem> = if hits.is_empty() {
vec![MenuItem::new(placeholder(query.as_deref()), None)]
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
query = None;
continue;
};
return open(&socket, &mountpoint, &policy, hit, query.is_none());
return query::open(&socket, &mountpoint, &policy, hit, query.is_none());
}
// No entry matched what was typed: treat it as the next query.
MenuChoice::Custom(text) => query = Some(text),
@@ -118,57 +112,6 @@ fn placeholder(query: Option<&str>) -> String {
}
}
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
/// with, and the only listing the daemon offers without a query.
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
return Ok(Vec::new());
};
Ok(pins
.into_iter()
.map(|pin| {
Hit::Drive(SearchHit {
name: file_name(&pin.path),
path: pin.path,
is_dir: pin.is_dir.unwrap_or(pin.recursive),
size: 0,
modified: 0,
pinned: true,
uid: pin.uid,
mounted_path: None,
score: 0,
})
})
.collect())
}
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
let reply = request(
socket,
Request::SearchV2 {
query: query.to_string(),
limit,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: SearchKind::All,
},
},
)?;
let Response::SearchResultsV2 {
drive_hits,
local_hits,
..
} = reply
else {
return Ok(Vec::new());
};
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
hits.extend(local_hits.into_iter().map(Hit::Local));
rank_hits(&mut hits);
hits.truncate(limit);
Ok(hits)
}
/// One launcher line per hit. Labels must be unique: they are how the selection
/// is matched back, and two files of the same name in the same folder listing
/// would otherwise be indistinguishable.
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
suffix += 1;
}
items.push(MenuItem::new(label, Some(icon_name(hit))));
items.push(MenuItem::new(label, Some(query::icon_name(hit))));
}
items
}
/// Icon theme name for a hit.
///
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
/// an empty buffer answers `application-x-zerosize` for everything. The generic
/// names below exist in every icon theme.
fn icon_name(hit: &Hit) -> String {
if hit.is_dir() {
return "folder".to_string();
}
let name = hit.name();
if is_image(name) {
"image-x-generic"
} else if is_media(name) {
"video-x-generic"
} else if is_document(name) {
"text-x-generic"
} else {
"application-x-generic"
}
.to_string()
}
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
/// disk, a Drive folder or streamable file goes through the mount, and anything
/// else is materialised by the daemon first.
fn open(
socket: &Path,
mountpoint: &Path,
policy: &OpenWith,
hit: &Hit,
from_pins: bool,
) -> Result<(), String> {
match hit {
Hit::Local(local) => {
opener::open(policy, Path::new(&local.path), local.is_dir);
Ok(())
}
Hit::Drive(drive) => {
// A folder has no materialised form, and a pin row's `is_dir` is pin
// policy rather than node kind, so both go to the mount unchecked.
let unconditional = from_pins
|| matches!(
drive_activation(&drive.name, drive.is_dir),
DriveActivation::Folder | DriveActivation::MountedMedia
);
if unconditional {
opener::open(
policy,
&mounted_or_relative(mountpoint, drive),
drive.is_dir,
);
return Ok(());
}
// Anything else the mount exposes opens there too, so an editor
// saves back into Drive instead of into a cache blob.
if let Some(path) = mounted_target(mountpoint, drive) {
opener::open(policy, &path, false);
return Ok(());
}
match request(
socket,
Request::OpenFile {
path: drive.path.clone(),
uid: Some(drive.uid.clone()),
},
)? {
Response::FilePath { path } => {
// The reply is a content-hash blob; the rules key off the
// Drive name, which is what the user actually opened.
opener::open_named(policy, Path::new(&path), &drive.name, false);
Ok(())
}
Response::Error { message, .. } => Err(format!("could not open: {message}")),
_ => Err("unexpected reply from the daemon".to_string()),
}
}
}
}
/// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page.
fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::LocalHit;
use pdfs_core::control::{LocalHit, SearchHit};
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
Hit::Drive(SearchHit {
@@ -331,18 +188,4 @@ mod tests {
assert!(placeholder(None).contains("type to search"));
assert!(placeholder(Some("md")).contains("md"));
}
#[test]
fn icons_are_theme_names_the_launcher_can_look_up() {
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
assert_eq!(
icon_name(&drive("a.png", "a.png", false)),
"image-x-generic"
);
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
assert_eq!(
icon_name(&drive("a.bin", "a.bin", false)),
"application-x-generic"
);
}
}
+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"
);
}
}
+9 -8
View File
@@ -14,8 +14,9 @@ use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use ksni::blocking::TrayMethods;
use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use ksni::{MenuItem, Tray};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
@@ -282,13 +283,13 @@ fn main() -> Result<()> {
state: poll_state(&socket, &default_mountpoint),
};
let service = TrayService::new(tray);
let handle = service.handle();
std::thread::spawn(move || {
if let Err(e) = service.run() {
tracing::error!("tray service stopped: {e:?}");
}
});
// The tray autostarts with the session and can beat the desktop's SNI
// watcher onto the bus. Without `assume_sni_available` that race is a hard
// spawn error; with it the item waits and registers once the watcher shows.
let handle = tray
.assume_sni_available(true)
.spawn()
.context("start tray service")?;
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
+44 -6
View File
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
mod activation;
mod dmenu;
mod fzf;
mod query;
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
@@ -125,7 +127,7 @@ fn is_media(name: &str) -> bool {
/// One row in the unified result list. The two sections hold different payloads
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
/// local file is already on disk — but they share one keyboard cursor.
#[derive(Clone)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
enum Hit {
Drive(SearchHit),
Local(LocalHit),
@@ -341,12 +343,20 @@ Options:
rofi, …) instead of the built-in GTK window. Also
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
in config.json.
--fzf Search in fzf, in a terminal, re-querying the daemon on
every keystroke — results appear as you type, unlike
--dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }.
--gtk Force the built-in window, overriding that setting.
--menu <COMMAND> Launcher command line for --dmenu, e.g.
--menu 'fuzzel --dmenu --width 60'. Overrides
\"prompt\": { \"menu\": [...] }.
--query <TEXT> Search for TEXT immediately instead of opening on the
pinned-files list (--dmenu only).
pinned-files list (--dmenu and --fzf only).
--feed <TEXT> Print the hits for TEXT, one per line, and exit. This is
what --fzf re-runs on each keystroke; it is not meant to
be typed.
--inner Run fzf here rather than spawning a terminal. Set by the
terminal --fzf spawns; not meant to be typed.
-h, --help Show this help.
";
@@ -356,6 +366,12 @@ struct Args {
mode: Option<PromptMode>,
menu: Option<Vec<String>>,
query: Option<String>,
/// `--feed`: print hits for this text and exit. `Some(None)` is `--feed`
/// with an empty argument, which fzf sends for an empty input — a real
/// request for the pinned list, not an absent flag.
feed: Option<Option<String>>,
/// `--inner`: we are the process a spawned terminal is running.
inner: bool,
}
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
@@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
mode: None,
menu: None,
query: None,
feed: None,
inner: false,
};
let mut argv = argv.peekable();
while let Some(arg) = argv.next() {
match arg.as_str() {
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
"--fzf" => args.mode = Some(PromptMode::Fzf),
"--gtk" => args.mode = Some(PromptMode::Gtk),
"--inner" => args.inner = true,
// fzf passes an empty argument for an empty input, so a missing
// value here is that case rather than a user error.
"--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())),
// A launcher command is one shell-ish string so it can live in a
// keybinding; splitting on whitespace is enough for flags, and a
// launcher argument needing spaces belongs in config.json.
@@ -416,16 +439,31 @@ fn main() -> glib::ExitCode {
}
};
// A feed is fzf's own reload child, not a front end: it must print hits and
// nothing else, so it is answered before the stored mode is even read.
if let Some(query) = args.feed {
fzf::feed(query.as_deref());
return glib::ExitCode::SUCCESS;
}
// The stored mode makes an existing keybinding switch front ends without
// being re-bound; an explicit flag still wins.
let stored = AppDirs::new()
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
.unwrap_or_default();
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
return match dmenu::run(dmenu::Options {
let launcher = match args.mode.unwrap_or(stored) {
PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
menu: args.menu,
query: args.query,
}) {
})),
PromptMode::Fzf => Some(fzf::run(fzf::Options {
query: args.query,
inner: args.inner,
})),
PromptMode::Gtk => None,
};
if let Some(result) = launcher {
return match result {
Ok(()) => glib::ExitCode::SUCCESS,
Err(message) => {
eprintln!("pdfs-prompt: {message}");
@@ -1326,7 +1364,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
if is_dir {
return gio::ThemedIcon::new("folder").upcast();
}
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]);
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
gio::functions::content_type_get_icon(&content_type)
}
+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");
}
}
+3 -2
View File
@@ -153,8 +153,9 @@ Because Proton Drive does not support partial byte writes, modified files must b
2. **Close/Release (`queue_revision`):** When the application closes the file descriptor, the daemon:
- Fetches any untouched gaps from the remote base file to compile the full file.
- Durably publishes the scratch data and authored-range sidecar into `staging` under a `{uid}-{millis}-{counter}` name. Temporary data and metadata are synced before atomic rename, and their directory is synced before the source is removed.
- Transactionally queues or supersedes a pending database operation (`PendingOp`), so insertion failure cannot erase the previously acknowledged upload. For `OP_REVISION` ops, execution is debounced by 2 seconds (`DRAIN_REVISION_DEBOUNCE = 2s`) to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission.
3. **Async Drain Thread (`run_pending_drain`):** The background drain worker picks up the database operations queue, handles revision uploads, resolves conflicts, and cleans up staging files. Upon landing a revision upload (`refresh_after_upload`), it rebaselines both still-queued ops (`rebaseline_pending`) and open write handles targeting the same node to prevent false self-conflict copies on subsequent writes.
- Transactionally queues or supersedes a pending database operation (`PendingOp`), so insertion failure cannot erase the previously acknowledged upload. For `OP_REVISION` ops, execution is debounced to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission. The debounce is adaptive (`Core::revision_debounce`): it starts at `DRAIN_REVISION_DEBOUNCE = 2s` and, once a node has been uploaded, widens toward how long that upload actually took, bounded by `DRAIN_REVISION_DEBOUNCE_MAX = 60s`. A file saved faster than it can be sent therefore supersedes in the queue rather than mid-upload.
- Signals `Core::cancel_upload` for the node before touching the queue. A drain worker may already be reading the blob this write supersedes; the flag is read by the upload's `CountingReader`, which refuses the SDK's next block. `queue_trash` and `discard_queued_ops` do the same, because they unlink the blob outright.
3. **Async Drain Threads (`run_pending_drain`):** `DRAIN_WORKERS = 3` background workers share the operations queue. They pick work up through `Db::claim_next_due_op`, which marks the row `claimed_at` in the same transaction that selects it and excludes any op whose uid another worker already holds — ordering only has to hold *per node*, and that exclusion is what guarantees it. Worker 0 additionally runs the queue's idle chores (LRU touch flush, `recover_fsynced_writes`). A claim is process-local state: the single-writer `flock` means one found at open belongs to a crashed run, and `Db::clear_op_claims` drops the lot so those ops are not invisible forever. Each worker handles revision uploads, resolves conflicts, and cleans up staging files. Upon landing a revision upload (`refresh_after_upload`), it rebaselines both still-queued ops (`rebaseline_pending`) and open write handles targeting the same node to prevent false self-conflict copies on subsequent writes.
```mermaid
sequenceDiagram
+1218 -19
View File
File diff suppressed because it is too large. Load diff
+844
View File
@@ -0,0 +1,844 @@
# Changelog
All notable changes to the Linux client since the 1.0.0 release. Format follows
[Keep a Changelog](https://keepachangelog.com/1.1.0/); versions follow semver, and every
release is tagged `vX.Y.Z` with `Cargo.toml` and `packaging/PKGBUILD` in agreement.
`Schema:` lines record the SQLite `SCHEMA_VERSION` (`pdfs-core/src/db/migrations.rs`) a
release ships. Migrations are forward-only — a database written by a newer client is a hard
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).
## [1.11.1] — 2026-09-19
Schema: **30** (unchanged).
### Fixed
- **An upload parked behind a temp-file rename no longer stays parked forever.** A create for a
name that looks like a writer's temporary file waits for the rename to the final name. If that
rename never came — the writer crashed, the file was deleted, or the name was never temporary —
the upload sat in the queue permanently, holding its staged bytes. The queue now sweeps parked
creates while it is idle: one whose file is gone is dropped, and one that has been parked for
over an hour with nobody holding it open is uploaded under the name it has. An open file is
left alone however old it is (B94).
- **A file whose real name ends in `.tmp` uploads normally.** `.tmp` and `.temp` were treated as
writer temp-file suffixes, so ordinary files — a Google Photos Takeout `index.tmp`, for
instance — were parked waiting for a rename that was never going to happen (B95).
- **Staged bytes that belong to nothing are cleaned up.** A staged blob that matches no queued
upload and names no node used to be kept forever and warned about once per blob on every mount.
Blobs older than 30 days are now retired with their size logged; younger ones are kept, but
reported as a single line instead of one per blob (B96).
- **Refreshing the gallery repeatedly no longer wedges the control socket.** Each refresh scope
now runs one at a time; asking again while one is running is answered immediately rather than
repeating work that contends with it for the database. `pdfs diagnostics` also names the scope
of a running refresh (B97).
- **Shutting down during a photo refresh no longer logs a wall of "undecryptable photo"
warnings.** The metadata pass stops when teardown begins instead of running into the cancelled
runtime. Nothing was wrong with those photos; the next refresh reads them again (B97).
## [1.11.0] — 2026-09-19
Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` /
`proton-drive-rs` **0.6.5**.
### Changed
- **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape:
photos are taken in capture order until they no longer fit the target height, and the row is
then scaled to land exactly on the right margin. Nothing is cropped and nothing is
letterboxed, which is what the square centre-cropped tiles used to do to portraits and
panoramas. The last row of a day stays at the target height rather than being stretched, and
Ctrl+scroll / Ctrl+± now changes that target height. Gaps and tile corners are smaller, so the
page reads as photos rather than as cards.
- **Thumbnails stay on screen while you scroll.** A decoded thumbnail now lands in the texture
cache even when the tile that asked for it has already been recycled, the cache is a real LRU
holding 1500 textures instead of 600, and the rows just past the edge of the window are
fetched ahead of the scroll direction. Scrolling fast through a large timeline no longer
leaves a screen of blank tiles behind.
- **Refresh on the Photos page really refreshes.** Instead of only clearing the freshness stamp
and letting the next read serve the stale page, it starts the timeline re-read and follows it:
the page paints from what the daemon holds, then paints again when the new timeline lands. The
timeline is also considered stale after 60 s rather than 5 minutes. `pdfs refresh photos`
blocks until the re-read is done, as it reads.
- **Restore puts back the whole tree.** The trash is a flat list, but you delete a shape: a
folder, and often things you had already deleted inside it. Restoring a folder now also
restores the items that were trashed inside it, and restoring one of those items first
restores the trashed folders above it — otherwise the file came back into a folder that is
still in the trash, which is to say nowhere you can see it. Parents are restored before their
children, and each node is still restored individually, so one node the server refuses does
not cancel the rest.
### Added
- **A RAW and its JPEG are one photo.** A camera that writes both put two tiles of the same shot
in the timeline; now it puts one, badged `RAW`, and the lightbox has a control that switches
between the files. The Raw tab still lists every raw file individually, because that is what
the tab is for. Grouping follows the server's own photo relation where it has one — which
also collapses live photos and bursts — and otherwise pairs files with the same name taken on
the same day where one is raw and the other is not. Deleting the tile deletes the whole shot,
and the confirmation says how many files that is.
- **Delete from the gallery.** Select photos with the Select button, with Ctrl+click or with
Shift+click, then Move to Trash — or press Delete in the lightbox. The tiles leave the grid
immediately with an Undo toast, and the photos land in Proton's trash, where they stay
recoverable. A photo the server refuses to trash comes back into the grid.
- **A photo deleted on your phone disappears here.** The daemon now follows the photos volume's
own event stream, so a photo trashed on another device leaves the gallery within about ten
seconds instead of waiting for the timeline to go stale.
- **`pdfs diagnostics`.** Reports what the daemon is doing right now: every worker thread with the job
it holds and for how long, queue depths per lane, the control requests still in flight, resident
size and the pending-op count. Answers even while the daemon is wedged, which is the only time
anyone asks (B91).
- **A stall watchdog.** The daemon warns in the journal when a worker has held one job for two
minutes, when a control request has run that long, or when a queue is not draining — and it now
answers the systemd watchdog, but only after a real round trip over its own control socket. A
daemon that stops answering is restarted instead of sitting there looking healthy (B91).
### Fixed
- **A Pixel raw capture groups with its JPEG.** Pixel names the two files of one shot
`PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng`, so their stems differ and the pairing
rule never saw them as one shot. The part-of-a-shot marker is now cut off before the names are
compared, and the grid shows the cover JPEG once. The grouping is computed by a timeline
refresh, so an existing library pairs up after the next one.
- **The Raw tab no longer decodes a raw it does not have to.** A raw file that was written next
to a JPEG of the same shot is now given that JPEG's thumbnail, which the server usually already
has. No raw download and no raw decode for those tiles.
- **Photos can be moved to Trash from the gallery again.** Photos live on the photo volume, which
the mount never holds, so the write check found no node to consult and refused every photo with
"trash access: not allowed". Membership of our own timeline now answers for them; an album photo
on someone else's volume is still refused.
- **A photos refresh no longer fails with "Resource temporarily unavailable" (B92).** Re-reading a
twelve-thousand-photo library resolves the nodes in batches of 200 and takes minutes, but the
daemon held the control reply until it had finished — well past the client's 120 s read bound.
The caller therefore saw `io: Resource temporarily unavailable (os error 11)` for a refresh that
was running perfectly, and the gallery reloaded from the old timeline. The daemon now answers as
soon as the refresh is running, and the new `PhotosRefreshStatus` request reports when it is
done. Each refresh also logs its photo count, batch count and duration.
- **A worker no longer panics during shutdown (B91).** The FUSE worker pool is joined before the
tokio runtime is dropped, so a worker still inside a job does not find a runtime that is going
away underneath it.
- **A permanently rejected block upload is no longer retried (SDK 0.6.5).** The SDK now knows
which API errors are worth replaying: 4xx is permanent, except 404, 408 and 429.
- **Long downloads no longer fail on expired block URLs (SDK 0.6.5).** A download whose block
URLs lapse mid-transfer re-mints them once instead of failing, on the whole-file path and on
public links, as reads already did.
- **A long rate-limit answer can no longer look like a hang (SDK 0.6.5).** A server-supplied
`Retry-After` is now capped at 120 s.
## [1.10.2] — 2026-09-16
Fixes a daemon that could not be stopped or restarted. No schema change.
Schema: **28**; no SDK change.
### Fixed
- **A dead network mount in your home directory no longer wedges the daemon (B90).** The
"This computer" file indexer walked `$HOME` and descended into other FUSE mounts — an sshfs
whose server had gone away, an unresponsive rclone. Stat'ing one of those blocks
uninterruptibly, so the indexer thread stopped responding to SIGKILL, the daemon could never
exit, `systemctl --user stop` timed out, and the half-dead process kept the cache database lock
that the next start needed: every restart then failed with "another Proton Drive daemon is
already using cache.db". The indexer now skips mountpoints below your home directory by path,
without touching them. Nothing changes about which files are indexed — those mounts were always
excluded from the results, just not before they had been stat'ed.
## [1.10.1] — 2026-09-15
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
### Security
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
`dbus-codegen` → `clap` 2 → `atty`.
### Changed
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
found, so no re-login is needed.
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
the desktop's tray host onto the session bus.
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
## [1.10.0] — 2026-09-15
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
### Added
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
### Changed
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
now calls `move-multiple` with a target name, so there is no half-applied state between the two
halves and no second call for the encrypted-name requirements to go stale against (B46). A
rename that stays in the same folder still uses the rename endpoint.
### Fixed
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
that is already in the trash.
## [1.9.3] — 2026-09-01
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
### Fixed
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
of identical lines (`#21`).
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
### Changed
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
so the schedule is testable without a keyring and without real time passing.
## [1.9.2] — 2026-08-28
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
`proton-sdk` / `proton-drive-rs` **0.6.2**.
### Added
- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose
metadata sidecars did not match imports with no capture time at all, and the SDK then seals
"now" into every revision — a decade of photos lands on one date. The date is still in the file
name, so this finds photos whose name disagrees with their stored capture time by more than a
day and rewrites them: download, re-upload at the right time, carry favourites and album
memberships across, then **trash** (never delete) the original — Proton has no API for editing a
sealed capture time. `--dry-run` reports without touching anything, `--from`/`--to` aim the run
at the days an import landed on, and `--limit` caps it. Followed with `pdfs redate-status`,
stopped with `pdfs cancel-redate`.
- **`pdfs-prompt --fzf`: launcher search that updates as you type.** A dmenu-style launcher
(fuzzel, rofi, wofi, …) is handed one static list and filters it itself, which is why `--dmenu`
has to make searching two steps — type, Enter, *then* results. `fzf` can re-run a command on
every keystroke, so the new mode re-queries the daemon per keystroke (debounced to match the GTK
prompt) and keeps `--disabled` on so the daemon's ranking, not fzf's matcher, decides the order.
Abbreviation and typo tolerance therefore work here exactly as they do in the built-in window.
- Started without a tty — from a WM keybinding — `--fzf` spawns a terminal for itself (foot,
ghostty, kitty, alacritty, wezterm or xterm, whichever is installed), each with the app-id/class
`pdfs-prompt` so a single window rule floats it. Override with `"prompt": { "terminal": [...] }`,
where a `{cmd}` token places the command and its absence appends it. `"prompt": { "mode": "fzf" }`
makes it the default front end for an existing keybinding.
### Fixed
- **Google Photos Takeout: sidecars with a localized suffix are matched again.** Google localizes
the `.supplemental-metadata` it appends to a photo's JSON sidecar — a German export writes
`.ergänzende-Metadaten` — and the scanner only knew the English spelling. No sidecar matched, so
no photo had a capture time, so every photo in the export was stamped with the moment it was
uploaded. Matching now hangs on the photo name in front of the suffix rather than on the suffix
itself, which is locale-agnostic, and covers the misspelt variants Google has also shipped.
- **A photo with no usable sidecar now takes its date from its file name.** `PXL_20260818_171030868.jpg`,
`IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`, `IMG-20230219-WA0001.jpg` and
`Screenshot from 2023-01-01 10-29-50.png` all carry the date the photo was taken. Read as UTC, so
it can be hours out — but never the years out that "the moment it was imported" is. A sidecar
still wins.
- **An opened file or folder no longer dies with the window that opened it.** Everything launched
through `open_with` was started in the caller's process group with the caller's stdio, which was
invisible from a resident GUI but fatal from `--fzf`: the prompt exits the moment you choose
something, its terminal closes, and the pty hangup killed the application it had just started —
so an `@dir` rule like `alacritty --working-directory {}` opened nothing at all. Opened children
now get their own process group and no inherited stdio. Affects every front end; only `--fzf`
could observe the bug.
### Changed
- **The gallery virtualises at the row, not the day.** The Photos ListView had one item per day,
and a bind built every one of that day's tiles — fine for a normal day, ruinous for the ~1,600
photos a Takeout import drops onto a single date, which was 1,600 widgets constructed on the main
thread before the section could be shown. Days are now flattened into heading rows and grid rows,
so a bind is a handful of widgets whatever the day holds. A resize or a zoom step rebuilds the row
model (the column count decides which photos share a row) and scrolls back to the photo that was
at the top.
- Updated to `proton-sdk` / `proton-drive-rs` 0.6.2 (photos save-to-timeline, `enumerate_events`).
- The daemon side of both launcher front ends — search, pins, mountpoint lookup and the
open-a-result policy — moved out of `pdfs-gui/src/dmenu.rs` into a shared `pdfs-gui/src/query.rs`,
so `--dmenu` and `--fzf` cannot drift on what a result set is or what "open" means. No behaviour
change to `--dmenu`.
## [1.9.1] — 2026-08-21
Thumbnails everywhere the GUI lists files, a status bar under the Files browser, and the
repository's contribution/branding files. No schema change. Schema: **28**; SDK unchanged at
`proton-sdk` / `proton-drive-rs` **0.6.1**.
`exiftool` (`libimage-exiftool-perl` / `perl-image-exiftool` / `perl-Image-ExifTool`) is a new
runtime dependency, declared in the `.deb`, `.rpm` and `PKGBUILD` packaging. It is only used to
pull the embedded preview out of a camera RAW; without it every other image format still gets a
thumbnail.
### Added
- **Thumbnails for ordinary Drive files**, not just the Photos timeline — painted in Files (both
the grid and the Name column of the list), Shared, Shared by me, and Trash. Standard raster
formats (`bmp`, `gif`, `jpeg`, `png`, `tiff`, `webp`) are decoded in the daemon; camera RAWs
(`arw`, `cr2`, `cr3`, `dng`, `nef`, `orf`, `raf`, `rw2`, and the rest of the concrete list) are
served from their embedded preview via `exiftool`. Previews are cached beside the existing photo
thumbnails, outside the cache-budget scan, and tagged with the node's modification time — so a
new revision of an image can never be shown with its predecessor's thumbnail.
- Thumbnails for visible rows are requested opportunistically as tiles scroll in, and the daemon
drops work for listings the user has already navigated away from. The listing generation is
issued by the daemon (`ReserveFileThumbGeneration`), not seeded from the front-end's clock.
- **Build a whole folder tree ahead of time** from the Files toolbar: one recursive local-thumbnail
job per daemon, with live scan/found/completed/failed progress and a Cancel that appears only
once the daemon has acknowledged the start. The job outlives navigation — it is deliberate work,
separate from the opportunistic per-tile requests.
- **A Dolphin-style status bar under the Files browser**: the current listing's counts, a Zoom
slider for the icon grid, and account storage usage. Zoom resizes the realised grid cells rather
than rebuilding the model, so dragging the slider does not tear down the selection; the default
size reproduces the previous fixed 72 px tiles. Capacity stays out of the bar until a real
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of
only in Settings.
- Repository housekeeping: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull
request templates, a project logo, and workspace crate metadata (`repository`, `homepage`,
`readme`, `keywords`, `categories`).
### Fixed
- A remote thumbnail miss no longer suppresses local generation. The two negative results are now
held in separate bounded caches: "Proton has no thumbnail for this node" and "this exact revision
was decoded locally and could not be read" mean different things, and only the second is a
permanent verdict for that revision.
- A RAW whose preview could not be extracted because of the *environment* — `exiftool` missing, a
failed download — is retried instead of being remembered as undecodable. Only bytes conclusively
outside the supported formats are cached as a negative.
- A recursive build that finds a uid an opportunistic tile job already owns waits briefly for it
rather than either duplicating the work or blocking on it forever, and a cancellation racing a
waiter's registration is caught by a backstop instead of leaving the waiter parked.
- Starting a build for a root that is already building attaches to that job; starting one for a
*different* root is refused explicitly rather than silently reporting the running job's progress
as the caller's.
- Decrypted camera RAWs are staged for `exiftool` in an owner-only (`0700`) directory of their own,
removed as soon as the preview is out and swept at cache open after an unclean shutdown.
- The RAW extension list had `rw` (not a format) and was missing `raw`; both classification helpers
now read from one shared table instead of two hand-maintained `matches!` arms.
- The tray and desktop-entry icons were wrong/missing; `.desktop` files and the packaged SVG were
corrected.
## [1.9.0] — 2026-08-18
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
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
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
audit entries that had been sitting as "fixed in code, verification pending" since 1.0 (B49–B55).
No schema change. Schema: **28**.
The read path no longer assumes a revision is a series of 4 MiB blocks; it takes the geometry from
the revision. The B85 investigation also **contradicted its own bug report** — see Notes, because
the conclusion recorded there was that reads of such files were losing data, and they were not.
### Fixed
- A folder switched from mirror to on-demand now shows the files the mirror pass itself uploaded.
The sync engine created nodes on the remote without invalidating the parent's cached child
listing, and that listing is a database flag rather than an in-memory cache — so it survived a
daemon restart, and the files stayed invisible until the folder was switched back. Every remote
mutation the engine makes (folder create, file upload, and the three trash paths) now drops the
parent's cached listing by uid, which reaches a folder that no mount has resident — the case a
mirror folder always is (`docs/BUGS.md` B86).
- `pdfs ls` and `pdfs refresh` accept a path under any mount the daemon owns, not only the primary
one. Both previously answered "is not under the mountpoint" for anything inside a secondary
on-demand mount, which left a folder serving a stale listing with no manual way to re-enumerate
it. A path under nothing this daemon has mounted still gets the same error as before.
- A file whose blocks are not 4 MiB is no longer read twice over. Block offsets came from
`index * 4 MiB`, so on a revision laid out differently every block the client asked for
straddled two of the revision's own, and each was fetched and decrypted to answer for one — then
cached at boundaries the next read would not ask about. Offsets now come from the revision's
recorded block sizes, and the on-disk cache, the in-memory ring and the in-flight fetch key
address a block by the byte range it covers rather than by its index (`docs/BUGS.md` B85).
### Changed
- The block cache records each block's plaintext start alongside its `(mtime, size)` tag, and each
revision's block table in a small sidecar beside its blocks. Blocks cached by an earlier release
carry no start; they are honoured wherever the old 4 MiB assumption and the new plan agree on
the offset, which is every file this client uploaded — so the existing cache survives the
upgrade intact and is quietly refetched only for the files it was placing wrongly.
### Notes
- **B85's diagnosis was wrong, and the entry has been corrected rather than closed.** It read a
set of journal lines as proof that this client was requesting wrong byte ranges and being saved
only by B84's repair path. `RevisionReader::read_at` does not work that way: it plans over the
revision's real block sizes and clamps every block to the range asked for, identically in the
0.6.0 the client builds against and in every earlier version. A read can therefore never return
more bytes than it requested — yet every quoted line shows exactly that (`blen=4194304
got=4939506`), and the one place in the client that logs that shape can only reach it on the
opposite condition. The numbers were either transcribed with two fields swapped or captured from
a build not in the history; they want re-capturing from the journal before anything is concluded
from them. What was real is the assumption itself, and that is what this release removes — a
cost fix, not a correctness one. Nothing here changes what a read returns.
- The first read of a file still plans on the 4 MiB assumption, and learns the real geometry as a
side effect of opening the reader it needs anyway. Resolving a reader merely to *plan* would
reintroduce the per-read key derivation the block cache exists to avoid, so the first read of a
non-4-MiB file pays one straddling fetch and then self-corrects.
- Seven data-safety audit entries (B49–B55) moved from "fixed in code, verification pending" to
verified. The fixes were all genuinely present and needed no change; what they lacked was the
injected-failure testing each entry asked for, and that is what was written — unwritable
directories and `chmod 000` subtrees for permission failures, a real `/dev/shm`↔`/tmp` device
boundary for the cross-filesystem fallbacks, SQLite's `max_page_count` for a full disk, an abort
trigger for a failed insert, and truncated and unpublished sidecars for the crash boundaries.
Two seams were added to make failure arrangeable at all: `stage_write_at`, mirroring the
`preserve_write_at` that already existed, and `walk_local_tree` lifted out of the sync engine so
the local scan can be driven against a real tree without a daemon.
- **Two of those entries are not fully closed, and say so.** B51 and B52 concern crash consistency,
and nothing short of a machine actually losing power tests that; the crash *boundaries* are
covered by reconstructing on disk exactly what a crash at each point leaves. B54 also diverges
from its required fix on purpose: it has no "deliberate delete" confirmation path, so a total
local wipe is refused outright rather than offered for confirmation. That is stricter than asked
for, and the cost is that a user who really did delete everything must remove and re-add the
sync folder.
- B86's fix routes only `ls` and `refresh` to the owning mount. The other path-addressed control
requests — pin, rename, move, delete, sharing — still resolve against the primary mount only.
That is a larger change and was not what blocked the repro; it is recorded as an open follow-up
(`docs/BUGS.md` B88).
- Three findings were opened rather than fixed: the geometry above is learned one read late, so a
non-4-MiB file pays a single wasted fetch on its first read (B87); the remaining path-addressed
control requests are still primary-only (B88); and the container-level `CLAUDE.md` still
describes the SDK dependency as 0.1.11 when 1.7.1 bumped it to 0.6.0, which is what sent the
B85 investigation to the wrong source tree to begin with (B89).
## [1.8.1] — 2026-08-16
Some files read short through the mount and reported success — an application got a truncated
copy with nothing anywhere saying so (`docs/BUGS.md` B84). Found because GNOME's file indexer
kept complaining that PNGs under `~/ProtonDrive/Pictures` were corrupt; it was right. The rest
of this release is the long-standing architectural backlog from the July concurrency audits
(B25, B32, B39, B40, B43, B44), each verified against the current code before being fixed.
Schema: **28** — migration V28 adds `sync_entry.local_mtime_ns`.
### Fixed
- A read no longer trusts a revision whose recorded block sizes disagree with the file's size.
Such a file leaves the block/range path entirely: it is fetched through the whole-file,
manifest-verified download — the same path `pdfs pin` uses, and the reason pinning was the
workaround — and served from the resulting cached blob. One download per file however many
reads discover the problem at once, and files above 512 MiB fail `EIO` with a note to pin
them rather than pulling down gigabytes for one read.
- A block that comes back shorter than the file's size implies is never served, cached or kept
in memory. Assembling a read out of short blocks is what quietly produced the truncation:
the missing bytes were simply not appended, the kernel handed userspace a short read, and
every tool reads a short read as end-of-file.
- A synced folder no longer reports the same deletion as an error on every pass. Deleting a
local file that something else had already deleted counted as a failure, which kept its
baseline and made the next pass try again — forever. A path that is already gone is the
state the sync pass wanted (`docs/BUGS.md` B42).
- A mirror folder no longer misses an edit made in the same second as its last sync. Change
detection compared whole seconds, so rewriting a file in place without changing its length
read as "unchanged" and never uploaded — the remote silently stayed behind until something
else about the file moved. Baselines now record the modification time to nanoseconds
(`docs/BUGS.md` B25).
- A sync download no longer overwrites an edit that finished while it was running. The
destination is re-checked in the last moment before the download is published, and a local
file that has moved on since the pass planned for it is kept as a conflict copy — the same
resolution a both-sides-changed conflict gets (`docs/BUGS.md` B39).
- An upload interrupted by a writer no longer reports itself as synchronized. The baseline now
records the content that actually went up rather than whatever is on disk when the transfer
ends, so a revision torn by a concurrent write is replaced by a clean one on the next pass
instead of standing as the file's content on Drive (`docs/BUGS.md` B40).
- Listing a directory that changes while it is being read no longer skips or repeats entries.
`readdir` resumed by index into a listing rebuilt on every call, so a create or a trash
between two pages shifted everything after it. The listing is now frozen for the life of the
directory handle (`docs/BUGS.md` B43).
- Opening a file for writing while a previous close is still publishing no longer discards that
close's revision. The base is re-checked at the moment the write handle is installed, rather
than trusted across a copy that can be the size of the whole file (`docs/BUGS.md` B32).
- Unmounting now stops and joins the daemon's background workers instead of leaving them
running. The drain, sync engine, conflict sweep, online probe, local indexer and control
listener share a stop signal and interruptible waits, so an in-process remount no longer
starts a second set on top of the first (`docs/BUGS.md` B44).
### Notes
- The size `stat` reports was always the correct one; it is the reader's belief about where
blocks end that was wrong, so nothing about reported sizes changes.
- Verified live on the affected account: four multi-block PNGs that had been reading short now
deliver their full size, and the journal shows each one repaired. The diagnostics also
answered the open question, and not as expected — the revision metadata is fine; those files
simply do not have 4 MiB blocks, and this client assumes they do. Filed as `docs/BUGS.md`
B85. Until that is fixed, a file with non-4-MiB blocks is downloaded whole on its first read.
- Migration V28's new column is nullable rather than converted from the existing seconds. A
baseline row written before this release has no sub-second time to recover, and inventing
zero for it would make every already-synced file compare as changed and re-upload an entire
mirror on first start; `NULL` keeps those rows comparing exactly as they did, and each gains
a real value the next time its path syncs.
- Three audit entries closed without a code change, having been fixed already or being correct
as they stand: sync-folder removal is serialized against reconciliation (B41), the drain's
database contention was removed by the read-only connection pool and the claim column (B30),
and the refusals for symlinks, hard links, device nodes and `chmod` are deliberate — Drive
cannot represent them, and a local-only representation no other Proton client can read would
be worse than a clean refusal (B31).
- Six of this release's fixes were then driven live against the installed 1.8.1 on a real
account: the same-second edit uploads (B25), three rapid rewrites of a 64 MiB file publish the
third one byte for byte (B32), a write racing a download survives as a conflict copy (B39), an
upload torn by a concurrent write heals itself on the following pass (B40), a `getdents64`
walk with a 160-byte buffer against a directory mutated between pages returns every entry
exactly once (B43), and a restart of a daemon with six mounts and a busy drain stops in 110 ms
and comes back at the same thread count (B44). B42 could not be driven from a single machine —
see B86 below.
- One new bug came out of the verification: an on-demand mount serves a folder listing that
predates the sync engine's own uploads, so files the mirror pass created are invisible until
the folder is switched back (`docs/BUGS.md` B86). Nothing is lost — they are on the server and
come back on the next mirror pass — but they cannot be seen or named while the folder is
on-demand, which is also what blocks B42's live verification.
## [1.8.0] — 2026-08-16
A queued write whose node had left the local tree was retried every five seconds forever and
counted as an ordinary pending upload — 28 of them, holding 18.4 GiB, had been doing that for
30 days on the machine that found it (`docs/BUGS.md` B83). Schema: 27 — migration V27 adds
`pending_op.access_deferred_since`.
### Fixed
- The drain no longer reads "I have never heard of this node" as "you may not write this
node". A missing authority is now resolved against the remote: the node is re-interned if
it is still there, and reported as a real failure if it is gone. Ops that had been stuck
invisibly for weeks now say what is wrong with them on the first pass.
- A create or mkdir resolves its *parent* as the authority, so the refetch asks about the
folder rather than the node it has not made yet.
### Added
- Access deferrals are bounded. A deferral still costs no attempt and records no error for
the first five minutes — the case it exists for is a share downgraded mid-queue and undone
a moment later — but past that window each recheck records a failure, so the op enters the
ordinary backoff and appears in `failing_ops` and the `pdfs status` error text like any
other stuck operation. The first deferral of a run is logged at `warn` rather than `debug`.
### Notes
- No staged data is touched by any of this. A reported failure keeps its `pending_op` row and
its staged blob exactly as before; the change is in what the user is told, and in the queue
no longer waiting on a permission change that was never coming.
## [1.7.1] — 2026-08-16
Write-back throughput follow-up to 1.7.0, same audit
(`docs/plans/audit-2026-08-15-perf-locks.md`). Schema: 26 — migration V26 adds
`pending_op.claimed_at`.
### Added
- Parallel write-back drain: three workers share the queue through a `pending_op.claimed_at`
claim column (migration V26), so a large upload no longer holds every queued rename, trash
and small write behind it. Ordering is preserved per node — the claim query never offers
two workers ops for the same uid — and claims left by a crashed run are cleared at open.
- An upload already on the wire is cancelled when a newer write, a trash or a discard
supersedes it, instead of spending the uplink on a revision the next operation replaces.
### Changed
- The revision debounce is adaptive: it widens toward how long that node's last upload
actually took (bounded to 2–60 s), so a file saved faster than it can be sent supersedes
in the queue rather than mid-upload.
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.6.0.
## [1.7.0] — 2026-08-16
Performance and robustness pass over the mount, from the architecture audit in
`docs/plans/audit-2026-08-15-perf-locks.md`. Schema: 25.
### Added
- Sequential-read prefetch (`Core::prefetch_ahead`): per-file sequential detection, depth
doubling 2→8, reset to 0 after a seek, skipping blocks already cached, and bounded by its
own permit budget so it can never queue ahead of a demand read.
- Single-flight block fetching (`BlockFlight`) keyed on `(uid, mtime, size, block)`, so a
demand read, kernel read-ahead and prefetch of the same block share one download+decrypt.
- In-memory block LRU (`BlockRing`) in front of *both* block paths; a block read off disk is
promoted into it, so the following ~31 kernel reads of that 4 MiB block cost nothing.
- Read-only SQLite connection pool (`ReadPool` / `Db::read`) with every `SELECT`-only method
routed at it, so a read no longer waits on the write connection.
- `nodes.path` persisted and backfilled, replacing the recursive `path_of` walk.
- Single-writer `flock` on the database; a `SQLITE_CORRUPT`/`NOTADB` file is quarantined as
`cache.db.corrupt-<ts>` (WAL sidecars too) and rebuilt rather than failing the mount.
- `VACUUM` and the deep `integrity_check` now ack immediately and run as background jobs;
`pdfs doctor` polls for the verdict.
- Staging reconcile at mount: staged blobs no queued operation refers to are re-queued, or
named in the log when they cannot be addressed.
- Queue/staging visibility — `Status` gained `parked_uploads`, `failing_ops`,
`failing_error`, `staged_bytes`, `staged_oldest_secs`; `pdfs status` prints them when
non-zero.
- `statfs` implemented against the account quota; FUSE `init` negotiates `max_readahead`,
`max_background`, `PARALLEL_DIROPS` and `CACHE_SYMLINKS`; `blksize` raised to 1 MiB.
### Changed
- `State` no longer writes to SQLite under the inode lock — mutations queue into an outbox
applied after the lock is released, so interning a 5000-child listing no longer commits a
transaction with the whole mount frozen.
- Re-listing an unchanged folder no longer touches its subtree; when a subtree really moved,
one recursive walk carries both the new path and the inherited trashed flag.
- `release(2)` no longer gap-fills from the network: it stages an incomplete blob from local
caches only and the drain thread finishes it off the dispatch loop.
- A partial write over an undrained one is merged instead of refused.
- Size-upgrade waiters park on a queue instead of occupying a worker thread, capped at 8
folders in flight; past the cap `stat` answers provisionally.
- Block-cache disk writes moved off the read's critical path; no more fsync per cached block.
- Cache LRU touches buffered in memory and flushed in one transaction.
- Search: sync-folder list and pin lookups hoisted out of the per-hit loop.
- SQLite tuned (`cache_size`, `mmap_size`, `temp_store=MEMORY`, `wal_autocheckpoint`) and
three new indexes added.
- Mount registry lock released before FUSE teardown/join; kernel notifications batched under
the state lock and flushed after it.
### Fixed
- **Data loss:** `ENOSPC` is now propagated from `write`/`fsync`/staging instead of being
swallowed, with emergency eviction wired up and scratch marked durable before any staging
error; a failed `record_pending_write` fails `release` rather than dropping the write.
- **Data loss:** the conflict sweep no longer discards queued operations after the trash
round trip.
- **Data loss:** `rescue_scratch` moves a blob with an unreadable sidecar to `recovery/`
unaccompanied instead of fabricating a sidecar for it.
- Own-sealed revisions are persisted (7-day TTL), so a restart no longer reopens the
transient-download fork window fixed in 1.1.0.
- Depth-capped `is_pinned` (cycle-safe), daemon write timeout, and every client-supplied
`limit` clamped.
- `recover_fsynced_writes` no longer scans the recovery directory on every idle pass.
## [1.6.0] — 2026-08-12
Search-index correctness and ranking (`docs/plans/search-index.md`). Schema: 21 —
migration V21 rebuilds `nodes_fts`.
### Fixed
- **B80** — 29% of the account was missing from the search index. `node_is_indexable_tx`
required a null-parent ancestor, which only the My Files root has, so every node under a
device folder was dropped from `nodes_fts` on its first upsert after the v16 backfill
(2,705 of 9,252 nodes on the account this was diagnosed against). The rule now matches the
backfill, migration V21 replays it, nodes under a trashed folder stay out, and the walk is
depth-capped.
- **B81** — the prompt labelled every Drive hit "My files", including device folders; pin
rows also took `is_dir` from `Pin::recursive`.
- **B82** — a `parent_uid` cycle hung the daemon on any short search: the recursive CTEs in
`path_of` and the pin walk use `UNION ALL`, which does not deduplicate. `path_of` is now
depth-capped at 256 and returns the truncated path.
### Changed
- Local search ranking: whole-query prefix matches get their own lane (newest first) ahead
of trigram rank, which on a home directory full of dependency trees was burying the
obvious answer — 73% of the 500 best-ranked candidates for "test" came from one Go module
cache. The single-character lane, which recovers a typo that destroys every trigram, stays
behind it.
## [1.5.0] — 2026-08-11
Schema: 20.
### Added
- **`pdfs-prompt --dmenu`** — the same search through an external dmenu-style launcher
(fuzzel, rofi, wofi, tofi, bemenu, dmenu) instead of the built-in GTK HUD, so the prompt
matches a tiling-WM desktop. A launcher filters a fixed list rather than re-querying per
keystroke, so searching is a two-step loop; `--query` skips the first step, fuzzel and rofi
get file-type icons. Configurable as `"prompt": { "mode": "dmenu", "menu": [...] }`, with
`--gtk` overriding for one invocation.
- **Configurable open-with rules** (`pdfs-core/src/opener.rs`): an ordered list of name/class
patterns in `config.json` under `open_with`, each mapped to argv and optionally wrapped in
a terminal, first match wins, falling back to `xdg-open`. Lets "open a text file" mean
`nvim` in a terminal without breaking every graphical caller. Absent config reproduces the
old behaviour exactly.
### Fixed
- The prompt and dmenu openers hand applications the cached file while matching rules
against the *original* Drive filename, so e.g. `.md` files stop being routed by their
cache-path extension.
## [1.4.0] — 2026-08-10
Schema: 20.
### Added
- **File version history** (`pdfs-fuse/src/revisions.rs` + a GTK versions dialog): list a
file's revisions, restore one, delete one, or write an old one out to a local path. A
restore re-points the file server-side, so no content crosses the wire and nothing enters
the drain queue; the daemon drops its cached content and repopulates on the next read.
- **Persistent SDK entity cache** (`pdfs-core/src/sdkcache.rs`): the SDK's decrypted node
metadata now survives a daemon restart instead of re-walking the tree. It lives in its own
`sdk_cache.db`, deliberately off the daemon's hot `cache.db` mutex, holds no key material,
needs no migrations, and is cleared on logout.
- Per-node outcomes for batch operations (`pdfs-core/src/batch.rs`): `trash`/`restore`/
`delete`/`move` report one result per node, so a node the server rejects no longer fails
the whole batch.
- Photo viewer page in the GUI and richer photo metadata in the details pane.
### Changed
- SDK telemetry routed into `tracing`, so its transfer/block-storage/request spans land in
the daemon log instead of a no-op sink.
- A file that fits in one block uploads as a single atomic request rather than the
draft/block/commit sequence.
## [1.3.0] — 2026-07-31
Schema: 19.
### Added
- **Photo albums** — `PhotoAlbums` / `AlbumPhotos` control requests, daemon-side `albums.rs`
and a GUI albums page (the former gallery view, renamed). Albums other people share with
you are included; their photos are persisted per album because a shared album's photos
never appear in your own timeline. Served stale-while-refreshing like the timeline.
- **GUI "Locations" page** — one row per mount spec: the primary `~/ProtonDrive` mount
alongside every folder this computer backs up, mirrored or on-demand. Called *Locations*
rather than *Mounts* because a mirror folder is a plain directory with no FUSE session.
It absorbs the old Computers page's actions and the mountpoint chooser from Settings.
- `role` on shared-node metadata (`viewer`/`editor`/`admin`), defaulted for wire-compat with
older clients and daemons.
- FUSE acceptance coverage for the permission-based mounts.
## [1.2.1] — 2026-07-31
Schema: 18.
### Fixed
- Migration bug that could leave the `ProtonDrive` mount unwritable after upgrade.
- Permission error on on-demand mounts.
- B78.
### Changed
- Release pipeline: `.deb` signing added and `.rpm` signing fixed.
## [1.2.0] — 2026-07-29
Sharing and multi-mount release. Schema: 18.
### Added
- **Shared with me**: shares others sent you are listed, verified and mountable, including on
the primary mount.
They appear under a synthetic `Shared with me` directory on a virtual volume
(`pdfs-fuse/src/virtual.rs`), with name collisions and over-long components resolved by
its own naming rules.
- Per-share access model (`db/share_access.rs`): the effective role of each shared root is
persisted and kept resident in mount state, so interning an inode never costs a query. The
database stays the restart/offline authority — every role change is written through before
the cache updates. A share you cannot write is presented POSIX read-only.
- Unified location model (`pdfs-core/src/mounts.rs`, `db/mounts.rs`): one presentation shape
for every local Proton Drive location, joining device sync state with a new `mount` table.
`MountMode` is `Mirror` (full local copy, reconciled both ways) or `OnDemand` (FUSE session
fetching on access), with an `Unknown` fallback so a mode written by a newer client does
not break an older one.
## [1.1.1] — 2026-07-27
Schema: 16.
### Fixed
- Trash refresh did not pull updates.
- Packaging: `PKGBUILD` was left at 1.1.0 by the 1.1.1 version bump, failing the release
workflow's tag/manifest/PKGBUILD agreement check.
## [1.1.0] — 2026-07-26
Conflict-handling and concurrency release. Schema: 16.
### Added
- Conflict sweep (`pdfs-fuse/src/sweep.rs`, 5-minute cadence): removes conflict copies proven
identical by size + `content_sha1`, surfaces divergent ones once as an activity entry, and
never removes what it cannot prove.
- Sweep mode and `.pdfsignore`-driven conflict handling; ignore rules are rebuilt per sync
pass so edits take effect without a restart.
- Substantially extended FUSE acceptance suite.
### Fixed
- **B69** — spurious `(sync-conflict)` copies. Revision identity now uses the server's
`revision_id` rather than `(mtime, size)`, which the server re-stamps on seal; the planner
gained `AdoptBaseline` so an equal-size no-baseline pair is recorded instead of forked on
first reconcile.
- **B70** — browser downloads into an on-demand mount had their in-flight temporaries sealed
as canonical revisions. Transient names are recognised and held until the name is finalized.
- Assorted race conditions and concurrency bugs on the write/drain paths.
### Changed
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.2.2.
## [1.0.0] — 2026-07-22
First stable release: FUSE files-on-demand mount, sync daemon under `proton-drive.service`,
`pdfs` CLI and the GTK4 app, on top of the pure-Rust Proton Drive SDK. Schema: 16.
### Fixed
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem.
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
[1.7.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.1
[1.7.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.0
[1.6.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.6.0
[1.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.5.0
[1.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.4.0
[1.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.3.0
[1.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.1
[1.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.0
[1.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.1
[1.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.0
[1.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.0.0
+25 -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.
* **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.
* **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.
* **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.
@@ -141,6 +142,27 @@ The gallery can mark and filter favourites, through the SDK's photo-tag API (`up
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
### Justified Gallery, Gallery Delete (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-gui/src/app/pages/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` / `fit_row`): photos are taken in capture order until their summed aspect ratio no longer fits the target row height, and the row is then scaled so it ends exactly on the content width. A tile is therefore its own photo's shape, and `ContentFit::Contain` has nothing left to crop. The last row of a day is left at the target height rather than stretched, because a day holding two photos is a short day, not a layout fault.
- **Ratios that are not known yet.** `PhotoItem::ratio` is persisted, but a photo that has never been decoded has none. Those are laid out square and remembered in `assumed_ratios`; when a decode proves the real shape the day re-flows on the existing debounce timer, so a screenful of decodes costs one re-flow rather than one per photo. Ratios are clamped to 0.4–3.0 so a single panorama cannot decide what a row looks like.
- **Thumbnails survive recycling.** A reply for a tile that has already been re-bound still lands in the texture cache, which is an LRU of 1500 textures keyed by uid, and the rows just outside the realised range are prefetched in the scroll direction.
- **Delete is uid-addressed.** The photos volume is not in the FUSE mount, so the path-based `Request::Delete` cannot reach it. `Request::TrashNodes { uids }` trashes through the SDK and answers `Response::Trashed { trashed, failed }`; the daemon then drops those rows with `Db::photos_delete` (album membership included) so the gallery does not wait for a timeline refresh. The GUI removes the tiles optimistically with an Undo toast and puts back anything the server refused.
- **Remote deletions arrive on their own.** The Drive volume and the photos volume have separate event streams, so `run_photos_event_sync` polls the photos volume with its own cursor (`photos_event_cursor` in the state table). Trash and delete events remove rows; anything else only invalidates freshness, because there is no inode space to converge on this volume. Together with a 60 s `TIMELINE_TTL` and a `RefreshScope::Photos` that now awaits the refresh, a photo deleted on a phone leaves the grid in about ten seconds.
### RAW + JPEG Grouping (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
- **What is grouped and what is not.** `photos_page`, `photos_months` and `photos_counts` count groups, except on the Raw tab, which lists files. Album pages are never grouped: an album is a list someone made. The relation is learned-and-kept like `media_type`, so a refresh that could not resolve a node does not break a group up.
- **Deleting.** `Core::trash_photos` expands each uid to its group before trashing, because the grid shows the shot as one tile — leaving the RAW behind would put the photo back on the Raw tab and nowhere else. The GUI's confirmation names the file count.
- **The other files.** `Request::PhotoGroup { uid }` answers with the group's members as ordinary `PhotoItem`s, which is what the lightbox's switch steps through.
### Persistent SDK Entity Cache (Implemented)
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
@@ -157,6 +179,8 @@ Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`,
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
A restore is expanded before it is sent (`expand_restore`, `pdfs-fuse/src/lib.rs`): the persisted trash listing carries each row's `parent_uid` (schema 29), so a restore takes the connected piece of the trashed tree — every trashed descendant of what was asked for, and every trashed ancestor above it — and sends it shallowest wave first, because the server cannot put a node back under a parent that is still trashed. A uid the listing does not know about (it is materialised in chunks, and can be stale) is restored on its own rather than dropped.
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
-110
View File
@@ -1,110 +0,0 @@
# Search index + prompt display fixes
Working plan. Findings come from an audit of a real `cache.db`
(9,252 Drive nodes, 110,187 local files) on 2026-08-12.
## Evidence
```
nodes 9252
nodes_fts 6547 ← 2705 rows never indexed
nodes in orphan subtrees 2705 ← exactly the missing set
local_files 110187
/home/narl/go/pkg/mod 63399 (58% of the local index)
query "test" 46405 local matches, candidate pool capped at 1000
top 500 by fts rank 367 (73%) from go/pkg/mod
```
`pdfs search tickets` returns only `Documents/Tickets` (My Files); the
`tickets.pdf` sitting in the device folder `Downloads` is invisible.
## P1 — 29% of Drive is unsearchable
`node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs:606`) indexes a node
only when the ancestor walk terminates at a row with `parent_uid IS NULL`. The
DB holds exactly one such row (the My Files root). Device roots live in
`device`/`sync_folder`, never in `nodes`, so every device-folder subtree
(`Downloads`, `Pictures`, `Documents`, `Music`, `Videos` under device
*nthinkpad*) walks up to a `parent_uid` with no row and is judged unindexable.
Stale `pdfs-acceptance-*` roots hit the same path.
- [x] Relax the rule to "the walk terminates and no ancestor is trashed",
keeping the existing cycle guard. A subtree whose top row's parent is
simply not cached is still real, reachable data.
- [x] `MIGRATION_V21`: rebuild `nodes_fts` under the new rule, so existing
installs recover the missing rows instead of waiting for each node to be
upserted again.
- [x] Tests: a node under an uncached parent is indexed; a trashed ancestor
still excludes; the migration backfills.
### Found while testing: a parent cycle hangs the daemon
Writing the cycle test surfaced a live defect, not a test artefact. A query
below the trigram minimum takes the `LIKE` lane, which reads `nodes` directly
and never consults the index, and every row it returns goes through `path_of` —
whose parent walk is an unguarded `UNION ALL`. On a `parent_uid` cycle it never
terminates, while holding the daemon's only SQLite connection.
- [x] Depth-cap `path_of` (`db/utils.rs`), matching the 1024 cap
`path_relative_to` already carried.
- [x] Depth-cap the `descendants` CTE in `upsert_node_tx`, which has the same
exposure on the write side.
- [x] Keep cycles out of the index (part of the P1 rule above).
Still unguarded, not touched here: `pin_is_pinned`'s ancestor walk.
## P2 — local index noise crowds real files out of the candidate pool
`SKIP_DIRS` (`crates/pdfs-core/src/localindex.rs:23`) misses the Go module
cache and build output dirs. `search_v2` clamps candidates to ≤1000 and scores
*after* SQLite, so for any common term the pool is exhausted by vendored files
before a real one is considered.
- [x] Skip `go/pkg/mod` (path-relative, not a bare name), `dist`, `build`,
`.cache`-style artefact dirs.
- [x] Give `search_local_candidates` a name-prefix lane, mirroring what
`search_candidates` already does for Drive, so an exact-prefix match
cannot be starved by trigram rank.
- [x] Tests for both.
## P3 — display lies about where a hit lives
- [x] `Hit::location()` (`crates/pdfs-gui/src/prompt.rs:150`) hardcodes
`My files / …` for every Drive hit. Derive the label from
`mounted_path` when the daemon resolved one (device folder, sync folder)
and fall back to `My files` only for the primary mount.
- [x] Pin rows set `is_dir: pin.recursive` (`dmenu.rs:133`, `prompt.rs:872`) —
a non-recursively pinned folder renders and activates as a file.
- [ ] `pdfs locations` prints `Device folder 1..5` rather than the device and
folder name. (Cosmetic; deferred.)
## Verification
Done, 2026-08-12:
```
cargo fmt --all -- --check clean
cargo clippy --workspace --all-targets -- -D warnings clean
cargo test --workspace --locked 439 passed, 0 failed
```
The V21 backfill was also replayed against a read-only `.backup` of the live
`cache.db`:
```
live_nodes 9276
indexed 9276 (was 6547)
downloads_subtree 300
tickets.pdf -> Downloads/tickets.pdf
```
That run is what caught the last defect: the backfill originally seeded an
uncached-parent root with an empty path, so a node indexed by the migration
(`tickets.pdf`) and the same node after a later upsert (`Downloads/tickets.pdf`)
disagreed. The seed now keeps the name for any row that has a parent, matching
`path_of`.
Still to do, on the live system: rebuild, restart `proton-drive.service`, and
confirm `pdfs search tickets` returns the device folder's `tickets.pdf`. The
schema move to 21 is one-way — an older `pdfs` refuses a v21 database. The
`local_files` shrink (~110k → ~47k) lands with the next background scan.
+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
pkgname=proton-drive-linux
pkgver=1.7.0
pkgver=1.11.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')
url="https://github.com/narl/proton-drive-linux"
url="https://github.com/narrrl/proton-drive-linux"
license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
makedepends=('cargo' 'git')
provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime.
optdepends=('gnome-keyring: credential storage via Secret Service'
'xdg-utils: "Open folder" action from the tray')
'xdg-utils: "Open folder" action from the tray'
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
options=('!lto' '!debug')
# Built in place from the checkout; nothing to fetch or checksum.
@@ -1,11 +1,11 @@
[Desktop Entry]
Type=Application
Name=Proton Drive (Tray)
Name=Proton Drive for Linux (Tray)
GenericName=Cloud Drive Tray
Comment=Proton Drive system tray icon
Comment=Proton Drive status and quick actions (unofficial client)
Exec=pdfs-tray
Icon=io.narl.proton-drive-linux
Terminal=false
Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage;
Keywords=proton;drive;cloud;sync;storage;files;backup;
NoDisplay=true
+3 -3
View File
@@ -1,12 +1,12 @@
[Desktop Entry]
Type=Application
Name=Proton Drive
Name=Proton Drive for Linux
GenericName=Cloud Drive
Comment=Proton Drive files-on-demand for Linux
Comment=Files-on-demand Proton Drive (unofficial client)
Exec=pdfs-app
Icon=io.narl.proton-drive-linux
Terminal=false
Categories=Network;FileTransfer;GTK;
Keywords=proton;drive;cloud;sync;storage;
Keywords=proton;drive;cloud;sync;storage;files;backup;
StartupNotify=true
SingleMainWindow=true
+21 -9
View File
@@ -1,14 +1,26 @@
<?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>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#8b6dff"/>
<stop offset="1" stop-color="#6d4aff"/>
<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 x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/>
<!-- cloud -->
<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) -->
<path d="M64 50v22m0 0l-9-9m9 9l9-9" stroke="#6d4aff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<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>

Before

Width:  |  Height:  |  Size: 718 B

After

Width:  |  Height:  |  Size: 990 B

+10 -4
View File
@@ -12,9 +12,9 @@
Name: proton-drive-linux
Version: %{version}
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
URL: https://github.com/narl/proton-drive-linux
URL: https://github.com/narrrl/proton-drive-linux
ExclusiveArch: x86_64
BuildRequires: cargo
@@ -33,6 +33,7 @@ Requires: gtk4
Requires: libadwaita
Requires: libsecret
Requires: webkitgtk6.0
Requires: perl-Image-ExifTool
Requires: xdg-utils
# DE-specific; do not Require a single desktop environment.
@@ -40,11 +41,16 @@ Recommends: gnome-keyring
Recommends: gnome-shell-extension-appindicator
Recommends: kwallet
# Only for `pdfs-prompt --fzf`; every other front end works without them.
Suggests: fzf
Suggests: foot
Provides: pdfs = %{version}-%{release}
%description
Unofficial Proton Drive client featuring a FUSE files-on-demand mount,
CLI, GTK4/Libadwaita GUI, system tray, and search launcher.
Files-on-demand Proton Drive for the Linux desktop: a FUSE virtual mount with
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
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
+18 -2
View File
@@ -1,17 +1,33 @@
[Unit]
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.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
# Deliberately not Type=notify: the daemon waits for a stored login before it
# mounts anything, and a machine that boots before the user logs in would then
# fail the unit on the start timeout. NotifyAccess=all gets the daemon a
# NOTIFY_SOCKET all the same, which is all the watchdog below needs.
Type=simple
NotifyAccess=all
# The daemon waits for a stored login, mounts ~/ProtonDrive, and keeps it
# mounted. SIGTERM (from `systemctl --user stop`) triggers a clean lazy unmount.
ExecStart=/usr/bin/pdfs daemon
Restart=on-failure
# Always, not on-failure: a daemon that hangs is not a failure by exit code, and
# on 2026-09-18 one had to be restarted by hand after wedging overnight.
Restart=always
RestartSec=3
# The supervisor thread pings this, but only after a round trip over the control
# socket — so a daemon that stops answering its own clients is restarted instead
# of sitting there looking alive. It pings at half this interval.
WatchdogSec=120
# Backstop against unbounded growth: the hung run peaked at 5.2G resident.
# MemoryHigh throttles and reclaims first; MemoryMax is the hard stop, above any
# legitimate working set, so it only fires on a leak.
MemoryHigh=2G
MemoryMax=6G
# Give the lazy unmount time to finish before SIGKILL.
TimeoutStopSec=15