Compare commits

..
Author SHA1 Message Date
nvrl 16281ce5c6 feat(gui): play videos and music in the app
Opening a video or a song in My files, by a double-click or Open, now
plays it in an in-app player window, streamed from the mount as before.
Open With… is the way to another player, and the separate Play item is
gone. A file GStreamer can't play shows the error with Open With….

Both that player and the Photos lightbox share the new widgets/player.rs:
a fullscreen button, fullscreen on a double-click, Repeat in the ⋮ menu,
K to play and pause, J/L or Shift+arrows to jump 10 seconds, M to mute,
and a volume that carries over between videos. The lightbox's thumbnail
strip stays off a video, where it covered the seek bar.
2026-11-14 15:45:44 +01:00
nvrl 74cd6fd2a9 fix(gui): keep paging the timeline when zoomed out onto one screen
Zoomed all the way out, a 200-photo page fits on one screen. With
nothing to scroll, the scroll handler never asked for the next page and
the timeline stopped after the first rows. Ask for more whenever the
content no longer fills a screen and a half, also after a zoom step or
a resize, not only when a page lands.
2026-11-14 15:33:41 +01:00
nvrl 4b669168ee feat(gui): drag photos out of the timeline, upload progress bar
- A drag that starts on a selected tile carries the selection out as a
  text/uri-list. PhotoDrag, a gdk::ContentProvider, downloads the photos
  only when the receiver reads the list, and hands over hard links (or
  copies) under their Drive names in the user cache; copies older than
  a day are pruned. Drag-select steps aside for such a drag, and the
  timeline's own drop target ignores drags from inside the app.
- Uploading several photos shows "Uploading 3 of 12 items…" with a
  progress bar under the Photos header, replacing the start toast.
  Photo uploads report no byte progress from the daemon, so the bar
  counts files; per-tile rings would need that first.
2026-11-14 15:25:26 +01:00
nvrl 41212efdf3 feat(gui,fuse,core): share photos, copy image, thumbnail strip in the viewer
- Share… in the grid's photo menu and the viewer's ⋮ menu opens the Share
  dialog by uid. The daemon accepts a uid from the Photos timeline for
  sharing (resolve_shareable), since the photos volume is in no mount.
  Photos in an album someone else shared are not offered.
- Copy Image (Ctrl+C) puts the shown still on the clipboard. The viewer
  has its own toast overlay, so this and Save a Copy confirm over it
  instead of behind it.
- A thumbnail strip along the viewer's bottom edge, on the gallery model,
  marks the shown photo and jumps on click. Several pictures can now
  wait for one thumbnail, so the strip and the grid tile don't steal it
  from each other.
- PhotoItem carries place_id (serde default); the line under the
  viewer's title reads "Berlin · 12 of 340".
2026-11-14 13:58:25 +01:00
nvrl 5f0b15fe70 fix(gui): a photo viewer bar that reads on any photo
The bar's white-on-translucent-white buttons vanished over a bright photo,
and eight look-alike icons, a floppy disk and an open-document glyph among
them, left the user guessing. The bar now holds the favorite, the details
and a menu with Slideshow, Save a Copy, Open With, Add to Album, Show on
Map and Move to Trash, on dark discs that read over white and black
alike.

A shot stored as more than one file shows a labelled pill ("RAW", "Live",
"2 of 3") instead of a paged icon, lit while the second file is shown.
The title is the localized full date instead of a raw timestamp.

Delete asks first, as the grid does: the dialog opens over the viewer,
and the album prompts can open there too. Alt+Left closes the viewer and
F10 opens the menu; the shortcuts window lists F5, F10 and Alt+Left.
2026-11-14 13:37:50 +01:00
nvrl 5c184013f1 feat(gui): restore folder places and add keyboard Back
Back, Forward and Up in My files restore the folder's scroll offset and
selection as they were when it was left; opening a folder any other way
still starts at the top.

Closing the photo viewer scrolls the grid to the photo it showed last.

Alt+Left and Backspace go back a level in Shared with me and Computers
and leave an open album or place; Escape leaves an album or place too,
after ending selection mode. The shortcuts window lists it.

A fresh Photos load, or leaving the grid for Albums or Places, ends a
scrubber jump that was still paging.
2026-11-14 12:53:27 +01:00
nvrl 6be8a8f9bc fix(gui): keep the user's place when navigating
A folder opened in My files, Shared with me or Computers kept the scroll
offset of the folder before it, because the rows are swapped in place to
keep a refresh steady. Another folder, a new search and a fresh Photos
load (filter, album, place) now start at the top; a refresh still keeps
its place.

Closing a photo opened from the Places map returns to the map instead of
leaving the town's timeline behind, and the pending open is dropped when
the user leaves the town before it loads.

load_gallery no longer swaps Albums or Places for the timeline when a
page lands late (viewer paging, upload, trash Undo); the timeline is
marked stale and read when the user switches back. Coming back to Photos
reuses the timeline unless it is stale, and the timeline returns
filtered the way the kind tabs and date show after an album or place.

A search in My files is no longer replaced by the folder listing on file
changes, F5 or returning to the page. A favorite set in the viewer shows
on its tile at once.
2026-11-14 12:53:04 +01:00
nvrl 57acb0b849 chore(po): refresh source references after the merges 2026-11-14 03:08:17 +01:00
nvrl 68ff8c9e80 Merge remote-tracking branch 'origin/feat/photos-redesign' into release-prep
# Conflicts:
#	docs/BUGS.md
#	docs/CHANGELOG.md
2026-11-14 02:57:11 +01:00
nvrl 4552093c53 Merge remote-tracking branch 'origin/main' into release-prep
# Conflicts:
#	docs/CHANGELOG.md
2026-11-14 02:55:06 +01:00
nvrl f2a9a0f64b feat(gui): Ctrl+scroll changes the icon size in My files
Scrolling with Ctrl held over the icon grid steps the icon size, as it
does in Photos. Touchpad deltas add up to a whole notch before each
step, so a small swipe does not jump through every size.
2026-11-14 02:49:41 +01:00
nvrl e571f22d5d feat(gui,core): larger icon sizes in My files
The icon grid's size slider stops at 256 px instead of 144 px, which is
big enough to tell photos apart by their thumbnail. Thumbnails are
512 px, so they stay sharp at that size on a 2x screen. Names may wrap
wider on the larger tiles.

Ctrl+plus, Ctrl+minus and Ctrl+0 step and reset the size while the grid
is shown, as they do in Photos. The size is saved in the config as
files_view.icon_size once the slider rests; configs without it keep the
72 px default.
2026-11-14 02:48:29 +01:00
nvrl 1c50797772 feat(gui,core): modern Photos layout
Lay the timeline out in justified rows. Each photo keeps its own shape,
clamped between 1:2 and 5:2, and every row but a section's last spans
the width to the pixel. "Square Tiles" in the page menu restores the
even grid, and the Year zoom stays square. The zoom level and the
tile shape persist in a new `photos_view` config section.

Move Timeline/Albums/Places into the header as an icon-and-label
switch that shows only icons in a narrow window. The filters take a
single row whose kind tabs scroll sideways.

Selection mode follows GNOME apps:
- the header shows "N selected" and Cancel;
- an ActionBar along the bottom offers Favorite (or unfavorite), Add
  to Album, Remove from Album, Save Copies and Move to Trash;
- each section heading gets a check that selects the whole section.
One function, sync_header, decides the header for the page, an open
album or place, and selection mode.

The heading of the section at the top of the grid floats over the
grid. Favorites get a star badge, and video placeholders a video
glyph. New bundled icons: pdfs-photos, pdfs-album and pdfs-slideshow.

Upload takes several photos and videos at once, and files dropped on
the timeline upload too. The media type comes from the MIME database,
except that a recording-sized .ts counts as video.

The viewer's details panel moves into an OverlaySplitView that lies
over the photo below 800sp.
2026-11-14 02:40:38 +01:00
nvrl 0183f17435 feat(gui,fuse,core): video thumbnails in file views
My files, Shared, Trash and the launcher now ask the daemon for video
thumbnails, not only image ones. Grid tiles with a video thumbnail show
a small play badge.

A `.ts` file is a video from 1 MiB up and a TypeScript source below
that. One rule in pdfs-core (`is_video_file_name`) decides it for the
file icon, the thumbnail gate, the launcher icon and preview, and the
opener's `@media` / `@text` classes. The daemon also checks the MPEG-TS
sync byte before it runs ffmpeg, so a large TypeScript bundle gets no
retries.

A video thumbnail no longer downloads the whole file into memory (B283):
- the server thumbnail is tried first, at no content cost;
- files up to 24 MiB come whole;
- larger ones bring a 16 MiB head, plus the MP4/MOV `moov` box when it
  lies past the head, into a sparse staging file that ffmpeg reads.
The 256 MiB cap is gone. Without ffmpeg, a video answers "no thumbnail"
at once. The folder thumbnail build includes videos.
2026-11-14 02:17:18 +01:00
nvrl 14a663cee9 fix(fuse): keep pinned files on disk through remote changes
A remote change to a pinned file called `ContentCache::evict`, which does
not check pins, and only the pin request itself ever downloaded anything.
An edit from another device, or even a rename, left the file listed as kept
offline with no copy, so the next read downloaded it or failed without a
network. A file added to a pinned folder elsewhere was never downloaded,
and a folder pin whose downloads failed while offline never retried them.

A remote change no longer evicts a pinned file's copy. The copy is checked
against the revision's (mtime, size), so a newer revision does not read
it, and a rename keeps the mtime (B123), so the copy stays valid. A new
pdfs-pin-keeper thread checks pins again and downloads files that are
missing or stale:

- the pins a batch of remote events changed, after the batch's listings
  are dropped, including a pinned folder that gets a new file;
- every pin at startup, when the link returns, and after the event feed
  loses continuity;
- a file whose version is restored.

Failed downloads retry with a backoff of 30 s to 30 min. Pinning and the
keeper settle a provisional size first: a copy stored under the
ciphertext size never matched the plaintext it holds.

Fixes B282.
2026-11-14 00:20:06 +01:00
nvrl 3f45e89391 fix(fuse): keep files offline in on-demand synced folders
`pdfs pin` and `pdfs unpin` resolved every path against the primary mount
with `rel_to_mount`. An absolute path in an on-demand synced folder such as
~/Pictures was refused as "not under the mountpoint", and a relative path
named a file in My files instead. A synced folder's file is a different
Drive node from a same-named file in My files, so keeping one offline did
nothing for the other, and every program reading the synced copy
downloaded it.

Pin and unpin now route through `route_to_mount`, like ls, refresh, rm and
mv since B86, and run on the mount that owns the path. A path in a mirror
folder gets an error saying its files are already on this computer. A pin
made through an on-demand folder records the absolute local path, so
`pdfs pins`, the prompt and the HUD open the file where it was kept; pins
under the primary mount still record the mount-relative path.

Fixes B281.
2026-11-14 00:20:06 +01:00
nvrl b49631e18f Merge pull request #82 from narrrl/feat/shell-completions
feat(cli,core): shell completions with live values from the service
2026-11-12 11:48:12 +01:00
nvrl ad648ab5cc feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:47:29 +01:00
nvrl f25c2459a0 feat(cli,core): shell completions with live values from the service
`pdfs completions <shell>` prints a completion script for bash, zsh, fish,
elvish or PowerShell, built on clap_complete's dynamic engine: on Tab the
shell calls `pdfs` back, which completes commands and options and also the
values the running service knows, each with a description: paths in My
files, synced-folder and queue ids, computers, trash, albums, items shared
with me, invitations, bookmarks, conflict copies, and a file's versions or
a share's members and public link once its path is typed.

A completer that gets no answer from the service within two seconds offers
nothing rather than hanging the prompt. Listings that ask Proton are kept
for 30 s in $XDG_RUNTIME_DIR (0600), so repeated Tabs do not wait again.
Completion is triggered by PDFS_COMPLETE rather than clap's generic
COMPLETE, because the same binary runs the daemon.

Share roles are now checked by the CLI before the request. The package
recipes install the bash, zsh and fish scripts via packaging/completions.sh.
2026-11-12 11:43:53 +01:00
nvrl ce35a83a04 Merge pull request #79 from narrrl/chore/sdk-0.11.0
build(sdk): bump to 0.11.0
2026-11-12 10:25:00 +01:00
nvrl 762d06765e Merge pull request #80 from narrrl/fix/stale-mount-watchdog-startup
fix(fuse,cli): recover the mount after a watchdog kill and bound thumbnail memory
2026-11-12 10:24:43 +01:00
nvrl 2b95708260 chore(release): 3.8.0 2026-11-12 10:22:04 +01:00
nvrl c78bb529bc fix(fuse): bound memory held by thumbnail decodes
Every image upload decodes the whole file to build its thumbnail, and
nothing limited how many decodes ran at once. With a queue of large
photos all 16 drain workers decoded together; 100-megapixel JPEGs held
about 9 GiB, swapped the machine, and the watchdog killed the daemon on
every restart.

Each decode now takes a share of a process-wide 512 MiB budget, sized
from the encoded length and the header's pixel count. Phone photos still
decode side by side; a photo larger than the budget waits and runs alone.

Records B280.
2026-11-12 00:55:19 +01:00
nvrl 8d824af656 fix(fuse,cli): recover the mount after a watchdog kill
A daemon killed by the watchdog left its FUSE mounts dead. A dead mount
answers ENOTCONN, which create_dir_all took for an absent directory, so
every restart failed with EEXIST and retried forever without reaching the
code that clears stale mounts. mount_once now goes through
prepare_mountpoint, which clears a dead mount first.

Startup on a large account (70 s hydrating 48k nodes) plus the supervisor's
first ping one full period later outlasted WatchdogSec=120, so a restart
that did mount was aborted again. The daemon now asks for a 10-minute
watchdog before startup, the supervisor pings on its first tick, and the
unit's own timeout is restored once the control socket answers.

Records B279.
2026-11-12 00:38:50 +01:00
nvrl a0ab243be6 Merge branch 'main' into chore/sdk-0.11.0 2026-11-09 14:36:49 +01:00
nvrl c77c548d7e Merge pull request #77 from danielebrox/feat/sync-attach-my-files
feat(sync): attach local folders to existing My files folders
2026-11-06 22:58:16 +01:00
nvrl 2930b457d4 fix(docs): exclude SDK review from wiki 2026-11-05 19:18:18 +01:00
nvrl d9da0e2263 build(sdk): bump to 0.11.0 2026-11-05 19:12:21 +01:00
nvrl d4a14a17a8 fix(sync): isolate My files attachments
Schema 45 separates attachments from device backups so stopping sync or
changing mode cannot trash My files folders or evict their local data.
Exclude attachments from device profiles and validate device restores.

Verify attached roots before each pass and cover both local views,
concurrent edits, removal guards, and root loss across daemon restarts.

Refs #77
2026-11-05 17:11:57 +01:00
danielebrox 5ca0272f15 feat(sync): attach local folders to existing My files folders 2026-11-02 19:23:49 +01:00
Nils Pukropp 800705ccb0 chore(aur): update packages to 3.7.0 2026-11-02 15:16:31 +00:00
nvrl 7ab14bb8f7 Merge pull request #76 from narrrl/chore/release-3.7.0
chore(release): 3.7.0
2026-11-02 15:34:08 +01:00
nvrl 9c8b6680a7 chore(release): 3.7.0 2026-11-02 15:18:39 +01:00
nvrl bbd913b87d fix(cli): show the last sync as a time in sync list and devices list
`pdfs sync list` and `pdfs devices list` printed the last sync as epoch
seconds, such as `synced: 1793570850` in issue #46. Both columns now go
through `last_synced`, which formats it like `pdfs activity` and prints
`never` for a folder that never synced. `--json` output is unchanged
(B278).
2026-11-02 15:18:31 +01:00
nvrl 04f4610e4d Merge pull request #75 from narrrl/fix/footer-cant-open
fix(gui): say "Can't open your Drive" in the sidebar while a mount keeps failing
2026-11-01 18:09:25 +01:00
nvrl 0a5afd0b6a Merge pull request #74 from narrrl/docs/verify-3.7.0-run
docs(bugs): mark B276 and B258 checked by the 3.7.0 account run
2026-11-01 18:01:43 +01:00
nvrl 163c54a1e9 fix(gui): say "Can't open your Drive" in the sidebar while a mount keeps failing
The service can run while its mount fails on every try, for example over
a folder with files in it (B277). The page then said "Can't open your
Drive", but the sidebar status read "Not connected / Proton Drive isn't
running", which was wrong. paint_disconnected now reads the same failure
record the pages read, once the service is known to be running, and
shows "Can't open your Drive" with the reason. A reply from the daemon
that lands while it looks wins.
2026-11-01 17:55:52 +01:00
nvrl 0d7c6e7bae docs(bugs): mark B276 and B258 checked by the 3.7.0 account run
The account run of `scripts/fuse-acceptance.sh --journal-check --durability`
on 2026-11-01, against a build of main, passed 464 cases with 103 skipped.
Its eight daemon restarts left no panic in the journal (B276), and
`journalctl -p warning` listed every WARN line the run logged (B258).
2026-11-01 17:47:23 +01:00
nvrl 10252fa84b Merge pull request #73 from narrrl/docs/changelog-3.7.0
docs(changelog): record the changes merged since 3.6.0
2026-11-01 16:33:55 +01:00
nvrl bf9ec0862a docs(changelog): record the changes merged since 3.6.0
Entries for #55 to #65 and #70, which merged without one.
2026-11-01 16:20:18 +01:00
nvrl d0d0c73a51 Merge pull request #72 from narrrl/fix/wipe-guard-majority
fix(fuse): stop a sync pass when nearly all of a large folder vanishes
2026-11-01 16:19:54 +01:00
nvrl 129ad1a50d fix(fuse): stop a sync pass when nearly all of a large folder vanishes
The wipe guards refused a pass only when every synced path was missing
on one side. One surviving file cleared them, so a half-mounted disk,
a restored backup that brought back one file, or a script that emptied
the folder and wrote one back made the next pass trash everything else
on Drive, and on every computer syncing the folder (B270).

Both guards now also refuse when nine in ten or more of the tracked
paths of a folder with at least 20 are missing at once. A smaller
folder still stops only on a total loss, since an ordinary cleanup can
delete most of it. The reason now gives the count.

B270 stays partly fixed: a smaller loss still goes through, and the
app does not yet offer to confirm the deletions.
2026-11-01 16:06:08 +01:00
nvrl 506a610273 Merge pull request #71 from narrrl/fix/refuse-non-empty-mountpoint
fix(core,fuse,cli,gui): refuse to mount My files over a folder with files in it
2026-11-01 16:05:38 +01:00
nvrl 5048492a83 fix(core,fuse,cli,gui): refuse to mount My files over a folder with files in it
FUSE mounts over a non-empty folder without complaint, so files already
in the My files folder were hidden under the mount: not uploaded, and
back only once the service stopped, so they looked deleted (#67, B277).

- pdfs-core: new `mountpoint` module. `not_empty` lists what is in a
  folder (hidden entries count); `mounted_fs_type` moves here from the
  CLI so the daemon can use it. `MountFailure` gains an optional
  `not_empty` field, which older readers ignore.
- pdfs-fuse: `mount_with` checks the folder before the root fetch and
  before any worker starts. A dead mount is cleared first; one of our
  own live mounts is left to `spawn_session`, as before.
- pdfs-cli: `cmd_daemon` records the folder in the failure record. The
  refusal backs off and retries like any mount failure, so emptying the
  folder is enough.
- pdfs-gui: the status pages explain the refusal, and a dialog offers
  to open the folder or choose another one, once per run of failures.
  The folder picker refuses a folder with files in it.

Translated in all catalogs. Docs: BUGS (B277), CHANGELOG, FAQ,
TROUBLESHOOTING, CONFIGURATION.
2026-11-01 15:30:28 +01:00
nvrl 321547fb77 Merge pull request #70 from narrrl/chore/deps-2026-11
chore(deps): update dependencies, bump parking_lot, rusqlite, gettext-rs
2026-11-01 15:11:09 +01:00
nvrl a06a8150fb chore(deps): update dependencies, bump parking_lot, rusqlite, gettext-rs
Refresh Cargo.lock to the newest compatible versions and move three
crates to their next minor release:

- parking_lot 0.12 -> 0.13
- rusqlite 0.40 -> 0.41 (newer bundled SQLite)
- gettext-rs 0.7 -> 0.8

gettext-rs 0.8 marks setlocale as unsafe; the three calls in i18n.rs
run before any thread starts, the same precondition the set_var calls
next to them already state.

rustc 1.99 deprecates AtomicX::fetch_update in favour of try_update
(stable since 1.95, below the 1.96 minimum), which failed
`clippy -D warnings` on main. Rename the two uses so clippy passes
without `-A deprecated`.

The open Dependabot alerts for zip (< 9.0.5) and rustls (< 0.23.46)
are already resolved on main by #69 (zip 9.2.0, rustls 0.23.46).
2026-11-01 13:55:32 +01:00
nvrl c4bce50d35 Merge pull request #52 from narrrl/fix/create-landing-counted
fix(fuse,core): count a new file's landing until its bytes are cached
2026-11-01 12:06:39 +01:00
nvrl d2b5916b99 Merge remote-tracking branch 'origin/main' into fix/create-landing-counted 2026-11-01 11:28:03 +01:00
nvrl 4626ce17a7 Merge pull request #69 from narrrl/dependabot/cargo/cargo-f3dd2e6b0d
build(deps): bump the cargo group across 1 directory with 2 updates
2026-11-01 11:27:45 +01:00
nvrl 630f316fe6 Merge pull request #65 from narrrl/fix/clean-stop-runtime
fix(cli): leave the runtime running on a stop so stragglers cannot panic
2026-11-01 11:27:41 +01:00
nvrl 801681f747 Merge remote-tracking branch 'origin/main' into fix/create-landing-counted
# Conflicts:
#	docs/BUGS.md
2026-11-01 11:14:15 +01:00
nvrl e7382f2c7e test(core): name zip's options type the way zip 9 accepts 2026-11-01 11:12:17 +01:00
dependabot[bot] 7046f0f3ef build(deps): bump the cargo group across 1 directory with 2 updates
Bumps the cargo group with 2 updates in the / directory: [zip](https://github.com/zip-rs/zip2) and [rustls](https://github.com/rustls/rustls).


Updates `zip` from 8.6.0 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v8.6.0...v9.0.5)

Updates `rustls` from 0.23.45 to 0.23.46
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.45...v/0.23.46)

---
updated-dependencies:
- dependency-name: zip
  dependency-version: 9.0.5
  dependency-type: direct:production
  dependency-group: cargo
- dependency-name: rustls
  dependency-version: 0.23.46
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 23:39:08 +00:00
nvrl 37e8dae61b Merge pull request #54 from narrrl/docs/bugs-issue-audit
docs(bugs): record the audit of issues #53, #46, #40 and #38
2026-10-31 19:44:18 +01:00
nvrl c52f2098f2 Merge pull request #64 from narrrl/chore/sdk-0.10.1
chore(deps): bump proton-sdk and proton-drive-rs to 0.10.1
2026-10-31 19:40:34 +01:00
nvrl f4a2674b9c Merge pull request #63 from narrrl/fix/sim-flakes-and-open-race
fix(fuse): retry a write open whose queued revision landed under the copy
2026-10-31 19:40:24 +01:00
nvrl 347861bbc1 Merge pull request #62 from narrrl/fix/restore-outcome-and-walk-gaps
fix(fuse,cli): keep a pass going past a failed listing, report skipped restores
2026-10-31 19:40:14 +01:00
nvrl c57763b07f Merge pull request #61 from narrrl/fix/diagnose-coverage
fix(cli): have diagnose report failing writes, a pause and folders in error
2026-10-31 19:40:04 +01:00
nvrl 846dfd8d14 Merge pull request #60 from narrrl/fix/db-errors-not-offline
fix(core): read database errors handed to from_api as internal, not offline
2026-10-31 19:39:53 +01:00
nvrl 2ae99de844 Merge pull request #59 from narrrl/fix/mount-failure-surfaced
fix(cli,core,fuse,gui): say why the mount keeps failing, and back off
2026-10-31 19:39:44 +01:00
nvrl 69ff216e7d Merge pull request #58 from narrrl/fix/never-synced-label
fix(gui): say a folder is waiting for its first sync, not up to date
2026-10-31 19:39:34 +01:00
nvrl b3ea564c6b docs(bugs): keep the index newest first 2026-10-31 19:29:29 +01:00
nvrl 463c97a14d Merge branch 'chore/sdk-0.10.1' into fix/clean-stop-runtime
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:29:05 +01:00
nvrl 40aa8016ea Merge branch 'fix/sim-flakes-and-open-race' into chore/sdk-0.10.1
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:47 +01:00
nvrl d1b7535696 Merge branch 'fix/restore-outcome-and-walk-gaps' into fix/sim-flakes-and-open-race
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:44 +01:00
nvrl ba414c4446 Merge branch 'fix/diagnose-coverage' into fix/restore-outcome-and-walk-gaps
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:41 +01:00
nvrl 5b7f17bdaf Merge branch 'fix/db-errors-not-offline' into fix/diagnose-coverage
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:38 +01:00
nvrl 73068f5868 Merge branch 'fix/mount-failure-surfaced' into fix/db-errors-not-offline
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:36 +01:00
nvrl b093010b64 Merge branch 'fix/never-synced-label' into fix/mount-failure-surfaced
# Conflicts:
#	docs/BUGS.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
2026-10-31 19:26:34 +01:00
nvrl ab8fe0e61d Merge branch 'fix/sync-scheduling-watches' into fix/never-synced-label
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:26:28 +01:00
nvrl b8295373f7 Merge remote-tracking branch 'origin/docs/bugs-issue-audit' into fix/sync-scheduling-watches
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:21:21 +01:00
nvrl 5bc0b5c58a Merge pull request #56 from narrrl/fix/sync-engine-containment
fix(fuse,core,cli): keep a panicking sync pass from ending the engine
2026-10-31 19:20:04 +01:00
nvrl 73af39ee65 Merge remote-tracking branch 'origin/docs/bugs-issue-audit' into fix/sync-engine-containment
# Conflicts:
#	docs/BUGS.md
2026-10-31 19:11:41 +01:00
nvrl 48aaad23ce Merge pull request #55 from narrrl/fix/journald-priority
fix(cli): give journald each log line's priority
2026-10-31 19:09:37 +01:00
nvrl 176112a9cb fix(cli): leave the runtime running on a stop so stragglers cannot panic
A stop shut the tokio runtime down after joining the workers it owns.
Threads it never joins, such as a size lookup or a profile backup that
a request started during the stop, then panicked on their next timer
with "A Tokio 1.x context was found, but it is being shutdown". The
process exits right after a stop, so the runtime is now left for the
exit to end. A mount that is retried still shuts its runtime down with
the 2 s limit.

The acceptance journal check now counts panics, and no longer counts
the ENOENT reply errors a stop causes by aborting its FUSE connections.

docs/BUGS.md B276.
2026-10-31 19:09:23 +01:00
nvrl 7957e1ed59 chore(deps): bump proton-sdk and proton-drive-rs to 0.10.1
0.10.1 decrypts address key tokens with every account key that opens,
not only the active ones, and leaves out an address whose keys do not
open instead of failing the account. That is the likely cause of issue
#40, a mount that retried "missing key" forever (B265).
2026-10-31 13:48:01 +01:00
nvrl 53543c91f9 docs(bugs): record the slow-link conflict copy seen in CI (B275) 2026-10-31 13:16:47 +01:00
nvrl fccb3d9561 fix(fuse): retry a write open whose queued revision landed under the copy
A write open copies the queued revision's blob into its scratch file.
When the upload landed between the sample and the copy, the drain had
already deleted the blob, and the open failed with EIO (B274, seen once
in CI). The drain drops the pending entry before the blob, so a failed
copy whose entry moved on now goes around again, as the re-check after
the copy already does.

Two simulations raced the daemon rather than it:
- a_file_deleted_after_a_restart_leaves_drive_with_what_its_create_sent
  let the create's reply through before the kernel's release attached
  the second bytes. That attach resets the attempts, so the retry
  landed before the test could see the failure. It now waits for the
  bytes to be attached.
- a_file_deleted_offline_while_open_still_reads_and_takes_writes read
  the cache as the queue emptied, which is a moment before the
  eviction. It now waits for the cache to empty.
2026-10-31 12:58:11 +01:00
nvrl 6f2b4bb80a fix(fuse,cli): keep a pass going past a failed listing, report skipped restores
A listing that failed anywhere in the remote walk ended the whole pass,
so a first sync of a large folder on a flaky link walked the tree again
and again and never downloaded anything (B271, issue #46). A failed
listing below the root now records that folder as a gap: nothing under
it is deleted, the rest of the pass applies, and the report names it.

A restore that skipped every folder was logged as a successful activity
(B269). The daemon now gets the restored and skipped folders back, logs
any skip as a failed activity with the reasons, and sends the Rewatch
for folders already restored when the database insert fails partway.
`pdfs devices join` points to `pdfs activity` for the outcome.
2026-10-31 11:11:01 +01:00
nvrl a617b2b410 fix(cli): have diagnose report failing writes, a pause and folders in error
diagnose dropped most of the Status reply and never looked at the synced
folders, so it said "No problems found" while a folder would not sync
(B268, issue #53). It now warns about writes that keep failing, a write
a day old, a pause, folders in error or conflict, and a control socket
nothing listens on.
2026-10-31 10:57:34 +01:00
nvrl bdbfc0bfd4 fix(core): read database errors handed to from_api as internal, not offline
from_api took every error that was not a ProtonError for a transport
failure. Pin, unpin, the searches and node loads hand it pdfs_core
errors from the cache and the database, so a failure there said
"You're offline" (B266, the rest of B205). The source chain now
decides, and only an error with neither type reads as offline.
2026-10-31 10:53:25 +01:00
nvrl e9577f953f fix(cli,core,fuse,gui): say why the mount keeps failing, and back off
A mount that failed on every try retried every 5 s forever, and the
reason was only in the journal: the app and the tray said Connecting...
for days (B263, issue #40). The retry now doubles to a 2-minute ceiling,
and the last failure is kept in mount-failure.json for the app, the
tray, pdfs diagnose and systemctl status.

The early returns in mount left the workers they had started running
beside the next attempt (B264). They are now stopped on every error
path, without telling systemd the daemon is stopping.
2026-10-31 10:49:01 +01:00
nvrl 437d14b0f5 fix(gui): say a folder is waiting for its first sync, not up to date
A new sync folder row starts with state idle, so the app called a folder
up to date before any of it reached Drive (B261, issue #53). A mirror
folder that has never synced now reads "waiting for first sync", and
the This computer row counts such folders. All catalogs translated.
2026-10-31 10:30:52 +01:00
nvrl b40ca797d9 fix(fuse): run a burst's named folders first and retry failed watches
A poll in the same burst as a new folder's Reconcile(id) took its place,
so the new folder waited behind a pass over every other one (B260).

A recursive watch that failed partway left its watches registered, was
never retried, and its events ran passes over every folder (B262). It
is now unwatched and retried every 10 minutes, symlinks are not followed,
and watcher errors are logged. A folder removed and added again in one
burst keeps its watch under the new id (B272).
2026-10-31 10:24:32 +01:00
nvrl c64bb23e3c fix(fuse,core,cli): keep a panicking sync pass from ending the engine
One thread runs every synced folder's passes. A panic in a pass ended
it, and every later add, sync-now, file event and poll went nowhere
without a word until a restart, which fits issue #53 (B259).

Each pass now runs under catch_unwind; a panic sets the folder to error
and the next poll tries again. Diagnostics reports the engine's state,
the supervisor warns when it has ended or a pass has gone quiet, and
first passes log at info.
2026-10-31 10:14:23 +01:00
nvrl 639ca359fa fix(fuse,core): count a new file's landing until its bytes are cached
A create that carries a file's bytes retired its op, then cached the
bytes and gave the tree the real uid. Nothing counted that window, so
`pdfs status` read empty while it ran, which B119 promises means the
file reads without the network.

A delete in that window found the placeholder uid in the tree and
trashed the node by its real uid. `queue_trash` asked the tree whether
that uid was open, found no entry, and dropped the cached bytes the open
handle reads. If the cache store ran at the same moment, its tag was
written after the eviction and never removed. CI caught that as a
stray .meta file.

The create's landing is now counted like an upload's, from the op's
retirement until the tree has the real uid. The delete and the replace
pass whether they found the file open. Opening the cache removes a tag
whose bytes are gone, which also covers a crash between an eviction's
two removals. The simulations wait for `Daemon::drained`, which counts
the landing, before they go offline.

B255 and B256 move to fixed: the account run checked both.
2026-10-27 14:44:34 +01:00
116 changed files with 21261 additions and 10181 deletions

No files matched your search

+1
View File
@@ -160,6 +160,7 @@ jobs:
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale
packaging/completions.sh target/release/pdfs deb/usr/share
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
Generated
+419 -406
View File
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "3.6.0"
version = "3.8.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,9 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.10.0" }
proton-drive-rs = { version = "0.10.0" }
proton-sdk = { version = "0.11.0" }
proton-drive-rs = { version = "0.11.0" }
chrono = { version = "0.4", default-features = false, features = ["std"] }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -33,7 +34,7 @@ anyhow = "1"
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
# the recovered worker needs.
parking_lot = "0.12"
parking_lot = "0.13"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "6"
@@ -41,7 +42,7 @@ ignore = "0.4"
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"] }
zip = { version = "9", 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"
@@ -49,13 +50,16 @@ sha1 = "0.11"
# 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.40", features = ["bundled"] }
rusqlite = { version = "0.41", 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"] }
# `unstable-dynamic`: Tab calls `pdfs` back, so arguments complete from the
# running service. Only `pdfs-cli/src/complete.rs` uses it.
clap_complete = { version = "4.6", features = ["unstable-dynamic"] }
rpassword = "7"
async-trait = "0.1"
futures = "0.3"
+1 -1
View File
@@ -125,7 +125,7 @@ The documentation is in [`docs/`](docs/README.md) and on the
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
| [FAQ](docs/FAQ.md) | Short answers to common questions |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
| [Command-line reference](docs/CLI.md) | Every `pdfs` command, Tab completion and scripting with `--json` |
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
+1
View File
@@ -21,6 +21,7 @@ anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
clap_complete.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+731
View File
@@ -0,0 +1,731 @@
//! Shell completion for `pdfs`.
//!
//! Built on clap_complete's dynamic engine: the script a shell loads is a thin
//! stub that calls `pdfs` back on every Tab with the words typed so far, and
//! `pdfs` answers from its own clap definition. That lets an argument complete
//! from the running service as well as from the command tree — `pdfs sync rm
//! <Tab>` offers the synced-folder ids with their paths, `pdfs pin Doc<Tab>`
//! walks My files.
//!
//! Every value that comes from the service is best effort. With no service, a
//! slow one or an error, a completer offers nothing: a Tab that hangs or prints
//! an error into the prompt is worse than one that does nothing.
//!
//! The engine sits behind clap_complete's `unstable-dynamic` feature, so all of
//! this crate's use of it is kept to this module.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::CommandFactory;
use clap_complete::engine::{CompletionCandidate, PathCompleter, ValueCompleter};
use clap_complete::env::{Bash, CompleteEnv, Elvish, EnvCompleter, Fish, Powershell, Zsh};
use pdfs_core::control::{
AlbumInfo, BookmarkInfo, ConflictInfo, DeviceInfo, DirEntry, InvitationInfo, PendingOpInfo,
PublicLinkInfo, Request, Response, RevisionInfo, ShareEntry, ShareEntryKind, SyncFolderInfo,
};
/// The environment variable that turns a run of `pdfs` into a completion
/// request. Not clap's generic `COMPLETE`: the same binary is the systemd
/// daemon, and an unrelated `COMPLETE` in its environment must not stop it.
pub const VAR: &str = "PDFS_COMPLETE";
/// How long a completer waits for the service. A Tab is interactive, so a
/// listing the service cannot give soon is better skipped. Long enough for the
/// listings that ask Proton (computers take over a second).
const SERVICE_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a listing that asks Proton is reused. Every Tab is a new `pdfs`, so
/// without it `pdfs devices rm <Tab>`, a letter and another Tab would wait for
/// Proton twice. A uid that went away in the meantime only fails the command.
const CACHE_TTL: Duration = Duration::from_secs(30);
/// Answer a completion request and exit, if this run is one. Must run before
/// anything writes to stdout.
pub fn handle_request() {
CompleteEnv::with_factory(crate::Cli::command)
.var(VAR)
.complete();
}
/// The shells `pdfs completions` writes a script for.
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ShellArg {
Bash,
Zsh,
Fish,
Elvish,
Powershell,
}
impl ShellArg {
fn completer(self) -> &'static dyn EnvCompleter {
match self {
ShellArg::Bash => &Bash,
ShellArg::Zsh => &Zsh,
ShellArg::Fish => &Fish,
ShellArg::Elvish => &Elvish,
ShellArg::Powershell => &Powershell,
}
}
}
/// Write the script that registers `pdfs` completion with `shell`.
///
/// The script calls `pdfs` from `PATH`, not this binary's path, so one written
/// at packaging time keeps working once installed.
pub fn write_script(shell: ShellArg, out: &mut dyn std::io::Write) -> std::io::Result<()> {
shell
.completer()
.write_registration(VAR, "pdfs", "pdfs", "pdfs", out)?;
if let ShellArg::Zsh = shell {
// Installed as `_pdfs` in site-functions, the script is autoloaded as
// that function's body on the first Tab. The body above only defines
// and registers the completer, so this line also runs it, or the first
// Tab would complete nothing. Sourced, `funcstack` names no `_pdfs`.
writeln!(
out,
"if [ \"$funcstack[1]\" = \"_pdfs\" ]; then\n _clap_dynamic_completer_pdfs \"$@\"\nfi"
)?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// Talking to the service
/// One request to the service, or `None` if there is no answer worth showing.
fn ask(request: Request) -> Option<Response> {
let socket = pdfs_core::config::AppDirs::new().ok()?.control_socket();
ask_at(&socket, &request)
}
fn ask_at(socket: &Path, request: &Request) -> Option<Response> {
match pdfs_core::control::send_with_timeout(socket, request, SERVICE_TIMEOUT) {
Ok(Response::Error { .. }) | Err(_) => None,
Ok(response) => Some(response),
}
}
/// [`ask`] for a listing that comes from Proton rather than the local
/// database, reusing an answer younger than [`CACHE_TTL`]. `key` names the
/// listing's cache file.
fn ask_cached(key: &str, request: Request) -> Option<Response> {
let file = cache_file(key);
if let Some(hit) = file.as_deref().and_then(read_fresh) {
return Some(hit);
}
let response = ask(request)?;
if let Some(file) = &file {
write_cache(file, &response);
}
Some(response)
}
/// In `$XDG_RUNTIME_DIR`: private to the user, in memory and gone at logout,
/// which suits names from the account. With no runtime dir, nothing is cached.
fn cache_file(key: &str) -> Option<PathBuf> {
let runtime = PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?);
Some(
runtime
.join("proton-drive-linux")
.join(format!("completion-{key}.json")),
)
}
fn read_fresh(file: &Path) -> Option<Response> {
let age = std::fs::metadata(file)
.ok()?
.modified()
.ok()?
.elapsed()
.ok()?;
if age > CACHE_TTL {
return None;
}
serde_json::from_slice(&std::fs::read(file).ok()?).ok()
}
/// Best effort: a cache that cannot be written only costs the next Tab a wait.
fn write_cache(file: &Path, response: &Response) {
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
let (Some(dir), Ok(json)) = (file.parent(), serde_json::to_vec(response)) else {
return;
};
let _ = std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir);
let staging = file.with_extension(format!("{}.tmp", std::process::id()));
let written = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&staging)
.and_then(|mut f| std::io::Write::write_all(&mut f, &json));
if written.is_err() || std::fs::rename(&staging, file).is_err() {
let _ = std::fs::remove_file(&staging);
}
}
fn candidate(value: impl Into<OsString>, help: impl Into<String>) -> CompletionCandidate {
let help = help.into();
let candidate = CompletionCandidate::new(value);
if help.is_empty() {
candidate
} else {
candidate.help(Some(help.into()))
}
}
// ---------------------------------------------------------------------------
// Paths in Proton Drive
/// A path in the mount: an absolute path completes from the local filesystem
/// (through the mount), anything else from the service's listing of My files,
/// which is what the commands resolve a relative path against.
pub fn drive_path(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, false)
}
/// [`drive_path`] for an argument that must be a folder.
pub fn drive_dir(current: &OsStr) -> Vec<CompletionCandidate> {
complete_drive(current, true)
}
/// `pdfs refresh`: a folder, or one of the two named caches.
pub fn refresh_target(current: &OsStr) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
let mut candidates: Vec<_> = [
("trash", "the trash listing"),
("photos", "the photos timeline"),
]
.into_iter()
.filter(|(name, _)| name.starts_with(typed.as_ref()))
.map(|(name, help)| candidate(name, help))
.collect();
candidates.extend(drive_dir(current));
candidates
}
fn complete_drive(current: &OsStr, dirs_only: bool) -> Vec<CompletionCandidate> {
let Some(word) = current.to_str() else {
return Vec::new();
};
if word.starts_with('/') || word.starts_with('~') {
let local = if dirs_only {
PathCompleter::dir()
} else {
PathCompleter::any()
};
return local.complete(current);
}
let (parent, prefix) = split_drive_word(word);
let listing = Request::ListDir {
path: parent.trim_end_matches('/').to_owned(),
};
match ask(listing) {
Some(Response::Entries { entries }) => {
drive_candidates(parent, prefix, &entries, dirs_only)
}
_ => Vec::new(),
}
}
/// Split a typed drive path into the folder to list (with its trailing `/`)
/// and the start of the name being completed in it.
fn split_drive_word(word: &str) -> (&str, &str) {
match word.rfind('/') {
Some(at) => word.split_at(at + 1),
None => ("", word),
}
}
/// The entries of `parent` whose name starts with `prefix`. A folder ends in
/// `/`, which tells every shell not to add a space after it, so the next Tab
/// goes on into it.
fn drive_candidates(
parent: &str,
prefix: &str,
entries: &[DirEntry],
dirs_only: bool,
) -> Vec<CompletionCandidate> {
entries
.iter()
.filter(|e| e.name.starts_with(prefix) && (e.is_dir || !dirs_only))
.map(|e| {
let slash = if e.is_dir { "/" } else { "" };
CompletionCandidate::new(format!("{parent}{}{slash}", e.name))
.hide(e.name.starts_with('.') && !prefix.starts_with('.'))
})
.collect()
}
// ---------------------------------------------------------------------------
// Ids and uids from the service's listings
pub fn sync_folder_id() -> Vec<CompletionCandidate> {
match ask(Request::ListSyncFolders) {
Some(Response::SyncFolders { items }) => sync_folder_candidates(&items),
_ => Vec::new(),
}
}
fn sync_folder_candidates(items: &[SyncFolderInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|f| candidate(f.id.to_string(), format!("{} ({})", f.local_path, f.mode)))
.collect()
}
pub fn queue_id() -> Vec<CompletionCandidate> {
match ask(Request::ListPendingOps) {
Some(Response::PendingOps { items }) => queue_candidates(&items),
_ => Vec::new(),
}
}
fn queue_candidates(items: &[PendingOpInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|op| candidate(op.id.to_string(), format!("{} {}", op.kind, op.path)))
.collect()
}
pub fn device_uid() -> Vec<CompletionCandidate> {
match ask_cached("devices", Request::ListDevices) {
Some(Response::Devices { items }) => device_candidates(&items),
_ => Vec::new(),
}
}
fn device_candidates(items: &[DeviceInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|d| {
let this = if d.this_device {
" (this computer)"
} else {
""
};
candidate(d.uid.as_str(), format!("{}{this}", d.name))
})
.collect()
}
pub fn trash_uid() -> Vec<CompletionCandidate> {
match ask_cached("trash", Request::ListTrash) {
Some(Response::Entries { entries }) => trash_candidates(&entries),
_ => Vec::new(),
}
}
fn trash_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = match e.trashed_from.as_deref() {
Some(from) if !from.is_empty() => format!("{} (from {from})", e.name),
_ => e.name.clone(),
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn album_uid() -> Vec<CompletionCandidate> {
match ask_cached("albums", Request::PhotoAlbums) {
Some(Response::Albums { items, .. }) => album_candidates(&items),
_ => Vec::new(),
}
}
fn album_candidates(items: &[AlbumInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|a| candidate(a.uid.as_str(), a.name.as_str()))
.collect()
}
pub fn shared_uid() -> Vec<CompletionCandidate> {
match ask_cached("shared-with-me", Request::ListSharedWithMe) {
Some(Response::Entries { entries }) => shared_candidates(&entries),
_ => Vec::new(),
}
}
fn shared_candidates(entries: &[DirEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
let help = if e.shared_by.is_empty() {
e.name.clone()
} else {
format!("{} (from {})", e.name, e.shared_by)
};
candidate(e.uid.as_str(), help)
})
.collect()
}
pub fn invitation_id() -> Vec<CompletionCandidate> {
match ask_cached("invitations", Request::ListInvitations) {
Some(Response::Invitations { items }) => invitation_candidates(&items),
_ => Vec::new(),
}
}
fn invitation_candidates(items: &[InvitationInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|i| {
let help = match i.name.as_deref() {
Some(name) => format!("{name} from {}", i.inviter_email),
None => format!("from {}", i.inviter_email),
};
candidate(i.id.as_str(), help)
})
.collect()
}
pub fn bookmark_token() -> Vec<CompletionCandidate> {
match ask_cached("bookmarks", Request::ListBookmarks) {
Some(Response::Bookmarks { items }) => bookmark_candidates(&items),
_ => Vec::new(),
}
}
fn bookmark_candidates(items: &[BookmarkInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|b| candidate(b.token.as_str(), b.name.as_deref().unwrap_or(&b.url)))
.collect()
}
pub fn conflict_path() -> Vec<CompletionCandidate> {
match ask(Request::ListConflicts) {
Some(Response::Conflicts { items }) => conflict_candidates(&items),
_ => Vec::new(),
}
}
fn conflict_candidates(items: &[ConflictInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|c| candidate(c.path.as_str(), format!("copy of {}", c.original_path)))
.collect()
}
// ---------------------------------------------------------------------------
// Ids that belong to the path typed before them
/// A version of the file named earlier on the line (`pdfs versions restore
/// <path> <Tab>`).
pub fn version_id(current: &OsStr) -> Vec<CompletionCandidate> {
let Some(path) = typed_path() else {
return Vec::new();
};
match ask(Request::ListRevisions { path }) {
Some(Response::Revisions { items }) => starting_with(current, revision_candidates(&items)),
_ => Vec::new(),
}
}
fn revision_candidates(items: &[RevisionInfo]) -> Vec<CompletionCandidate> {
items
.iter()
.map(|r| {
let current = if r.is_active { ", current" } else { "" };
let size = r.claimed_size.unwrap_or(r.size_on_storage).max(0);
candidate(
r.id.as_str(),
format!(
"{}, {}{current}",
crate::format_epoch_utc(r.created),
crate::human_bytes(size as u64)
),
)
})
.collect()
}
/// A member or invitation on the share of the path typed earlier on the line.
pub fn share_entry_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((entries, _)) => starting_with(current, share_entry_candidates(&entries)),
None => Vec::new(),
}
}
fn share_entry_candidates(entries: &[ShareEntry]) -> Vec<CompletionCandidate> {
entries
.iter()
.map(|e| {
// The kind is the next argument, so the help names it in the words
// that argument takes.
let kind = match e.kind {
ShareEntryKind::Member => "member",
ShareEntryKind::ProtonInvite => "proton",
ShareEntryKind::ExternalInvite => "external",
};
candidate(e.id.as_str(), format!("{} ({}, {kind})", e.email, e.role))
})
.collect()
}
/// The public link of the path typed earlier on the line.
pub fn public_link_id(current: &OsStr) -> Vec<CompletionCandidate> {
match share_of_typed_path() {
Some((_, Some(link))) => starting_with(current, vec![public_link_candidate(&link)]),
_ => Vec::new(),
}
}
/// Described without its URL: the fragment is the link's password, and a Tab
/// menu is on screen for anyone watching.
fn public_link_candidate(link: &PublicLinkInfo) -> CompletionCandidate {
let help = match link.expires {
Some(expires) => format!(
"{} link, expires {}",
link.role,
crate::format_epoch_utc(expires)
),
None => format!("{} link", link.role),
};
candidate(link.id.as_str(), help)
}
fn share_of_typed_path() -> Option<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
match ask(Request::ListShare {
path: typed_path()?,
})? {
Response::Share { entries, link } => Some((entries, link)),
_ => None,
}
}
/// [`clap_complete::engine::ArgValueCompleter`] candidates are not filtered by
/// the engine, unlike [`clap_complete::engine::ArgValueCandidates`].
fn starting_with(
current: &OsStr,
candidates: Vec<CompletionCandidate>,
) -> Vec<CompletionCandidate> {
let typed = current.to_string_lossy();
candidates
.into_iter()
.filter(|c| c.get_value().to_string_lossy().starts_with(typed.as_ref()))
.collect()
}
/// The `path` argument of the command being completed, as the service takes
/// it. A completer only receives the word under the cursor, so this reads the
/// line from the process arguments, where the shell's stub passes it.
fn typed_path() -> Option<String> {
let args: Vec<OsString> = std::env::args_os().collect();
let index = std::env::var("_CLAP_COMPLETE_INDEX")
.ok()
.and_then(|i| i.parse().ok());
path_in(words_before_cursor(&args, index))
}
/// The words of the line before the one being completed. The stub runs
/// `pdfs -- <words>`; bash, zsh and elvish say which word the cursor is on,
/// fish and PowerShell pass the line cut at the cursor, so it is the last.
fn words_before_cursor(args: &[OsString], index: Option<usize>) -> Vec<OsString> {
let start = args
.iter()
.position(|a| a == "--")
.map_or(args.len(), |at| at + 1);
let mut words = args[start..].to_vec();
let index = index.unwrap_or(words.len().saturating_sub(1));
words.truncate(index);
words
}
/// Parse `words` as far as they go and take the innermost command's `path`.
fn path_in(words: Vec<OsString>) -> Option<String> {
let matches = crate::Cli::command()
.ignore_errors(true)
.try_get_matches_from(words)
.ok()?;
let mut leaf = &matches;
while let Some((_, sub)) = leaf.subcommand() {
leaf = sub;
}
let path = leaf.try_get_one::<PathBuf>("path").ok().flatten()?;
crate::path_arg(path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn values(candidates: &[CompletionCandidate]) -> Vec<String> {
candidates
.iter()
.map(|c| c.get_value().to_string_lossy().into_owned())
.collect()
}
fn help(candidate: &CompletionCandidate) -> String {
candidate
.get_help()
.map(ToString::to_string)
.unwrap_or_default()
}
fn entry(name: &str, is_dir: bool) -> DirEntry {
serde_json::from_value(serde_json::json!({
"name": name,
"is_dir": is_dir,
"size": 0,
"modified": 0,
"pinned": false,
"uid": format!("vol~{name}"),
}))
.unwrap()
}
fn words(line: &[&str]) -> Vec<OsString> {
line.iter().map(OsString::from).collect()
}
#[test]
fn the_command_definition_is_valid() {
crate::Cli::command().debug_assert();
}
#[test]
fn completions_script_is_written_for_every_shell() {
for shell in [
ShellArg::Bash,
ShellArg::Zsh,
ShellArg::Fish,
ShellArg::Elvish,
ShellArg::Powershell,
] {
let mut script = Vec::new();
write_script(shell, &mut script).unwrap();
let script = String::from_utf8(script).unwrap();
assert!(script.contains(VAR), "{script}");
assert!(script.contains("pdfs"), "{script}");
}
}
#[test]
fn drive_path_splits_parent_and_prefix() {
assert_eq!(split_drive_word(""), ("", ""));
assert_eq!(split_drive_word("Doc"), ("", "Doc"));
assert_eq!(split_drive_word("Documents/"), ("Documents/", ""));
assert_eq!(split_drive_word("Documents/Rep"), ("Documents/", "Rep"));
assert_eq!(split_drive_word("a/b/c"), ("a/b/", "c"));
}
#[test]
fn drive_dirs_end_in_a_slash() {
let entries = [
entry("Reports", true),
entry("Receipt.pdf", false),
entry("Other", true),
];
let all = drive_candidates("Documents/", "Re", &entries, false);
assert_eq!(
values(&all),
["Documents/Reports/", "Documents/Receipt.pdf"]
);
let dirs = drive_candidates("Documents/", "", &entries, true);
assert_eq!(values(&dirs), ["Documents/Reports/", "Documents/Other/"]);
}
#[test]
fn hidden_drive_entries_need_a_typed_dot() {
let entries = [entry(".git", true)];
assert!(drive_candidates("", "", &entries, false)[0].is_hide_set());
assert!(!drive_candidates("", ".", &entries, false)[0].is_hide_set());
}
#[test]
fn sync_folder_ids_complete_with_their_local_path() {
let folders: Vec<SyncFolderInfo> = serde_json::from_value(serde_json::json!([{
"id": 3,
"local_path": "/home/me/Documents",
"remote_uid": "vol~docs",
"mode": "mirror",
"state": "idle",
"last_sync": 0,
}]))
.unwrap();
let candidates = sync_folder_candidates(&folders);
assert_eq!(values(&candidates), ["3"]);
assert_eq!(help(&candidates[0]), "/home/me/Documents (mirror)");
}
#[test]
fn trash_uids_complete_with_their_name() {
let mut from_docs = entry("a.pdf", false);
from_docs.trashed_from = Some("Documents".into());
let candidates = trash_candidates(&[from_docs, entry("b", true)]);
assert_eq!(values(&candidates), ["vol~a.pdf", "vol~b"]);
assert_eq!(help(&candidates[0]), "a.pdf (from Documents)");
assert_eq!(help(&candidates[1]), "b");
}
#[test]
fn completer_without_a_service_offers_nothing() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-{}", std::process::id()));
let socket = dir.join("control.sock");
assert!(ask_at(&socket, &Request::ListSyncFolders).is_none());
}
#[test]
fn cached_listing_is_reused_until_it_is_stale() {
let dir = std::env::temp_dir().join(format!("pdfs-complete-cache-{}", std::process::id()));
let file = dir.join("completion-devices.json");
write_cache(&file, &Response::Devices { items: Vec::new() });
assert!(matches!(read_fresh(&file), Some(Response::Devices { .. })));
let mode = std::os::unix::fs::PermissionsExt::mode(
&std::fs::metadata(&file).unwrap().permissions(),
);
assert_eq!(mode & 0o777, 0o600);
let stale = std::time::SystemTime::now() - CACHE_TTL - Duration::from_secs(1);
std::fs::File::options()
.write(true)
.open(&file)
.unwrap()
.set_modified(stale)
.unwrap();
assert!(read_fresh(&file).is_none());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn words_before_cursor_stop_at_the_cursor() {
let args = words(&[
"pdfs", "--", "pdfs", "versions", "restore", "a.txt", "", "x",
]);
assert_eq!(
words_before_cursor(&args, Some(4)),
words(&["pdfs", "versions", "restore", "a.txt"])
);
// fish: the line arrives cut at the cursor, the current word last.
let fish = words(&["pdfs", "--", "pdfs", "versions", "restore", "a.txt", "ab"]);
assert_eq!(
words_before_cursor(&fish, None),
words(&["pdfs", "versions", "restore", "a.txt"])
);
}
#[test]
fn completing_words_finds_the_path_before_the_id() {
let line = words(&["pdfs", "versions", "restore", "Documents/a.txt"]);
assert_eq!(path_in(line).as_deref(), Some("Documents/a.txt"));
let line = words(&["pdfs", "--json", "share-role", "Shared/x"]);
assert_eq!(path_in(line).as_deref(), Some("Shared/x"));
let line = words(&["pdfs", "public-link", "update", "--role", "editor", "a"]);
assert_eq!(path_in(line).as_deref(), Some("a"));
assert_eq!(path_in(words(&["pdfs", "versions", "restore"])), None);
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -12,6 +12,7 @@ description = "Core auth/session/config for the Proton Drive Linux client"
[dependencies]
proton-sdk.workspace = true
proton-drive-rs.workspace = true
chrono.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
+33 -1
View File
@@ -621,10 +621,20 @@ impl ContentCache {
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// A `.meta` tags the blob named like it without the suffix. One
// whose blob is gone is what an eviction leaves between its two
// removals, by a crash or by a store of the same uid landing in
// between, and nothing else would ever remove it.
if let Some(blob) = name.strip_suffix(".meta") {
if !dir.join(blob).exists() {
let _ = std::fs::remove_file(entry.path());
}
continue;
}
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
// of bytes and not a cached block; like `.meta` it is neither
// budgeted nor evictable on its own.
if name.ends_with(".meta") || name.ends_with(".geom") {
if name.ends_with(".geom") {
continue;
}
// A `.tmp` is a staging file from a store that never completed
@@ -2250,6 +2260,28 @@ mod tests {
assert!(total <= 8, "budget enforced after reconcile, total={total}");
}
/// An eviction removes the blob, then its tag. A crash in between, or a
/// store of the same uid landing in between, left the tag on its own, and
/// nothing removed it.
#[test]
fn a_tag_without_its_blob_is_swept_at_open() {
let dir = TempDir::new();
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let db = Arc::new(Db::open_in_memory().unwrap());
{
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
c.store(&uid("kept"), 1, 4, b"kept").unwrap();
c.store(&uid("gone"), 1, 4, b"gone").unwrap();
std::fs::remove_file(c.blob_path(&uid("gone"))).unwrap();
}
let c = ContentCache::open(content, pins, 0, db).unwrap();
assert!(!c.meta_path(&uid("gone")).exists());
assert!(c.meta_path(&uid("kept")).exists());
assert_eq!(c.read_range(&uid("kept"), 1, 4, 0, 4).unwrap(), b"kept");
}
#[test]
fn stale_metadata_is_a_miss() {
let (c, _d) = cache();
+38
View File
@@ -163,6 +163,10 @@ pub struct AppConfig {
/// Defaulted for configs predating the field.
#[serde(default)]
pub files_view: FilesView,
/// How the desktop app's Photos page lays out its grid. Defaulted for
/// configs predating the field.
#[serde(default)]
pub photos_view: PhotosView,
/// The desktop app's main window size, remembered between runs. Defaulted
/// for configs predating the field.
#[serde(default)]
@@ -220,6 +224,10 @@ pub struct FilesView {
pub descending: bool,
/// Folders ahead of files, whatever the sort.
pub folders_first: bool,
/// The icon grid's thumbnail edge in logical pixels. `None` means the
/// app's default.
#[serde(skip_serializing_if = "Option::is_none")]
pub icon_size: Option<i32>,
}
impl Default for FilesView {
@@ -229,10 +237,23 @@ impl Default for FilesView {
sort: FileSort::Name,
descending: false,
folders_first: true,
icon_size: None,
}
}
}
/// The Photos page's grid choices, remembered between runs.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(default)]
pub struct PhotosView {
/// The zoom level: the row height the grid aims for, in logical pixels.
/// `None` means the app's default.
pub row_height: Option<i32>,
/// Crop every photo to a square tile instead of showing it at its own
/// shape.
pub square: bool,
}
/// The desktop app's main window, as it was when it last closed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
@@ -761,6 +782,11 @@ mod tests {
sort: FileSort::Modified,
descending: true,
folders_first: false,
icon_size: Some(192),
},
photos_view: PhotosView {
row_height: Some(240),
square: true,
},
window: WindowState {
width: 1200,
@@ -792,6 +818,7 @@ mod tests {
assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view);
assert_eq!(decoded.photos_view, config.photos_view);
assert_eq!(decoded.window, config.window);
}
@@ -835,6 +862,17 @@ mod tests {
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
assert_eq!(partial.sort, FileSort::Size);
assert!(partial.folders_first);
assert_eq!(partial.icon_size, None);
}
#[test]
fn a_config_without_a_photos_view_gets_the_default() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.photos_view, PhotosView::default());
let partial: PhotosView = serde_json::from_str(r#"{"square":true}"#).unwrap();
assert!(partial.square);
assert_eq!(partial.row_height, None);
}
#[test]
+117 -4
View File
@@ -469,6 +469,13 @@ pub enum Request {
/// Add a local folder to this machine's device, uploading its tree and
/// registering the device on first use. Replies with [`Response::Ok`].
AddSyncFolder { local_path: String },
/// Keep a real local directory in step with an existing folder in My files.
/// First sync unions one-sided files and preserves differing versions.
/// Replies with [`Response::Ok`]; no device backup is created.
AttachSyncFolder {
local_path: String,
remote_path: String,
},
/// List this device's synced folders. Replies with [`Response::SyncFolders`].
ListSyncFolders,
/// Remove a synced folder by id; `delete_remote` also trashes its cloud copy.
@@ -985,14 +992,17 @@ impl SyncIssue {
}
}
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
/// One device backup or attached My files mirror (in [`Response::SyncFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SyncFolderInfo {
/// Row id — the handle for [`Request::RemoveSyncFolder`]/[`Request::SetSyncFolderMode`].
pub id: i64,
/// `device` backup or `attached` My files mirror. Older daemons only had backups.
#[serde(default = "default_sync_folder_kind")]
pub kind: String,
/// Absolute local folder path.
pub local_path: String,
/// The uid of the folder's remote root under the device root.
/// The uid of the remote root under the device root or My files.
pub remote_uid: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
@@ -1015,6 +1025,10 @@ pub struct SyncFolderInfo {
pub paused: bool,
}
fn default_sync_folder_kind() -> String {
"device".into()
}
/// A folder under this machine's device offered for restore (in
/// [`Response::RestorableFolders`]).
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -1778,6 +1792,11 @@ pub struct PhotoItem {
/// the JPEG it shows is not the whole of what is stored.
#[serde(default)]
pub has_raw: bool,
/// GeoNames id of the town the photo was taken in, when its location is
/// known; [`crate::places::city`] names it. Older daemons omit it, and
/// album entries do not carry it.
#[serde(default)]
pub place_id: Option<u32>,
}
/// A daemon too old to classify a timeline entry is assumed to have served a
@@ -1837,6 +1856,34 @@ pub fn is_thumbnail_image_name(name: &str) -> bool {
|| RAW_THUMBNAIL_EXTENSIONS.contains(&extension.as_str())
}
/// The smallest `.ts` file taken for an MPEG transport stream. `.ts` is
/// TypeScript source as often as it is a recording, and the two sit far apart
/// in size: a source file is kilobytes, while a second of broadcast video is
/// already megabytes. Drive often stores either kind as
/// `application/octet-stream`, so the size is the only signal a listing has.
pub const TS_VIDEO_MIN_BYTES: u64 = 1024 * 1024;
/// Whether a file named `name`, of `size` bytes when the caller knows it, is a
/// video. The extension decides, except for `.ts`, which counts as video only
/// from [`TS_VIDEO_MIN_BYTES`] up. A `.ts` of unknown size counts as video, as
/// [`PhotoKind::classify`] has it.
pub fn is_video_file_name(name: &str, size: Option<u64>) -> bool {
if PhotoKind::classify(Some(name), None) != PhotoKind::Video {
return false;
}
let transport_stream = name
.rsplit_once('.')
.is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("ts"));
!transport_stream || size.is_none_or(|size| size >= TS_VIDEO_MIN_BYTES)
}
/// Whether file listings show a thumbnail for a file named `name`, of `size`
/// bytes when known: an image [`is_thumbnail_image_name`] accepts, or a video,
/// whose thumbnail is a frame the daemon takes with ffmpeg.
pub fn is_thumbnail_media_name(name: &str, size: Option<u64>) -> bool {
is_thumbnail_image_name(name) || is_video_file_name(name, size)
}
/// 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.
@@ -1980,6 +2027,29 @@ pub struct Diagnostics {
pub control_handler_limit: usize,
/// Queued mutations not yet drained.
pub pending_ops: u64,
/// The folder-sync engine. `None` from a daemon that predates the field.
#[serde(default)]
pub sync: Option<SyncEngineState>,
}
/// What the folder-sync engine is doing, for [`Diagnostics`].
///
/// The engine is one thread that runs every synced folder's passes in turn.
/// Dead or stuck, it looked exactly like an idle one: workers idle, nothing
/// queued, nothing in the journal (`docs/BUGS.md` B259).
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct SyncEngineState {
/// Whether the engine thread is running. Once it has ended, nothing syncs
/// until the daemon restarts.
pub running: bool,
/// The synced folder whose pass is running, if one is.
pub pass_folder: Option<i64>,
/// How long that pass has been running.
pub pass_age_secs: u64,
/// How long since that pass last reported progress.
pub pass_idle_secs: u64,
/// Passes that ended in a panic since the daemon started.
pub panics: u64,
}
/// The daemon's reply to a [`Request`].
@@ -2368,9 +2438,17 @@ impl Request {
///
/// Shared by the CLI and GUI so both speak the wire format identically.
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
send_with_timeout(socket, req, req.read_timeout())
}
/// [`send`] with a caller-chosen limit on how long the daemon may take to
/// answer, which also bounds the write. For a caller that must never keep a
/// person waiting, such as shell completion, where a slow answer is worth less
/// than none.
pub fn send_with_timeout(socket: &Path, req: &Request, timeout: Duration) -> Result<Response> {
let mut stream = UnixStream::connect(socket)?;
stream.set_read_timeout(Some(req.read_timeout()))?;
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout.min(WRITE_TIMEOUT)))?;
let mut line = serde_json::to_vec(req)?;
line.push(b'\n');
stream.write_all(&line)?;
@@ -2630,6 +2708,41 @@ mod tests {
assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo);
}
#[test]
fn a_ts_file_is_a_video_only_at_the_size_of_one() {
const GB: u64 = 1024 * 1024 * 1024;
assert!(!is_video_file_name("app.ts", Some(3 * 1024)));
assert!(!is_video_file_name(
"bundle.TS",
Some(TS_VIDEO_MIN_BYTES - 1)
));
assert!(is_video_file_name("recording.ts", Some(9 * GB)));
assert!(is_video_file_name("recording.TS", Some(TS_VIDEO_MIN_BYTES)));
assert!(is_video_file_name("recording.ts", None));
// Only `.ts` is in doubt; every other video name is one at any size.
assert!(is_video_file_name("clip.mkv", Some(10)));
assert!(is_video_file_name("clip.m2ts", Some(10)));
assert!(!is_video_file_name("notes.txt", Some(9 * GB)));
assert!(!is_video_file_name("ts", Some(9 * GB)));
}
#[test]
fn listings_thumbnail_images_and_videos() {
assert!(is_thumbnail_media_name("photo.JPG", Some(10)));
assert!(is_thumbnail_media_name("camera.nef", None));
assert!(is_thumbnail_media_name("clip.mp4", Some(10)));
assert!(is_thumbnail_media_name(
"recording.ts",
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("app.ts", Some(2048)));
assert!(!is_thumbnail_media_name(
"song.flac",
Some(64 * 1024 * 1024)
));
assert!(!is_thumbnail_media_name("notes.txt", None));
}
/// Keep every extension accepted by the shared classifier covered. Drive
/// activation uses this classification before it knows whether opening a
/// file should stream through FUSE or materialise a complete cache entry;
+4
View File
@@ -186,6 +186,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
@@ -247,6 +249,8 @@ impl Db {
// made, and it shows exactly what they put in it.
group_size: 1,
has_raw: false,
// Album rows carry no location.
place_id: None,
})
})?
.collect::<rusqlite::Result<_>>()?;
+78 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 44;
pub(super) const SCHEMA_VERSION: i64 = 45;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -467,6 +467,29 @@ impl Db {
tx.execute_batch(MIGRATION_V44)?;
}
}
if current < 45 {
let has_folders: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_folder'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_kind: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_folder') WHERE name = 'kind'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_folders && !has_kind {
tx.execute_batch(MIGRATION_V45_FOLDERS)?;
let has_mounts: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'mount'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_mounts {
tx.execute_batch(MIGRATION_V45_MOUNTS)?;
}
}
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1382,3 +1405,57 @@ CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
const MIGRATION_V44: &str = "
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
";
/// Schema v45: distinguish device backups from My files attachments. Existing
/// released rows are device backups. Attachments cannot evict their local tree.
const MIGRATION_V45_FOLDERS: &str = "
ALTER TABLE sync_folder ADD COLUMN kind TEXT NOT NULL DEFAULT 'device'
CHECK (kind IN ('device', 'attached'));
CREATE TRIGGER sync_folder_attached_insert
BEFORE INSERT ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
CREATE TRIGGER sync_folder_attached_update
BEFORE UPDATE ON sync_folder
WHEN NEW.kind = 'attached' AND (NEW.mode != 'mirror' OR NEW.pending_mode IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'attached folders only support mirror mode'); END;
";
const MIGRATION_V45_MOUNTS: &str = "
DROP TRIGGER mount_sync_folder_insert;
DROP INDEX mount_myfiles;
DROP INDEX mount_device;
DROP INDEX mount_shared;
ALTER TABLE mount RENAME TO mount_v44;
CREATE TABLE mount (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('myfiles', 'device', 'attached', 'shared')),
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
share_root_uid TEXT,
local_path TEXT NOT NULL DEFAULT '',
root_uid TEXT NOT NULL DEFAULT '',
root_share_id TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT 'unknown',
access TEXT NOT NULL DEFAULT 'rw' CHECK (access IN ('rw', 'ro')),
CHECK (
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
OR (kind IN ('device', 'attached') AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
OR (kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
)
);
INSERT INTO mount SELECT * FROM mount_v44;
DROP TABLE mount_v44;
CREATE UNIQUE INDEX mount_myfiles ON mount(kind) WHERE kind = 'myfiles';
CREATE UNIQUE INDEX mount_device ON mount(sync_folder_id) WHERE kind IN ('device', 'attached');
CREATE UNIQUE INDEX mount_shared ON mount(share_root_uid) WHERE kind = 'shared';
CREATE TRIGGER mount_sync_folder_insert
AFTER INSERT ON sync_folder
BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES (NEW.kind, NEW.id);
END;
CREATE TRIGGER mount_sync_folder_kind_update
AFTER UPDATE OF kind ON sync_folder
BEGIN
UPDATE mount SET kind = NEW.kind WHERE sync_folder_id = NEW.id;
END;
";
+14 -9
View File
@@ -1,6 +1,6 @@
//! Unified mount/location presentation rows.
//!
//! A device row stores only its `sync_folder` identity. Its path, remote root,
//! A device or attached row stores only its `sync_folder` identity. Its path, remote root,
//! mode, state, pending mode, and last-sync timestamp are selected from
//! `sync_folder`, which remains the authoritative sync-engine table.
@@ -72,7 +72,7 @@ impl Db {
)? > 0)
}
/// Every local location, with device fields joined from `sync_folder`.
/// Every local location, with synced-folder fields joined from `sync_folder`.
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
let conn = self.read();
let mut stmt = conn.prepare(
@@ -81,14 +81,14 @@ impl Db {
m.kind,
m.sync_folder_id,
m.share_root_uid,
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.local_path ELSE m.local_path END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_uid ELSE m.root_uid END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.remote_share_id ELSE m.root_share_id END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.mode ELSE m.mode END,
m.access,
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
CASE WHEN m.kind IN ('device', 'attached') THEN sf.state ELSE 'idle' END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.last_sync ELSE 0 END,
CASE WHEN m.kind IN ('device', 'attached') THEN sf.pending_mode ELSE NULL END
FROM mount AS m
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
ORDER BY CASE m.kind
@@ -138,6 +138,11 @@ impl Db {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"attached" => MountKind::Attached {
sync_folder_id: sync_folder_id.ok_or_else(|| {
Error::Other(format!("mount row {id} has no sync_folder_id"))
})?,
},
"shared" => MountKind::Shared {
share_root_uid: share_root_uid.ok_or_else(|| {
Error::Other(format!("mount row {id} has no share_root_uid"))
+4 -1
View File
@@ -40,6 +40,8 @@ pub struct StoredPhoto {
/// 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,
/// The town the photo was taken in, once its location has been read.
pub place_id: Option<u32>,
}
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
@@ -256,7 +258,7 @@ fn group_photos(entries: &[Grouped]) -> Vec<String> {
/// own group: the column is `NULL` until the next refresh computes it, and a
/// `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 \
p.kind, p.favorite, g.n, g.raw, p.place_id \
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
@@ -272,6 +274,7 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
favorite: r.get::<_, i64>(6)? != 0,
group_size: r.get::<_, i64>(7)?.max(1) as usize,
has_raw: r.get::<_, i64>(8)? != 0,
place_id: r.get(9)?,
})
}
+29 -5
View File
@@ -9,6 +9,8 @@ use std::collections::HashMap;
pub struct StoredSyncFolder {
pub id: i64,
/// `device` backup or `attached` My files mirror.
pub kind: String,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
@@ -59,12 +61,32 @@ impl Db {
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "device")
}
/// Attach an existing My files folder, without creating a device backup.
pub fn sync_folder_attach(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
self.sync_folder_insert(local_path, remote_uid, remote_share_id, "attached")
}
fn sync_folder_insert(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
kind: &str,
) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id, kind)
VALUES (?1, ?2, ?3, ?4)",
params![local_path, remote_uid, remote_share_id, kind],
)?;
Ok(conn.last_insert_rowid())
}
@@ -73,7 +95,7 @@ impl Db {
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
@@ -86,6 +108,7 @@ impl Db {
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
kind: r.get(8)?,
})
})?;
let mut items = Vec::new();
@@ -99,7 +122,7 @@ impl Db {
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.read();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync, kind
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
@@ -112,6 +135,7 @@ impl Db {
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
kind: r.get(8)?,
})
},
)
+88 -1
View File
@@ -363,6 +363,23 @@ fn place_photos_carry_their_own_location() {
);
}
/// A timeline photo names the town it was taken in, so the viewer can show it;
/// one without a location names none.
#[test]
fn timeline_photos_name_their_town() {
let db = Db::open_in_memory().unwrap();
db.photos_replace(&[
located("b", 500, "IMG_5.JPG", BERLIN),
TimelineRow::new("n", 400),
])
.unwrap();
let berlin = db.photo_places().unwrap()[0].id;
let mut photos = db.photos_by_uid(&["b".into(), "n".into()]).unwrap();
photos.sort_by(|a, b| a.uid.cmp(&b.uid));
let towns: Vec<Option<u32>> = photos.iter().map(|photo| photo.place_id).collect();
assert_eq!(towns, [Some(berlin), None]);
}
/// A refresh that skips an already-resolved photo keeps where it was taken; one
/// that reads the node again takes what the node now says.
#[test]
@@ -2076,7 +2093,11 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"DROP TRIGGER mount_sync_folder_insert;
"DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
ALTER TABLE sync_folder DROP COLUMN kind;
DROP TABLE mount;
DROP TABLE share_access;
DROP TABLE album_photos;
@@ -7691,3 +7712,69 @@ fn a_queued_op_stops_existing_once_the_node_is_deleted() {
assert_eq!(blobs, vec!["/staging/journal"]);
assert!(!db.op_exists(id).unwrap());
}
#[test]
fn attachments_have_their_own_location_and_cannot_evict_local_files() {
use crate::mounts::MountKind;
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_attach("/work", "vol~work", "myfiles-share")
.unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert_eq!(db.sync_folder_list().unwrap()[0].kind, "attached");
let locations = db.mount_list().unwrap();
assert!(
matches!(locations[0].kind, MountKind::Attached { sync_folder_id } if sync_folder_id == id)
);
assert_eq!(locations[0].local_path, "/work");
assert_eq!(locations[0].root_share_id, "myfiles-share");
assert!(db.sync_folder_set_mode(id, "ondemand").is_err());
assert!(
db.sync_folder_set_pending_mode(id, Some("ondemand"))
.is_err()
);
assert_eq!(db.sync_folder_get(id).unwrap().unwrap().mode, "mirror");
db.sync_folder_remove(id).unwrap();
assert!(db.mount_list().unwrap().is_empty());
}
#[test]
fn migration_v45_keeps_existing_device_folders_and_location_ids() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/backup", "vol~backup", "share")
.unwrap();
db.sync_folder_set_mode(id, "ondemand").unwrap();
db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap();
let location_id = db.mount_list().unwrap()[0].id;
db.conn
.lock()
.execute_batch(
"DROP TRIGGER sync_folder_attached_insert;
DROP TRIGGER sync_folder_attached_update;
DROP TRIGGER IF EXISTS mount_sync_folder_kind_update;
DROP TRIGGER mount_sync_folder_insert;
ALTER TABLE sync_folder DROP COLUMN kind;
CREATE TRIGGER mount_sync_folder_insert AFTER INSERT ON sync_folder BEGIN
INSERT INTO mount (kind, sync_folder_id) VALUES ('device', NEW.id); END;
UPDATE sync_state SET value = '44' WHERE key = 'schema_version';",
)
.unwrap();
db.migrate().unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.kind, "device");
assert_eq!(folder.mode, "ondemand");
assert_eq!(folder.pending_mode.as_deref(), Some("mirror"));
assert_eq!(db.mount_list().unwrap()[0].id, location_id);
let attached = db
.sync_folder_attach("/work", "vol~work", "myfiles")
.unwrap();
assert_eq!(
db.sync_folder_get(attached).unwrap().unwrap().kind,
"attached"
);
db.migrate().unwrap();
assert_eq!(db.mount_list().unwrap().len(), 2);
}
+52 -16
View File
@@ -160,13 +160,14 @@ impl CoreError {
/// telling the user their connection is down beats telling them the
/// server refused. The SDK's other failures say what went wrong instead
/// (see [`proton_kind`]).
///
/// Callers also hand it this crate's [`Error`] from the cache and the
/// database, which is not a transport error at all. Taking those for
/// "offline" told the user their connection was down when a pin failed on
/// the database (`docs/BUGS.md` B266), so the source chain is searched for
/// either type before falling back.
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton) => proton_kind(proton),
// Not the SDK's at all: a timeout around the call, or the socket.
None => ErrorKind::Offline,
};
Self::new(kind, format!("{what}: {e}"))
Self::new(api_kind_of(e), format!("{what}: {e}"))
}
/// Prefix the message while keeping the classification.
@@ -208,19 +209,40 @@ impl From<serde_json::Error> for CoreError {
impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn
| Error::ReloginRequired
| Error::Locked
| Error::WrongMailboxPassword
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(proton) => proton_kind(proton),
_ => ErrorKind::Internal,
};
Self::new(kind, e.to_string())
Self::new(core_kind(&e), e.to_string())
}
}
/// What this crate's [`Error`] means to a front-end.
fn core_kind(e: &Error) -> ErrorKind {
match e {
Error::NotLoggedIn
| Error::ReloginRequired
| Error::Locked
| Error::WrongMailboxPassword
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(proton) => proton_kind(proton),
_ => ErrorKind::Internal,
}
}
/// The kind for [`CoreError::from_api`]: the first SDK or crate error in `e`'s
/// source chain decides it.
fn api_kind_of(e: &(dyn std::error::Error + 'static)) -> ErrorKind {
let mut cause = Some(e);
while let Some(err) = cause {
if let Some(proton) = err.downcast_ref::<proton_sdk::ProtonError>() {
return proton_kind(proton);
}
if let Some(core) = err.downcast_ref::<Error>() {
return core_kind(core);
}
cause = err.source();
}
// Not the SDK's at all: a timeout around the call, or the socket.
ErrorKind::Offline
}
/// What a failed SDK call means to a front-end.
///
/// Only a transport failure is the connection. The rest happened on this
@@ -414,6 +436,20 @@ mod tests {
assert_ne!(e.kind, ErrorKind::Offline);
}
/// A pin or a search that fails on the database is not the connection, so
/// it must not say "You're offline" (`docs/BUGS.md` B266). An SDK error
/// wrapped in this crate's type classifies as the SDK error does.
#[test]
fn a_database_error_handed_to_from_api_is_not_offline() {
let db = Error::Other("database is locked".into());
assert_eq!(CoreError::from_api(&db, "pin").kind, ErrorKind::Internal);
let wrapped = Error::Proton(api(C::DoesNotExist));
assert_eq!(
CoreError::from_api(&wrapped, "load node").kind,
ErrorKind::NotFound
);
}
/// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure.
#[test]
+3
View File
@@ -15,6 +15,8 @@ pub mod kernelkey;
pub mod keycheck;
pub mod localindex;
pub mod menu;
pub mod mountfail;
pub mod mountpoint;
pub mod mounts;
pub mod opener;
pub mod places;
@@ -27,6 +29,7 @@ pub mod similar;
pub mod syncignore;
pub mod syncroot;
pub mod takeout;
pub mod timestamp;
pub mod tray;
pub mod update;
+89
View File
@@ -0,0 +1,89 @@
//! Why the daemon's mount keeps failing, for the front ends to read.
//!
//! The control socket opens only once a mount has succeeded, so a daemon whose
//! mount fails on every try had no way to say why. The app and the tray showed
//! "Connecting…" for days while the reason sat in the journal (`docs/BUGS.md`
//! B263). The daemon now writes its last failure to a small file in the state
//! directory, next to the control socket, and removes it once a mount is up.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The file's name inside the state directory.
const FILE_NAME: &str = "mount-failure.json";
/// The last failed mount attempt.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MountFailure {
/// The error, in English, as the journal has it.
pub error: String,
/// Unix seconds of the first failure in this run of failures.
pub since: i64,
/// Failed attempts in a row.
pub attempts: u32,
/// Unix seconds of the next attempt.
pub next_attempt: i64,
/// Set when the mount was refused because the folder already has files in
/// it, so the app can explain that in the user's language. Absent in a
/// file an older daemon wrote.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_empty: Option<crate::mountpoint::NotEmpty>,
}
/// Where the failure is kept for the state directory `state_dir`.
pub fn path(state_dir: &Path) -> PathBuf {
state_dir.join(FILE_NAME)
}
/// Record `failure`, replacing the one before it. Written to a temporary file
/// and renamed, so a reader never sees half of it.
pub fn record(state_dir: &Path, failure: &MountFailure) -> std::io::Result<()> {
let json = serde_json::to_vec(failure).map_err(std::io::Error::other)?;
let tmp = state_dir.join(format!("{FILE_NAME}.tmp"));
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path(state_dir))
}
/// Forget the failure: a mount is up, or a new daemon has started.
pub fn clear(state_dir: &Path) {
let _ = std::fs::remove_file(path(state_dir));
}
/// The recorded failure, if there is one and it can be read.
pub fn read(state_dir: &Path) -> Option<MountFailure> {
let bytes = std::fs::read(path(state_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_recorded_failure_reads_back_until_cleared() {
let dir = std::env::temp_dir().join(format!("pdfs-mountfail-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(read(&dir), None);
let failure = MountFailure {
error: "fetch My Files root: missing key".into(),
since: 1_700_000_000,
attempts: 3,
next_attempt: 1_700_000_040,
not_empty: None,
};
record(&dir, &failure).unwrap();
assert_eq!(read(&dir), Some(failure));
clear(&dir);
assert_eq!(read(&dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failure_an_older_daemon_wrote_still_reads() {
let old = r#"{"error":"missing key","since":1,"attempts":2,"next_attempt":3}"#;
let failure: MountFailure = serde_json::from_str(old).unwrap();
assert_eq!(failure.not_empty, None);
assert_eq!(failure.attempts, 2);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Whether a folder can take the My files mount without hiding anything.
//!
//! FUSE mounts over a folder that already has files in it without a word. The
//! files then sit under the mount, out of sight: not uploaded, and back only
//! once the service stops, so they look deleted (`docs/BUGS.md` B277, #67).
//! The daemon refuses such a folder, and the app refuses it when it is picked.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The file system type the kernel reports for this client's own mounts.
pub const OWN_FS_TYPE: &str = "fuse.protondrive";
/// How many of a folder's entries an error names.
const NAMED_ENTRIES: usize = 3;
/// A folder that already has something in it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct NotEmpty {
/// The folder.
pub path: PathBuf,
/// The first few names in it, sorted.
pub entries: Vec<String>,
/// How many entries it has in all.
pub count: usize,
}
impl std::fmt::Display for NotEmpty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} is not empty (holds ", self.path.display())?;
write!(f, "{}", self.entries.join(", "))?;
let rest = self.count.saturating_sub(self.entries.len());
if rest > 0 {
write!(f, " and {rest} more")?;
}
write!(
f,
"); My files would hide those files. Move them out, or choose another folder for My files"
)
}
}
impl std::error::Error for NotEmpty {}
/// What is in `dir`, if anything. A folder that does not exist yet is empty.
/// Hidden entries count: a `.directory` or a `.Trash-1000` hidden under the
/// mount is as lost as any other file.
pub fn not_empty(dir: &Path) -> std::io::Result<Option<NotEmpty>> {
let read = match std::fs::read_dir(dir) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut names = read
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
.collect::<std::io::Result<Vec<_>>>()?;
if names.is_empty() {
return Ok(None);
}
names.sort();
let count = names.len();
names.truncate(NAMED_ENTRIES);
Ok(Some(NotEmpty {
path: dir.to_path_buf(),
entries: names,
count,
}))
}
/// The file system type mounted at `path`, as `/proc/self/mountinfo` names it
/// (`ext4`, `fuse.protondrive`, …), or `None` when nothing is mounted there.
pub fn mounted_fs_type(path: &Path) -> Option<String> {
let wanted = path.canonicalize().ok()?;
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
mounts
.lines()
.filter_map(|line| {
let fields: Vec<_> = line.split_whitespace().collect();
let separator = fields.iter().position(|field| *field == "-")?;
let encoded = *fields.get(4)?;
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
let mountpoint = PathBuf::from(decoded);
if mountpoint.canonicalize().ok()? == wanted {
Some(fields.get(separator + 1)?.to_string())
} else {
None
}
})
.next()
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("pdfs-mountpoint-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn an_empty_or_missing_folder_takes_the_mount() {
let dir = temp_dir("empty");
assert_eq!(not_empty(&dir).unwrap(), None);
assert_eq!(not_empty(&dir.join("missing")).unwrap(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_folder_with_files_is_refused_and_named() {
let dir = temp_dir("files");
for name in ["report.pdf", "Notes", ".directory", "zeta.txt"] {
std::fs::write(dir.join(name), b"x").unwrap();
}
let found = not_empty(&dir).unwrap().expect("must refuse");
assert_eq!(found.count, 4);
assert_eq!(found.entries, [".directory", "Notes", "report.pdf"]);
let message = found.to_string();
assert!(message.contains("is not empty"), "{message}");
assert!(
message.contains(".directory, Notes, report.pdf and 1 more"),
"{message}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hidden_file_alone_is_enough_to_refuse() {
let dir = temp_dir("hidden");
std::fs::write(dir.join(".Trash-1000"), b"").unwrap();
let found = not_empty(&dir).unwrap().expect("must refuse");
assert_eq!(found.count, 1);
assert!(!found.to_string().contains("more"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_plain_folder_is_not_a_mount() {
let dir = temp_dir("plain");
assert_eq!(mounted_fs_type(&dir), None);
let _ = std::fs::remove_dir_all(&dir);
}
}
+4 -2
View File
@@ -81,6 +81,8 @@ pub enum MountKind {
MyFiles,
/// A folder owned by this machine's Proton Drive device.
Device { sync_folder_id: i64 },
/// A real local mirror of an existing My files folder.
Attached { sync_folder_id: i64 },
/// A future standalone mount rooted at a node shared with this account.
Shared { share_root_uid: String },
}
@@ -88,8 +90,8 @@ pub enum MountKind {
/// One local Proton Drive location as exposed over the control protocol.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MountSpec {
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
/// [`MountKind::Device`], not this id.
/// Presentation-row id. Synced-folder actions use the `sync_folder_id`
/// carried by [`MountKind::Device`] or [`MountKind::Attached`], not this id.
pub id: i64,
pub kind: MountKind,
pub local_path: String,
+70 -7
View File
@@ -27,6 +27,7 @@ use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::control::is_video_file_name;
use serde::{Deserialize, Serialize};
/// Environment override for the terminal used by `"terminal": true` rules.
@@ -108,11 +109,12 @@ impl OpenWith {
/// a Markdown file). The rules are about *what the user opened*, so the
/// caller passes that name and the argv still gets the real path.
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
let kind = Kind::of(path, name, is_dir);
for rule in &self.rules {
if rule.command.is_empty() {
continue;
}
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
if !rule.patterns.iter().any(|p| matches(p, name, kind)) {
continue;
}
let argv = expand(&rule.command, path);
@@ -325,16 +327,46 @@ fn expand_env(token: &str) -> Option<String> {
value.filter(|v| !v.is_empty())
}
/// What is being opened, as far as the `@class` patterns care.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Dir,
Video,
File,
}
impl Kind {
/// `.ts` names TypeScript and MPEG transport streams alike, so for that
/// one extension the size on disk decides (see
/// [`is_video_file_name`](crate::control::is_video_file_name)). No other
/// name costs a `stat`.
fn of(path: &Path, name: &str, is_dir: bool) -> Kind {
if is_dir {
return Kind::Dir;
}
let size = extension(name)
.eq_ignore_ascii_case("ts")
.then(|| std::fs::metadata(path).ok().map(|meta| meta.len()))
.flatten();
if is_video_file_name(name, size) {
Kind::Video
} else {
Kind::File
}
}
}
/// Whether `pattern` — a glob or an `@class` — matches this name.
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
fn matches(pattern: &str, name: &str, kind: Kind) -> bool {
let name_lower = name.to_lowercase();
let file = kind == Kind::File;
match pattern.to_lowercase().as_str() {
"@any" => true,
"@dir" => is_dir,
"@text" => !is_dir && class_text(&name_lower),
"@document" => !is_dir && class_document(&name_lower),
"@image" => !is_dir && class_image(&name_lower),
"@media" => !is_dir && class_media(&name_lower),
"@dir" => kind == Kind::Dir,
"@text" => file && class_text(&name_lower),
"@document" => file && class_document(&name_lower),
"@image" => file && class_image(&name_lower),
"@media" => kind == Kind::Video || (file && class_media(&name_lower)),
other if other.starts_with('@') => false,
other => glob_match(other, &name_lower),
}
@@ -533,6 +565,37 @@ mod tests {
);
}
#[test]
fn a_ts_file_opens_as_media_or_text_by_its_size() {
let policy = OpenWith {
terminal: Some(vec!["alacritty".into(), "-e".into()]),
default: None,
rules: vec![
rule(&["@text"], &["nvim"], true),
rule(&["@media"], &["mpv"], false),
],
};
let dir = std::env::temp_dir().join(format!("pdfs-opener-ts-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let source = dir.join("blob-source");
std::fs::write(&source, "export const answer = 42;\n").unwrap();
let recording = dir.join("blob-recording");
std::fs::File::create(&recording)
.unwrap()
.set_len(crate::control::TS_VIDEO_MIN_BYTES)
.unwrap();
let source_argv = policy.command_for_named(&source, "app.ts", false);
let recording_argv = policy.command_for_named(&recording, "recording.ts", false);
// A file that is not on this machine yet has no size to go by.
let unknown_argv = policy.command_for_named(&dir.join("missing"), "recording.ts", false);
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(source_argv[..3], ["alacritty", "-e", "nvim"]);
assert_eq!(recording_argv[0], "mpv");
assert_eq!(unknown_argv[0], "mpv");
}
#[test]
fn an_empty_policy_is_xdg_open() {
let policy = OpenWith::default();
+2 -2
View File
@@ -1332,8 +1332,8 @@ mod tests {
));
let file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options: zip::write::FileOptions<'_, ()> =
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, body) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(body).unwrap();
+33
View File
@@ -0,0 +1,33 @@
//! Convert the client's stored epoch seconds at the SDK boundary.
use proton_drive_rs::ModificationTime;
/// A UTC modification time, or `None` when outside the SDK's supported range.
pub fn modification_time(seconds: i64) -> Option<ModificationTime> {
chrono::DateTime::from_timestamp(seconds, 0).map(|time| time.fixed_offset())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_seconds_preserve_the_instant_including_before_1970() {
for seconds in [-1, 0, 1_709_208_000, 1_782_482_460] {
let time = modification_time(seconds).unwrap();
assert_eq!(time.timestamp(), seconds);
assert_eq!(time.offset().local_minus_utc(), 0);
assert_eq!(time.timestamp_subsec_nanos(), 0);
}
assert_eq!(
modification_time(-1).unwrap().to_rfc3339(),
"1969-12-31T23:59:59+00:00"
);
}
#[test]
fn out_of_range_seconds_omit_the_timestamp() {
assert!(modification_time(i64::MIN).is_none());
assert!(modification_time(i64::MAX).is_none());
}
}
+136
View File
@@ -0,0 +1,136 @@
//! Explicitly bind an existing My files folder to a real local mirror.
//!
//! Unlike a FUSE mount, this never covers or evicts the local directory.
//! The existing three-way engine performs the initial, non-destructive union.
use super::{Core, parse_uid, sync};
use pdfs_core::db::StoredSyncFolder;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
impl Core {
/// A bounded, server-verified ancestry walk. Do not decide ownership or
/// remote-root overlap from a possibly incomplete cached folder listing.
pub(super) fn attach_ancestors(&self, start: &NodeUid) -> CoreResult<Vec<NodeUid>> {
let mut ancestors = Vec::new();
let mut seen = HashSet::new();
let mut next = Some(start.clone());
while let Some(uid) = next {
if !seen.insert(uid.clone()) || ancestors.len() >= 256 {
return Err(CoreError::invalid(
"invalid or excessively deep remote folder ancestry",
));
}
ancestors.push(uid.clone());
if uid == self.primary_root_uid {
break;
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes(std::slice::from_ref(&uid)))
.map_err(|e| CoreError::from_api(&e, "verify sync folder ancestry"))?;
let node = nodes
.into_iter()
.next()
.ok_or_else(|| CoreError::not_found("remote sync folder is missing"))?;
if node.trashed {
return Err(CoreError::invalid("remote sync folder is in the trash"));
}
next = node.parent_uid;
}
Ok(ancestors)
}
pub(crate) fn attach_sync_folder(
&self,
local: &Path,
remote: &Path,
mountpoint: &Path,
) -> CoreResult<StoredSyncFolder> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if !self.primary {
return Err(CoreError::invalid("attach must use My files"));
}
if remote.is_absolute()
|| remote
.components()
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(CoreError::invalid(
"remote folder must stay inside My files",
));
}
let local = local
.canonicalize()
.map_err(|e| CoreError::invalid(format!("resolve {}: {e}", local.display())))?;
if !local.is_dir() {
return Err(CoreError::invalid("local sync path is not a directory"));
}
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let others = existing
.iter()
.map(|f| PathBuf::from(&f.local_path))
.collect::<Vec<_>>();
let mut reserved = crate::devices::sync_root_reserved();
reserved.mountpoint = Some(
mountpoint
.canonicalize()
.map_err(|e| CoreError::internal(format!("resolve mountpoint: {e}")))?,
);
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
return Err(CoreError::invalid(problem));
}
let (ino, remote_uid) = self.resolve(remote)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|e| e.node.is_folder() && !e.node.trashed);
if !is_folder {
return Err(CoreError::invalid("remote sync path is not a folder"));
}
let ancestors = self.attach_ancestors(&remote_uid)?;
if remote_uid == self.primary_root_uid || !ancestors.contains(&self.primary_root_uid) {
return Err(CoreError::invalid(
"choose an owned folder below My files, not its root or a shared folder",
));
}
for other in existing.iter().filter(|f| f.kind == "attached") {
let uid = parse_uid(&other.remote_uid)
.ok_or_else(|| CoreError::internal("invalid existing remote sync root"))?;
if uid == remote_uid
|| ancestors.contains(&uid)
|| self.attach_ancestors(&uid)?.contains(&remote_uid)
{
return Err(CoreError::invalid(
"remote folder overlaps an existing synced folder",
));
}
}
let share_id = self
.rt
.block_on(self.drive.context_share_id(&self.primary_root_uid))
.map_err(|e| CoreError::from_api(&e, "resolve My files share"))?
.to_string();
if share_id.is_empty() {
return Err(CoreError::invalid("My files share is unavailable"));
}
let id = self
.db
.sync_folder_attach(&local.to_string_lossy(), &remote_uid.to_string(), &share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// An empty baseline makes one-sided files new, never deletions. The
// engine hashes matching-size files and preserves divergent versions.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
self.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found("new sync folder disappeared"))
}
}
+35 -1
View File
@@ -157,6 +157,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
if ours {
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
}
// A file kept offline keeps its copy through a change, and the
// keeper replaces it once this batch's listings are read again.
// The copy is checked against the revision's `(mtime, size)`, so
// no read takes it for a newer revision. Evicted, it was gone until
// something read the file, which then downloaded it (B282). A
// node new in, or moved into, a kept folder is fetched by checking
// that folder.
let kept = match !*is_trashed && !ours {
true => kept_by_pin(core, node_uid, parent_node_uid.as_ref()),
false => None,
};
let mut had_node = false;
core.for_each_mount(|st, notify| {
if *is_trashed {
@@ -172,9 +183,12 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
}
});
// Once, not once per mount: the content cache is per-daemon.
if had_node {
if had_node && kept.is_none() {
core.cache.evict(node_uid);
}
if let Some(kept) = kept {
dirty.kept.insert(kept);
}
// A create, rename or move-in shows up as a change to the parent
// listing too, which is recorded for the end of the batch rather
// than acted on here — see [`DirtyParents`].
@@ -205,6 +219,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
| DriveEvent::ScopeAccessLost { .. }
| DriveEvent::SharedWithMeUpdated { .. } => {
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
// Changes went by unseen, kept files among them (B282).
core.recheck_all_pins();
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
core.for_each_mount(|st, notify| {
let dirs: Vec<u64> = st.children.keys().copied().collect();
@@ -296,6 +312,21 @@ struct DirtyParents {
/// Checked per mount — a mount that already lists every one of those
/// children under that parent keeps its listing.
ours: HashMap<NodeUid, HashSet<NodeUid>>,
/// Pinned files and folders the batch changed, for the pin keeper to
/// check once the listings above are dropped (`keeper`).
kept: HashSet<NodeUid>,
}
/// What the pin keeper has to check after a remote change to `node_uid`,
/// whose parent is now `parent_uid`: the node itself when a pin keeps it, or
/// else the parent, when a pinned folder holds it and the node is new there.
fn kept_by_pin(core: &Core, node_uid: &NodeUid, parent_uid: Option<&NodeUid>) -> Option<NodeUid> {
if core.cache.is_pinned(node_uid) {
return Some(node_uid.clone());
}
parent_uid
.filter(|parent| core.cache.is_pinned(parent))
.cloned()
}
/// Whether this mount's cached listing of `parent_uid` already accounts for
@@ -476,6 +507,9 @@ pub(super) async fn run_event_sync(
cursor = Some(applied);
}
flush_dirty_parents(&core, &dirty);
for uid in &dirty.kept {
core.recheck_pinned(uid);
}
// Another device changed the tree: whatever a front-end lists may have
// moved, including what is shared and what is in the trash.
core.events
+73 -12
View File
@@ -110,7 +110,7 @@ struct ControlHandlerPermit;
impl ControlHandlerPermit {
fn acquire() -> Option<Self> {
ACTIVE_CONTROL_HANDLERS
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
})
.ok()
@@ -204,18 +204,42 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
/// B99), so resolving one needs this too. A mirror folder has no mount to run
/// against: a file there is an ordinary local file.
fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!("{path} is in a mirrored folder; change the local file there instead")
})
}
/// [`route_to_mount`] for keeping a node offline or releasing it. A file in an
/// on-demand synced folder is a different Drive node from any file of the
/// same name under the primary mount, and keeping one offline did nothing for
/// the other: a program reading the synced folder's copy downloaded it
/// (`docs/BUGS.md` B281). A mirror folder already holds every file on disk.
fn route_keep(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
route_within(core, mountpoint, path, |path| {
format!(
"{path} is in a mirrored folder, which keeps all its files on this computer already"
)
})
}
/// [`route_to_mount`], refusing a path that leaves the mount it routed to and
/// answering a path in a mirror folder, which has no mount, with `mirrored`.
fn route_within(
core: &Core,
mountpoint: &Path,
path: &str,
mirrored: impl FnOnce(&str) -> String,
) -> CoreResult<(Core, PathBuf)> {
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
let p = Path::new(path);
let mirrored = core
let in_mirror = core
.db
.sync_folder_list()
.unwrap_or_default()
.iter()
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
if mirrored {
CoreError::invalid(format!(
"{path} is in a mirrored folder; change the local file there instead"
))
if in_mirror {
CoreError::invalid(mirrored(path))
} else {
e
}
@@ -231,6 +255,18 @@ fn route_change(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core,
}
}
/// The path the pin registry keeps for a pin made through `routed`: the
/// mount-relative path on the primary mount, as it always was, and the
/// absolute path the request named on an on-demand folder's mount, since
/// that node is not under the primary mountpoint (`docs/BUGS.md` B281).
fn pin_record(routed: &Core, rel: &Path, path: &str) -> PathBuf {
if routed.primary {
rel.to_path_buf()
} else {
Path::new(path).components().collect()
}
}
/// [`route_change`] for a request that trashes or renames the node it names.
/// A mount's root is My files or a whole synced folder. An on-demand folder's
/// root has a parent on Drive, so nothing further down would stop `pdfs rm
@@ -385,6 +421,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. }
| R::AttachSyncFolder { .. }
| R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. }
| R::SetSyncFolderPaused { .. }
@@ -520,8 +557,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(CtlRequest::Subscribe) => {
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) {
Ok(CtlRequest::Pin { path }) => match route_keep(core, mountpoint, &path) {
Ok((routed, rel)) => match routed.pin(&rel, &pin_record(&routed, &rel, &path)) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
@@ -532,8 +569,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.unpin(&rel) {
Ok(CtlRequest::Unpin { path }) => match route_keep(core, mountpoint, &path) {
Ok((routed, rel)) => match routed.unpin(&rel) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
@@ -938,6 +975,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
});
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default()
};
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
@@ -1397,6 +1436,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
message: format!("adding {local_path}"),
}
}
Ok(CtlRequest::AttachSyncFolder {
local_path,
remote_path,
}) => {
let result = rel_to_mount(mountpoint, &remote_path).and_then(|remote| {
core.attach_sync_folder(Path::new(&local_path), &remote, mountpoint)
});
match result {
Ok(folder) => CtlResponse::Ok {
message: format!("sync folder {} attached: {}", folder.id, folder.local_path),
},
Err(error) => CtlResponse::error(error),
}
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::error(e),
@@ -1526,9 +1579,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
let core = core.clone();
std::thread::spawn(move || {
match core.restore_sync_folders(&items, device.as_deref()) {
Ok(message) => {
Ok(outcome) => {
core.touch_profile();
core.log_activity(ActivityKind::Download, &message, "", true);
if !outcome.complete() {
warn!(skipped = ?outcome.skipped, "restore skipped folders");
}
core.log_activity(
ActivityKind::Download,
outcome.summary(),
outcome.skipped.join("; "),
outcome.complete(),
);
}
Err(e) => {
warn!(error = %e, "restore failed");
+27 -3
View File
@@ -33,6 +33,11 @@ use super::{
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
};
/// Registration validates local and remote root overlap before inserting a
/// mapping. Serialize all registration paths so concurrent control requests
/// cannot both validate against the same old set of roots.
pub(super) static SYNC_ROOT_REGISTRATION: Mutex<()> = Mutex::new(());
/// A mode that this daemon is allowed to apply.
///
/// Persisted and control-protocol values first pass through [`MountMode`] so
@@ -152,7 +157,9 @@ impl Core {
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
for location in &mut locations {
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
if let MountKind::Device { sync_folder_id } = &location.kind {
if let MountKind::Device { sync_folder_id } | MountKind::Attached { sync_folder_id } =
&location.kind
{
location.progress = progress.get(sync_folder_id).cloned();
location.paused = self.sync_folder_paused(*sync_folder_id);
}
@@ -495,7 +502,7 @@ impl Core {
}
/// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
pub(super) fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
@@ -513,6 +520,7 @@ impl Core {
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes.
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
let _registration = SYNC_ROOT_REGISTRATION.lock();
let meta = std::fs::metadata(local)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
if !meta.is_dir() {
@@ -699,6 +707,11 @@ impl Core {
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && delete_remote {
return Err(CoreError::invalid(
"attached My files folders cannot be deleted by stopping sync; omit --delete-remote",
));
}
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
// only the row would strand the mount: the path would keep serving a
// folder the daemon no longer tracks, and nothing would ever unmount it.
@@ -785,6 +798,11 @@ impl Core {
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && target_mode != ApplicableMountMode::Mirror {
return Err(CoreError::invalid(
"attached My files folders only support mirror mode; use the My files mount for online-only access",
));
}
let current_mode = ApplicableMountMode::parse(&folder.mode)
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
// A switch waits on the folder's reconcile, which a pause skips; refuse
@@ -960,6 +978,11 @@ impl Core {
.sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.kind == "attached" && mode != ApplicableMountMode::Mirror {
return Err(SwitchBlocked::Failed(
"attached My files folders only support mirror mode".into(),
));
}
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
return Err(SwitchBlocked::Superseded);
}
@@ -1199,7 +1222,7 @@ impl Core {
}
};
for folder in folders {
if folder.mode != "ondemand" {
if folder.kind != "device" || folder.mode != "ondemand" {
continue;
}
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
@@ -1395,6 +1418,7 @@ mod tests {
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
kind: "device".into(),
id: 7,
local_path: local_path.to_string(),
remote_uid: remote_uid.to_string(),
+1
View File
@@ -163,6 +163,7 @@ impl Core {
control_handlers: super::control::active_handlers(),
control_handler_limit: super::control::handler_limit(),
pending_ops: self.pending_ops_quickly(),
sync: Some(self.sync_engine.report()),
}
}
+7
View File
@@ -1281,6 +1281,13 @@ impl Core {
sent: (&sent.0, &sent.1),
landed: (&home.to_string(), &name),
};
// Counted from the op's retirement until the bytes are cached and the
// tree has the real uid, as an upload is. Uncounted, the queue read
// empty while that still ran: a delete in between took an open file
// for closed and dropped what its handle reads, and a script that
// waited for an empty queue read the file before it was cached
// (`docs/BUGS.md` B257).
let _landing = LandingUpload::begin(&self.landing_uploads);
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
CreateRetired::Withdrawn { trash } => {
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
+34 -6
View File
@@ -26,10 +26,10 @@ use futures::StreamExt as _;
use futures::stream::BoxStream;
use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid, ShareId};
use proton_drive_rs::{
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail,
ProtonDriveClient, RevisionReader, Thumbnail, ThumbnailType,
};
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -43,6 +43,8 @@ pub(crate) type OutcomeStream<'a> = BoxStream<'a, Result<(NodeUid, Result<()>)>>
pub(crate) trait DriveApi: Send + Sync {
async fn get_my_files_folder(&self) -> Result<Node>;
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId>;
async fn get_node(&self, uid: &NodeUid) -> Result<Option<Node>>;
async fn enumerate_nodes(&self, uids: &[NodeUid]) -> Result<Vec<Node>>;
@@ -116,6 +118,14 @@ pub(crate) trait DriveApi: Send + Sync {
async fn open_revision(&self, uid: &NodeUid) -> Result<Arc<dyn RevisionRead>>;
/// The file's thumbnail of `thumbnail_type`, or `None` when its revision
/// carries none.
async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>>;
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
@@ -305,6 +315,10 @@ impl RevisionRead for RevisionReader {
#[async_trait]
impl DriveApi for ProtonDriveClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
ProtonDriveClient::context_share_id(self, uid).await
}
async fn get_my_files_folder(&self) -> Result<Node> {
ProtonDriveClient::get_my_files_folder(self).await
}
@@ -346,7 +360,13 @@ impl DriveApi for ProtonDriveClient {
name: &str,
last_modification_time: Option<i64>,
) -> Result<NodeUid> {
ProtonDriveClient::create_folder(self, parent_uid, name, last_modification_time).await
ProtonDriveClient::create_folder(
self,
parent_uid,
name,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
)
.await
}
async fn upload_file(
@@ -378,7 +398,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead,
)
.await
@@ -403,7 +423,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
aead,
)
.await
@@ -423,7 +443,7 @@ impl DriveApi for ProtonDriveClient {
reader,
intended_size,
thumbnails,
last_modification_time,
last_modification_time.and_then(pdfs_core::timestamp::modification_time),
)
.await
}
@@ -433,6 +453,14 @@ impl DriveApi for ProtonDriveClient {
Ok(Arc::new(reader))
}
async fn download_thumbnail(
&self,
uid: &NodeUid,
thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
ProtonDriveClient::download_thumbnail(self, uid, thumbnail_type).await
}
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
+9 -1
View File
@@ -1434,6 +1434,14 @@ impl ProtonFs {
// (`ContentCache::clone_or_copy`).
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
let _ = std::fs::remove_file(&path);
// The drain lets go of the pending entry before it
// discards the blob, so a blob that went away under the
// copy is a revision that landed or was replaced: the
// same race the re-check below catches, only earlier.
if self.core.pending_blob(&uid).as_ref() != Some(&pending.path) {
warn!(%uid, attempt, "queued revision went away while opening for write; retrying");
continue;
}
error!(%uid, source = %pending.path.display(), error = %e,
"copy queued revision into write scratch failed");
break 'install Err(Errno::EIO);
@@ -2368,7 +2376,7 @@ impl ProtonFs {
// regardless — the file is gone from their point of view the moment the
// command returns. The same when the network goes
// away under the remote call below.
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name, open_now) {
Ok(()) => reply.ok(),
Err(e) => reply.error(e),
};
+271
View File
@@ -0,0 +1,271 @@
//! Keeping pinned files on this computer after they were pinned.
//!
//! A pin downloads a file once. Nothing downloaded it again afterwards: a
//! change from another device deleted the cached copy, a file added to a
//! pinned folder elsewhere was never fetched, and a download that failed while
//! the link was down stayed failed. Each of those left a file listed as kept
//! offline that the next read downloaded, and that could not be read at all
//! without a network (`docs/BUGS.md` B282).
//!
//! The keeper is one thread that checks pins again and downloads what is
//! missing or stale: every pin when the daemon starts and when the link comes
//! back, and the pins a batch of remote events touched once the batch is
//! applied. A remote change no longer deletes a pinned file's copy. The copy
//! is stored under the revision's `(mtime, size)`, so a read of a newer
//! revision does not take it, and the keeper replaces it once the new one is
//! downloaded.
use std::collections::HashSet;
use std::time::{Duration, Instant};
use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, info, warn};
use super::{Core, is_local_uid, node_size, parse_uid};
/// How often the keeper looks for work.
const KEEPER_TICK: Duration = Duration::from_secs(1);
/// The first wait before a check that could not download everything is tried
/// again. It doubles on every failure, up to [`RETRY_MAX`].
const RETRY_FIRST: Duration = Duration::from_secs(30);
const RETRY_MAX: Duration = Duration::from_secs(30 * 60);
/// What the keeper owes a check.
#[derive(Debug, Default)]
pub(crate) struct PinWork {
/// Every pin.
all: bool,
/// The pinned files and folders named here. A folder is checked whole.
nodes: HashSet<NodeUid>,
}
impl PinWork {
fn is_empty(&self) -> bool {
!self.all && self.nodes.is_empty()
}
fn merge(&mut self, other: PinWork) {
self.all |= other.all;
self.nodes.extend(other.nodes);
}
}
/// A file a pin keeps, as the tree knows it now.
pub(crate) struct KeptFile {
pub(crate) uid: NodeUid,
pub(crate) name: String,
pub(crate) mtime: i64,
pub(crate) size: u64,
}
impl Core {
/// Have the keeper check every pin.
pub(crate) fn recheck_all_pins(&self) {
self.pin_work.lock().all = true;
}
/// Have the keeper check the pinned file or folder `uid`.
pub(crate) fn recheck_pinned(&self, uid: &NodeUid) {
self.pin_work.lock().nodes.insert(uid.clone());
}
/// The keeper's loop, until the daemon stops. Starts with a check of every
/// pin, which catches up on whatever changed while the daemon was down.
///
/// Work that failed waits out a backoff of its own. New work does not wait
/// for it, so a file that keeps failing does not hold up the next change.
pub(crate) fn run_pin_keeper(&self) {
self.recheck_all_pins();
let mut backlog = PinWork::default();
let mut backlog_due = Instant::now();
let mut retry = RETRY_FIRST;
while self.shutdown.sleep(KEEPER_TICK) {
if !self.is_online() {
continue;
}
let mut work = std::mem::take(&mut *self.pin_work.lock());
let retrying = !backlog.is_empty() && Instant::now() >= backlog_due;
if retrying {
work.merge(std::mem::take(&mut backlog));
}
if work.is_empty() {
continue;
}
let failed = self.keep_pins(&work);
if failed.is_empty() {
if retrying {
retry = RETRY_FIRST;
}
continue;
}
backlog.merge(failed);
backlog_due = Instant::now() + retry;
retry = (retry * 2).min(RETRY_MAX);
}
}
/// Download every file `work` covers whose cached copy is missing or
/// stale. Returns what could not be checked or downloaded.
fn keep_pins(&self, work: &PinWork) -> PinWork {
let mut failed = PinWork::default();
let roots: Vec<NodeUid> = if work.all {
self.cache
.list_pins()
.iter()
.filter_map(|pin| parse_uid(&pin.uid))
.chain(work.nodes.iter().cloned())
.collect()
} else {
work.nodes.iter().cloned().collect()
};
let mut seen: HashSet<u64> = HashSet::new();
let mut files: Vec<u64> = Vec::new();
for uid in roots {
if self.shutdown.is_stopping() {
return failed;
}
if !self.cache.is_pinned(&uid) {
continue;
}
let found = {
let st = self.state();
st.by_uid.get(&uid).and_then(|&ino| {
let e = st.entries.get(&ino)?;
Some((ino, e.parent, e.node.is_folder()))
})
};
let Some((ino, parent, is_folder)) = found else {
debug!(%uid, "pin keeper: pinned node is not in the tree");
continue;
};
if is_folder {
match self.pin_subtree_files(ino) {
Ok(inos) => files.extend(inos.into_iter().filter(|i| seen.insert(*i))),
Err(e) => {
warn!(%uid, error = %e, "pin keeper: listing a kept folder failed");
failed.nodes.insert(uid);
}
}
continue;
}
// An event about this file dropped its parent's listing. Listing
// it again brings the file's entry up to the revision Drive has
// now, which is what the cached copy is checked against.
if !work.all
&& parent != ino
&& let Err(e) = self.ensure_children(parent)
{
warn!(%uid, error = ?e, "pin keeper: listing a kept file's folder failed");
failed.nodes.insert(uid);
continue;
}
if seen.insert(ino) {
files.push(ino);
}
}
let stale: Vec<KeptFile> = files
.into_iter()
.filter_map(|ino| self.kept_file(ino))
.filter(|f| !self.cache.is_cached(&f.uid, f.mtime, f.size))
.collect();
if stale.is_empty() {
return failed;
}
let job = self.transfers.begin_job("Updating files kept offline");
job.set_total(stale.len() as u64);
let (mut fetched, mut failures) = (0usize, 0usize);
for file in stale {
if self.shutdown.is_stopping() {
break;
}
// Unpinned while the check ran.
if !self.cache.is_pinned(&file.uid) {
job.step();
continue;
}
job.detail(file.name.clone());
match self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size) {
Ok(()) => fetched += 1,
Err(e) => {
warn!(uid = %file.uid, error = %e, "pin keeper: download failed");
failed.nodes.insert(file.uid);
failures += 1;
}
}
job.step();
}
info!(
fetched,
failed = failures,
"pin keeper: brought files kept offline up to date"
);
failed
}
/// The file at `ino`, as a pin keeps it. `None` for a folder, a file this
/// daemon still owes an upload for, or a file whose real size cannot be
/// read.
///
/// A queued write is ahead of Drive. Downloading Drive's revision would
/// cache what the write replaces, and the drain keeps the uploaded bytes
/// of a pinned file once they land.
///
/// A listing carries the ciphertext size until its size upgrade lands
/// (B12). Stored under that size, the copy never matched the plaintext it
/// holds, so every read downloaded the file again and every check
/// downloaded it once more.
pub(crate) fn kept_file(&self, ino: u64) -> Option<KeptFile> {
let (uid, name, mtime, size, provisional) = {
let st = self.state();
let e = st.entries.get(&ino)?;
let NodeKind::File { claimed_size, .. } = &e.node.kind else {
return None;
};
(
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
claimed_size.is_none(),
)
};
if is_local_uid(&uid) || self.pending.lock().contains_key(&uid) {
return None;
}
let size = match provisional {
true => self.resolve_provisional_size(ino, &uid)?,
false => size,
};
Some(KeptFile {
uid,
name,
mtime,
size,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn work_that_failed_keeps_every_node_and_the_full_check() {
let mut backlog = PinWork::default();
assert!(backlog.is_empty());
let uid = parse_uid("vol~a").unwrap();
backlog.merge(PinWork {
all: false,
nodes: HashSet::from([uid.clone()]),
});
backlog.merge(PinWork {
all: true,
nodes: HashSet::from([uid.clone()]),
});
assert!(backlog.all);
assert_eq!(backlog.nodes.len(), 1);
assert!(!backlog.is_empty());
}
}
+75 -24
View File
@@ -79,6 +79,7 @@ use proton_drive_rs::{
};
mod albums;
mod attach;
mod background;
mod conflicts;
mod control;
@@ -90,6 +91,7 @@ mod drive;
use drive::{DriveApi, RevisionRead};
mod events;
mod filesystem;
mod keeper;
mod link;
pub use filesystem::ProtonFs;
mod mount;
@@ -118,13 +120,14 @@ mod workers;
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
use link::BATCH_CALL_TIMEOUT;
pub(crate) use mount::is_stale_mount;
pub use mount::prepare_mountpoint;
pub use mount::{MountOptions, MountOutcome, mount};
use mount::{
SecondaryInsertRejection, SecondaryMount, clear_stale_mount, fuse_connection_id, spawn_session,
};
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
pub use systemd::idle_sleep;
pub use systemd::{idle_sleep, status as notify_status, stretch_watchdog_for_startup};
use tracing::{debug, error, info, warn};
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
use r#virtual::{
@@ -587,6 +590,8 @@ struct Core {
/// Nudges the drain workers: set true and notify to have them re-examine
/// the queue instead of waiting out their backoff.
drain_wake: Arc<(Mutex<bool>, Condvar)>,
/// The pins the keeper owes a check (`keeper`).
pin_work: Arc<Mutex<keeper::PinWork>>,
/// The daemon's stop signal, shared with every background loop this `Core`
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
shutdown: Arc<shutdown::Shutdown>,
@@ -728,6 +733,8 @@ struct Core {
/// can say what a pass is doing rather than just "syncing". An entry exists
/// only while that folder's reconcile pass is running.
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
/// Whether the folder-sync engine is alive, and which pass it is on.
sync_engine: Arc<sync::EngineHealth>,
/// Channel to the folder-sync engine: nudges it to
/// reconcile a folder, reconcile everything, or re-scan its watch set.
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
@@ -3161,6 +3168,28 @@ impl Core {
)
}
/// [`Core::resolve_anywhere`], widened to the Photos timeline for sharing.
///
/// A photo lives on the photos volume, which no location shows, so the
/// residency proof above never finds it. The timeline in the `photos` table
/// is this daemon's proof instead: it lists only photos on the user's own
/// volume, which are theirs to share. A photo in an album someone else
/// shared is not in it and stays refused.
pub(crate) fn resolve_shareable(&self, uid: &str) -> CoreResult<NodeUid> {
resolve_anywhere_with(
uid,
|uid| self.owns_visible_uid(uid),
|uid| {
let uid = uid.to_string();
Ok(self.db.mirror_contains_uid(&uid)?
|| !self
.db
.photos_by_uid(std::slice::from_ref(&uid))?
.is_empty())
},
)
}
fn source_parent_uid(&self, ino: u64, rel: &Path) -> CoreResult<NodeUid> {
let state = self.state();
let entry = state
@@ -3997,7 +4026,12 @@ impl Core {
/// the cached ones, as when the trash is sent at once: a partial write
/// fills its gaps from them, offline too (`docs/BUGS.md` B186). The final
/// close or the trash landing, whichever comes last, lets go of them.
fn queue_trash(&self, uid: &NodeUid, name: &str) -> Result<(), Errno> {
///
/// `open` is whether the caller found the file open. A create that landed
/// under a delete is trashed by its real uid before the tree has it, so
/// asking the tree alone took an open file for closed (`docs/BUGS.md`
/// B257).
fn queue_trash(&self, uid: &NodeUid, name: &str, open: bool) -> Result<(), Errno> {
self.require_uid_writable(uid)?;
// The bytes below are about to be discarded, and a drain worker may be
// reading them onto the wire right now. Stop it first: with several
@@ -4012,7 +4046,7 @@ impl Core {
error!(%uid, error = %e, "queueing trash failed");
Errno::EIO
})?;
let open_now = self.is_open_anywhere(uid);
let open_now = open || self.is_open_anywhere(uid);
if open_now {
let read_from = self.pending_blob(uid);
self.release_dropped_below(
@@ -4261,7 +4295,12 @@ impl Core {
/// recursive pin and downloads every descendant file on a background thread,
/// reported as a job: a big folder far outlasts the socket read timeout.
/// Returns a human message.
fn pin(&self, rel: &Path) -> CoreResult<String> {
///
/// `recorded` is the path the pin registry keeps for the front ends to
/// open the pin by: `rel` itself on the primary mount, which they join to
/// the mountpoint, and the absolute local path on an on-demand folder's
/// mount, whose node is not reachable under the primary mountpoint at all.
fn pin(&self, rel: &Path, recorded: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = {
let st = self.state();
@@ -4285,7 +4324,7 @@ impl Core {
// Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree.
self.cache
.add_pin(&uid, rel, true)
.add_pin(&uid, recorded, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
let core = self.clone();
let folder = name.clone();
@@ -4295,9 +4334,15 @@ impl Core {
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
}
// Stored under a provisional size, the copy would never match the
// plaintext it holds (`docs/BUGS.md` B282).
let size = match self.kept_file(ino) {
Some(file) if file.uid == uid => file.size,
_ => size,
};
self.fetch_content(&uid, &name, mtime, size)?;
self.cache
.add_pin(&uid, rel, false)
.add_pin(&uid, recorded, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name)
}
@@ -4315,14 +4360,20 @@ impl Core {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) in files {
for ino in files {
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
return;
}
job.detail(file.clone());
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
warn!(%uid, error = %e, "pin folder: download failed");
failed += 1;
// A file with a queued write, or no readable size, is
// left to the drain and the keeper (`keeper::kept_file`).
if let Some(file) = self.kept_file(ino) {
job.detail(file.name.clone());
if let Err(e) =
self.fetch_pinned(&file.uid, &file.name, file.mtime, file.size)
{
warn!(uid = %file.uid, error = %e, "pin folder: download failed");
failed += 1;
}
}
job.step();
}
@@ -4356,9 +4407,9 @@ impl Core {
Ok(())
}
/// Every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
/// The inode of every file in the subtree rooted at folder `ino`.
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<u64>> {
let mut files: Vec<u64> = Vec::new();
let mut stack = vec![ino];
while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
@@ -4373,12 +4424,7 @@ impl Core {
if e.node.is_folder() {
stack.push(k);
} else {
files.push((
e.uid.clone(),
e.node.name.clone(),
e.node.modification_time,
node_size(&e.node),
));
files.push(k);
}
}
}
@@ -5381,9 +5427,12 @@ impl Core {
/// `unlink` under an open handle. Its queued ops go now, or the drain would
/// upload to the node after it was trashed (`docs/BUGS.md` B106).
fn remove_replaced(&self, ino: u64, uid: &NodeUid, name: &str) -> Result<(), Errno> {
// Asked under the uid the tree has now: a landed create's real uid is
// not in the tree yet (`docs/BUGS.md` B257).
let open = self.is_open_anywhere(uid);
let landed;
let uid = if is_local_uid(uid) {
if self.is_open_anywhere(uid) {
if open {
self.withdraw_queued_ops(uid)?;
} else {
self.discard_queued_ops(uid)?;
@@ -5408,7 +5457,7 @@ impl Core {
// Local-first or offline, or the network goes away under the call:
// queue the trash. The rename that follows queues as well, behind it.
if !self.sends_inline() {
return self.queue_trash(uid, name);
return self.queue_trash(uid, name, open);
}
self.require_uid_writable(uid)?;
if let Err(e) = self
@@ -5416,7 +5465,7 @@ impl Core {
.and_then(batch::into_unit)
{
if self.lost_link(&e, "trash a replaced node") {
return self.queue_trash(uid, name);
return self.queue_trash(uid, name, open);
}
error!(%uid, name, error = %e, "trashing the node a rename replaces failed");
self.log_activity(ActivityKind::Trash, name, e.to_string(), false);
@@ -5989,6 +6038,7 @@ impl Core {
if let Some(p) = self.sync_progress.lock().get_mut(&folder_id) {
f(p);
}
self.sync_engine.moved();
}
/// Set the number of items the scan expects to check, from the size of the
@@ -6430,6 +6480,7 @@ fn sync_folder_info(
) -> SyncFolderInfo {
SyncFolderInfo {
id: f.id,
kind: f.kind,
local_path: f.local_path,
remote_uid: f.remote_uid,
mode: f.mode,
@@ -9255,7 +9306,7 @@ mod tests {
assert!(!replaced.contains("st.forget(uid)"));
assert_before(
replaced,
"if self.is_open_anywhere(uid)",
"let open = self.is_open_anywhere(uid)",
"self.discard_queued_ops(uid)",
);
assert_before(replaced, "if open_now", "self.cache.evict(uid)");
+3
View File
@@ -286,6 +286,9 @@ impl Core {
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
}
self.wake_drain();
// Downloads for pins failed while the link was down, and the events
// that would have named the files are behind us (`docs/BUGS.md` B282).
self.recheck_all_pins();
}
/// Wait up to `budget` for the link to come back. Returns whether it is up.
+122 -28
View File
@@ -196,6 +196,43 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
}
}
/// Make sure `mountpoint` is a directory a mount can go on, clearing a dead
/// mount a killed daemon left there first.
///
/// A dead mount answers `ENOTCONN`, which `create_dir_all` takes for "absent";
/// it then tries `mkdir` and fails with `EEXIST`. The daemon retried that error
/// forever and never got as far as [`check_mountpoint`], which would have
/// cleared the mount (`docs/BUGS.md` B279).
pub fn prepare_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
if is_stale_mount(mountpoint) {
clear_stale_mount(mountpoint);
}
std::fs::create_dir_all(mountpoint)
}
/// Refuse to mount My files over a folder that already has files in it: FUSE
/// would hide them, unsent and out of sight, until the service stops
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
/// starts, so a refusal costs nothing to retry.
///
/// A dead mount is cleared first, since it cannot be listed. One of this
/// client's own live mounts is left to [`spawn_session`], which clears it as it
/// always has; listing it would show Drive's files, not the folder's.
pub(super) fn check_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
if is_stale_mount(mountpoint) {
clear_stale_mount(mountpoint);
}
if pdfs_core::mountpoint::mounted_fs_type(mountpoint).as_deref()
== Some(pdfs_core::mountpoint::OWN_FS_TYPE)
{
return Ok(());
}
match pdfs_core::mountpoint::not_empty(mountpoint)? {
Some(found) => Err(std::io::Error::other(found)),
None => Ok(()),
}
}
/// `sync_state` key holding the uid of the My Files root, so a later run can
/// recover it from `nodes` without the API.
const ROOT_UID_KEY: &str = "root_uid";
@@ -427,6 +464,7 @@ pub(crate) fn mount_with(
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own.
super::diagnostics::start_clock();
check_mountpoint(mountpoint)?;
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
let online = start == Start::Online;
let scope = root.tree_event_scope_id();
@@ -473,6 +511,7 @@ pub(crate) fn mount_with(
landed: Arc::new(Mutex::new(HashMap::new())),
unadopted: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
pin_work: Arc::new(Mutex::new(Default::default())),
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
upload_times: Arc::new(Mutex::new(HashMap::new())),
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
@@ -507,6 +546,7 @@ pub(crate) fn mount_with(
events: Arc::new(Default::default()),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_engine: Arc::default(),
sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
@@ -540,12 +580,11 @@ pub(crate) fn mount_with(
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
for worker in 0..DRAIN_WORKERS {
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name(format!("pdfs-drain-{worker}"))
.spawn(move || core.run_pending_drain(worker == 0))?,
);
let drain_core = core.clone();
let spawned = std::thread::Builder::new()
.name(format!("pdfs-drain-{worker}"))
.spawn(move || drain_core.run_pending_drain(worker == 0));
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Start the folder-sync engine. It watches every mirror folder, polls the
@@ -565,24 +604,33 @@ pub(crate) fn mount_with(
info!("conflict sweep disabled by configuration");
} else {
info!(mode = ?sweep_mode, "conflict sweep enabled");
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || core.run_conflict_sweep_loop())?,
);
let sweep_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-conflict-sweep".into())
.spawn(move || sweep_core.run_conflict_sweep_loop());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Watch the link for the life of the mount: a mount from the cache waits for
// the network to come back, and an online one can lose it at any moment and
// must notice it coming back just the same.
{
let core = core.clone();
workers.push(
std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || core.run_online_probe())?,
);
let probe_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || probe_core.run_online_probe());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep pinned files on this computer through changes made elsewhere, and
// fetch the ones a failed or missed download left out (`docs/BUGS.md`
// B282). Its own thread: a check lists folders and downloads files.
{
let keeper_core = core.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-pin-keeper".into())
.spawn(move || keeper_core.run_pin_keeper());
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
@@ -593,11 +641,10 @@ pub(crate) fn mount_with(
let transfers = core.transfers.clone();
let mountpoint = mountpoint.to_path_buf();
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))?,
);
let spawned = std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown));
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
}
// Bind the control socket before the FUSE session takes over the thread. A
@@ -606,12 +653,18 @@ pub(crate) fn mount_with(
let old_umask = unsafe { libc::umask(0o77) };
let listener_res = UnixListener::bind(control_socket);
unsafe { libc::umask(old_umask) };
let listener = listener_res?;
let listener = match listener_res {
Ok(listener) => listener,
Err(error) => {
abandon_workers(&core, control_socket, workers);
return Err(error);
}
};
// Owner-only before anything can connect: a peer on this socket commands the
// daemon's authenticated session without a credential of its own (B6).
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
let _ = std::fs::remove_file(control_socket);
abandon_workers(&core, control_socket, workers);
return Err(std::io::Error::other(format!(
"control socket permissions: {e}"
)));
@@ -622,7 +675,7 @@ pub(crate) fn mount_with(
let bg = match spawn_session(&core, mountpoint, root) {
Ok(session) => session,
Err(error) => {
let _ = std::fs::remove_file(control_socket);
abandon_workers(&core, control_socket, workers);
return Err(error);
}
};
@@ -642,7 +695,7 @@ pub(crate) fn mount_with(
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);
abandon_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() {
@@ -692,8 +745,12 @@ pub(crate) fn mount_with(
}
}
// The mount is live and the control socket answers: this is the moment the
// unit is actually usable, which is what `Type=notify` reports.
// unit is actually usable, which is what `Type=notify` reports. Whatever
// failure an earlier attempt recorded for the front ends is over.
systemd::ready();
if let Some(state_dir) = control_socket.parent() {
pdfs_core::mountfail::clear(state_dir);
}
// 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
@@ -859,6 +916,43 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
// 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();
halt_workers(core, control_socket, workers);
}
/// Stop the threads a mount attempt started before it failed.
///
/// The early returns used to leave them running: every retry started another
/// set beside the last one's, and their `Core` kept the database open, so the
/// next attempt could fail on its lock instead of the real error
/// (`docs/BUGS.md` B264). Unlike [`stop_workers`] this does not tell systemd
/// the daemon is stopping, because it is about to try again, and a manager
/// told so would wait for the process to exit and then kill it.
fn abandon_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
halt_workers(core, control_socket, workers);
}
/// Keep a worker the mount just spawned, or, if the spawn failed, stop the
/// ones already running and hand the error back.
fn keep_or_abandon(
core: &Core,
control_socket: &Path,
workers: &mut Vec<std::thread::JoinHandle<()>>,
spawned: std::io::Result<std::thread::JoinHandle<()>>,
) -> std::io::Result<()> {
match spawned {
Ok(handle) => {
workers.push(handle);
Ok(())
}
Err(error) => {
abandon_workers(core, control_socket, std::mem::take(workers));
Err(error)
}
}
}
/// Signal every worker to stop, then join them up to [`WORKER_JOIN_DEADLINE`].
fn halt_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
core.shutdown.stop();
core.wake_drain();
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
+608 -72
View File
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::{Read as _, Write as _};
use std::os::unix::fs::OpenOptionsExt as _;
use std::os::unix::fs::{FileExt as _, OpenOptionsExt as _};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -20,14 +20,17 @@ use base64::Engine as _;
use pdfs_core::control::{
FileThumbRequest, MappingProgress, PhotoItem, PhotoKind, PhotoLocation, PhotoThumb, PlaceInfo,
ThumbnailBuildStatus, Topic, is_raw_image_name, is_thumbnail_image_name,
is_thumbnail_media_name,
};
use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
use tracing::{debug, info, warn};
use super::drive::RevisionRead;
use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
node_size, now_ms, parse_uid,
@@ -58,9 +61,18 @@ const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
static FFMPEG_MISSING_WARNING: Once = Once::new();
/// Largest video downloaded whole to grab one frame for its thumbnail. The
/// download lands in memory, so a long 4K recording is left without one.
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// How much of a video's start a thumbnail downloads. The first frame, and
/// the one a second in, sit inside it at any common bitrate. A video is never
/// downloaded whole for one frame: a recording can be many gigabytes.
const VIDEO_HEAD_BYTES: u64 = 16 * 1024 * 1024;
/// A video no larger than this is downloaded whole: its head and tail would
/// cover most of it anyway.
const VIDEO_WHOLE_MAX_BYTES: u64 = 24 * 1024 * 1024;
/// Largest MP4/MOV index (`moov` box) fetched from past the head. An hour of
/// video indexes in a few MiB; anything larger is not worth one frame.
const VIDEO_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
/// Top-level boxes walked looking for the index before giving up.
const VIDEO_BOX_WALK_MAX: usize = 32;
/// A single frame is seconds of work even for 4K; this only stops a wedged
/// ffmpeg from holding a thumbnail permit.
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
@@ -72,6 +84,105 @@ const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
/// Largest still image read whole to render the thumbnail its upload carries.
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
/// How much memory every thumbnail decode in the daemon may hold at once:
/// the encoded file plus its decoded pixels.
///
/// Without a limit, each of the 16 drain workers decoded its own full-size
/// photo. A batch of 100-megapixel JPEGs then held about 9 GiB, which swapped the
/// machine until the watchdog killed the daemon, on every restart
/// (`docs/BUGS.md` B280). With this budget, a phone photo still decodes beside
/// several others, and a 100-megapixel one decodes alone.
const DECODE_BUDGET_BYTES: u64 = 512 * 1024 * 1024;
/// The process-wide [`DecodeBudget`] every thumbnail decode draws from.
static DECODE_BUDGET: DecodeBudget = DecodeBudget::new(DECODE_BUDGET_BYTES);
/// A counting gate over bytes: [`acquire`](Self::acquire) waits until the cost
/// fits beside what is already held.
struct DecodeBudget {
limit: u64,
used: std::sync::Mutex<u64>,
freed: std::sync::Condvar,
}
/// A share of a [`DecodeBudget`], given back on drop.
struct DecodePermit<'a> {
budget: &'a DecodeBudget,
cost: u64,
}
impl DecodeBudget {
const fn new(limit: u64) -> Self {
Self {
limit,
used: std::sync::Mutex::new(0),
freed: std::sync::Condvar::new(),
}
}
/// Wait until `cost` bytes fit, and hold them. A cost above the limit is
/// cut down to the limit, so the largest image waits for an idle budget
/// and then runs alone, instead of waiting forever.
fn acquire(&self, cost: u64) -> DecodePermit<'_> {
let cost = cost.clamp(1, self.limit);
let mut used = self
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while *used + cost > self.limit {
used = self
.freed
.wait(used)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
*used += cost;
DecodePermit { budget: self, cost }
}
}
impl Drop for DecodePermit<'_> {
fn drop(&mut self) {
let mut used = self
.budget
.used
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*used -= self.cost;
self.budget.freed.notify_all();
}
}
/// What decoding an image of `encoded_len` bytes and `dimensions` costs in
/// memory: the encoded bytes, the decoded pixels at up to 4 bytes each, and a
/// second copy of the pixels when [`decode_upright`] rotates them. When the
/// header gives no dimensions, assume the image is compressed 16 to 1.
fn decode_cost(encoded_len: u64, dimensions: Option<(u32, u32)>) -> u64 {
let pixels = match dimensions {
Some((width, height)) => u64::from(width) * u64::from(height) * 4 * 2,
None => encoded_len.saturating_mul(16),
};
encoded_len.saturating_add(pixels)
}
/// The pixel dimensions in the header of an encoded image, without decoding it.
fn dimensions_of(bytes: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// [`dimensions_of`] for an image file. Reads only the header.
fn dimensions_of_file(path: &Path) -> Option<(u32, u32)> {
image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
@@ -146,8 +257,8 @@ struct RawPreviewUnavailable;
/// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt {
Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for, or a video
/// too large to fetch for one frame. Permanent.
/// Decoded nothing: a format this build has no decoder for, a video
/// ffmpeg takes no frame from, or a `.ts` that is not one. Permanent.
Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
@@ -209,7 +320,14 @@ fn thumbnail_build_may_start(status: &ThumbnailBuildStatus, path: &str) -> CoreR
/// formats return an undecodable verdict.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
/// Waits for its share of [`DECODE_BUDGET`] first.
fn scale_thumbnail(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
let _permit = DECODE_BUDGET.acquire(decode_cost(bytes.len() as u64, dimensions_of(bytes)));
scale_thumbnail_unbudgeted(bytes, name, staging_dir)
}
/// [`scale_thumbnail`] for a caller that already holds its [`DecodePermit`].
fn scale_thumbnail_unbudgeted(bytes: &[u8], name: &str, staging_dir: &Path) -> ScaleAttempt {
scale_thumbnail_with_exiftool(
bytes,
name,
@@ -471,21 +589,6 @@ fn warn_ffmpeg_missing() {
});
}
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
/// staging copy. See [`video_file_thumbnail`].
fn video_thumbnail(
bytes: &[u8],
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
return ScaleAttempt::Unavailable;
};
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// A thumbnail from one frame of the video at `path`. A second in, past the
/// black lead-in most clips open on, and the first frame for a clip shorter than
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
@@ -518,6 +621,151 @@ fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> Scale
ScaleAttempt::Undecodable
}
/// What a thumbnail is made from.
enum Fetched {
/// The thumbnail itself, from the server.
Ready(Vec<u8>),
/// Nothing: a video with no server thumbnail, and no ffmpeg to take one.
Nothing,
/// A whole image.
Whole(Vec<u8>),
Video(VideoSample),
}
/// The parts of a video its thumbnail needs, downloaded: the start of the
/// file, and for an MP4 or MOV whose index is further in, that index.
struct VideoSample {
/// The whole file's length.
size: u64,
head: Vec<u8>,
/// The `moov` box and its offset, when it is not inside `head`.
index: Option<(u64, Vec<u8>)>,
}
impl VideoSample {
/// Whether this could be an MPEG transport stream: one opens on the 0x47
/// sync byte, and repeats it every 188-byte packet. Only asked of `.ts`
/// files, which are TypeScript source as often as they are video.
fn is_transport_stream(&self) -> bool {
self.head.first() == Some(&0x47) && self.head.get(188).is_none_or(|&byte| byte == 0x47)
}
}
/// Download what a thumbnail of the video `reader` reads needs. A file of up
/// to [`VIDEO_WHOLE_MAX_BYTES`] comes whole. A larger one brings its first
/// [`VIDEO_HEAD_BYTES`], which is all a stream format (Matroska, WebM, MPEG-TS,
/// AVI) needs for a frame, plus the index of an ISO media file (MP4, MOV, 3GP)
/// written with its index after the media data, which is how most cameras and
/// phones write it.
async fn sample_video(reader: &dyn RevisionRead) -> DriveResult<VideoSample> {
let size = reader.size();
if size <= VIDEO_WHOLE_MAX_BYTES {
let head = reader.read_at(0, size).await?;
return Ok(VideoSample {
size,
head,
index: None,
});
}
let head = reader.read_at(0, VIDEO_HEAD_BYTES).await?;
let index = match iso_media_index(reader, &head, size).await? {
Some((offset, len)) if offset.saturating_add(len) > head.len() as u64 => {
Some((offset, reader.read_at(offset, len).await?))
}
_ => None,
};
Ok(VideoSample { size, head, index })
}
/// Where the `moov` box of an ISO media file sits, as `(offset, length)`.
/// `None` for a file that is not one, or whose index is missing, oversized or
/// past [`VIDEO_BOX_WALK_MAX`] boxes. Box headers past `head` are read one at a
/// time, 16 bytes each.
async fn iso_media_index(
reader: &dyn RevisionRead,
head: &[u8],
size: u64,
) -> DriveResult<Option<(u64, u64)>> {
const FIRST_BOXES: [&[u8; 4]; 6] = [b"ftyp", b"moov", b"mdat", b"wide", b"free", b"skip"];
if !head
.get(4..8)
.is_some_and(|kind| FIRST_BOXES.iter().any(|known| kind == *known))
{
return Ok(None);
}
let mut offset = 0u64;
for _ in 0..VIDEO_BOX_WALK_MAX {
if offset.saturating_add(8) > size {
break;
}
let header = match usize::try_from(offset)
.ok()
.and_then(|start| head.get(start..start.checked_add(16)?))
{
Some(header) => header.to_vec(),
None => reader.read_at(offset, 16).await?,
};
let Some((kind, len)) = iso_box_header(&header, size - offset) else {
break;
};
if kind == *b"moov" {
return Ok((len <= VIDEO_INDEX_MAX_BYTES).then_some((offset, len)));
}
offset = offset.saturating_add(len);
}
Ok(None)
}
/// The type and length of the ISO box whose header starts `header`, of a box
/// that may run at most `left` bytes to the end of the file.
fn iso_box_header(header: &[u8], left: u64) -> Option<([u8; 4], u64)> {
let size = u32::from_be_bytes(header.get(0..4)?.try_into().ok()?);
let kind: [u8; 4] = header.get(4..8)?.try_into().ok()?;
let len = match size {
// Runs to the end of the file.
0 => left,
// The real length follows as 64 bits.
1 => u64::from_be_bytes(header.get(8..16)?.try_into().ok()?),
len => u64::from(len),
};
(8..=left).contains(&len).then_some((kind, len))
}
/// A thumbnail from one frame of a downloaded [`VideoSample`]. The sample is
/// laid out in a staging file at the offsets it came from, so ffmpeg finds an
/// index where the file's own boxes say it is. The gap between holds no data
/// and costs no disk.
fn video_sample_thumbnail(
sample: &VideoSample,
name: &str,
staging_dir: &Path,
ffmpeg: &OsStr,
timeout: Duration,
) -> ScaleAttempt {
let transport_stream_name = Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
if transport_stream_name && !sample.is_transport_stream() {
return ScaleAttempt::Undecodable;
}
let Some(file) = RawTempFile::create_in(staging_dir, &sample.head, name) else {
return ScaleAttempt::Unavailable;
};
if let Some((offset, index)) = &sample.index {
let laid_out = OpenOptions::new()
.write(true)
.open(&file.0)
.and_then(|staged| {
staged.write_all_at(index, *offset)?;
staged.set_len(sample.size.max(offset + index.len() as u64))
});
if laid_out.is_err() {
return ScaleAttempt::Unavailable;
}
}
video_file_thumbnail(&file.0, ffmpeg, timeout)
}
/// The thumbnail to attach to an upload of the local file at `path`, which is
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
/// the gallery has to download the whole file to make its own.
@@ -544,11 +792,20 @@ fn upload_thumbnails_with(
}
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
} else if is_thumbnail_image_name(name) {
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
let Some(len) = std::fs::metadata(path)
.ok()
.map(|meta| meta.len())
.filter(|len| *len <= UPLOAD_IMAGE_MAX_BYTES)
else {
return Vec::new();
};
scale_thumbnail(&bytes, name, staging_dir)
// Taken before the read, so the encoded bytes count against the
// budget too.
let _permit = DECODE_BUDGET.acquire(decode_cost(len, dimensions_of_file(path)));
let Ok(bytes) = std::fs::read(path) else {
return Vec::new();
};
scale_thumbnail_unbudgeted(&bytes, name, staging_dir)
} else {
return Vec::new();
};
@@ -770,6 +1027,7 @@ impl Core {
favorite: photo.favorite,
group_size: photo.group_size as u32,
has_raw: photo.has_raw,
place_id: photo.place_id,
}
}
@@ -1291,10 +1549,10 @@ impl Core {
.map(|item| (item.uid.clone(), item.name.clone()))
.collect();
// Proton only stores server-generated thumbnails for nodes in Photos.
// These requests come exclusively from ordinary Drive, Shared and Trash
// views, so downloading a remote thumbnail here would be a guaranteed
// miss. Generate directly from the full image and cache the result.
// Drive makes no thumbnails itself; a file has one only when its
// uploader attached it, as this client and Proton's apps do. The
// generation step asks for that first, and only makes one from the
// file's content without it.
let mut wanted = Vec::new();
let mut seen = HashSet::new();
for (raw, uid, modified) in &parsed {
@@ -1344,7 +1602,7 @@ impl Core {
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
let previous = self
.file_thumb_generation
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
Some(next(current))
})
.expect("generation update always succeeds");
@@ -1445,7 +1703,10 @@ impl Core {
return (uid, ThumbAttempt::Cancelled);
}
let video = is_video_name(&name);
if video && !ffmpeg_available() {
// Without ffmpeg a video can still have its uploader's
// thumbnail, but Photos has asked the server already.
let can_sample = !video || ffmpeg_available();
if !can_sample && matches!(job, ThumbJob::Photos) {
return (uid, ThumbAttempt::Unavailable);
}
@@ -1494,21 +1755,34 @@ impl Core {
return (uid, ThumbAttempt::Cancelled);
}
// A video is downloaded whole for one frame, so a long
// recording is not worth it — and would not fit in memory.
if video {
match core.photos().get_node(&uid).await {
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
return (uid, ThumbAttempt::Undecodable);
}
Ok(Some(_)) => {}
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
}
}
let download_uid = uid.clone();
let download = async move { client.download_file(&download_uid).await };
let drive = core.drive.clone();
let download = async move {
// Photos asked the server in one batch already. A
// file view's or a build's file may carry a
// thumbnail its uploader made, which costs one
// small download instead of its content.
if !matches!(job, ThumbJob::Photos)
&& let Some(bytes) = drive
.download_thumbnail(&download_uid, ThumbnailType::Thumbnail)
.await?
.filter(|bytes| !bytes.is_empty())
{
return Ok(Fetched::Ready(bytes));
}
if !can_sample {
return Ok(Fetched::Nothing);
}
if video {
let reader = drive.open_revision(&download_uid).await?;
sample_video(reader.as_ref()).await.map(Fetched::Video)
} else {
client
.download_file(&download_uid)
.await
.map(Fetched::Whole)
}
};
tokio::pin!(download);
let downloaded = match job {
ThumbJob::Files(_) => loop {
@@ -1546,10 +1820,10 @@ impl Core {
let Some(downloaded) = downloaded else {
return (uid, ThumbAttempt::Cancelled);
};
let bytes = match downloaded {
Ok(bytes) => bytes,
let fetched = match downloaded {
Ok(fetched) => fetched,
Err(e) => {
warn!(%uid, error = %e, "image download for thumbnail failed");
warn!(%uid, error = %e, "download for a thumbnail failed");
return (uid, ThumbAttempt::Unavailable);
}
};
@@ -1559,18 +1833,20 @@ impl Core {
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
let made = tokio::task::spawn_blocking(move || {
if video {
video_thumbnail(
&bytes,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
)
} else {
scale_thumbnail(&bytes, &name, &staging_dir)
}
let made = tokio::task::spawn_blocking(move || match fetched {
Fetched::Video(sample) => video_sample_thumbnail(
&sample,
&name,
&staging_dir,
OsStr::new("ffmpeg"),
FFMPEG_TIMEOUT,
),
Fetched::Whole(bytes) => scale_thumbnail(&bytes, &name, &staging_dir),
Fetched::Nothing => ScaleAttempt::Unavailable,
Fetched::Ready(bytes) => match ratio_of(&bytes) {
Some(ratio) => ScaleAttempt::Made(GeneratedThumb { bytes, ratio }),
None => ScaleAttempt::Undecodable,
},
})
.await
.unwrap_or(ScaleAttempt::Unavailable);
@@ -1754,7 +2030,7 @@ impl Core {
if seen_folders.insert(entry.uid) {
folders.push(folder.join(entry.name));
}
} else if is_thumbnail_image_name(&entry.name) {
} else if is_thumbnail_media_name(&entry.name, Some(entry.size)) {
match parse_uid(&entry.uid) {
Some(uid) if seen_images.insert(uid.clone()) => {
found_here += 1;
@@ -2116,19 +2392,83 @@ impl Core {
}
}
#[cfg(test)]
mod decode_budget_tests {
use super::{DecodeBudget, decode_cost};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
/// A decode that does not fit beside the ones running waits for them,
/// instead of every drain worker decoding at once (`docs/BUGS.md` B280).
#[test]
fn a_decode_over_the_budget_waits_for_the_running_one() {
let budget = Arc::new(DecodeBudget::new(100));
let first = budget.acquire(60);
let started = Arc::new(AtomicBool::new(false));
let waiter = {
let (budget, started) = (budget.clone(), started.clone());
std::thread::spawn(move || {
let _second = budget.acquire(60);
started.store(true, Ordering::SeqCst);
})
};
std::thread::sleep(Duration::from_millis(100));
assert!(
!started.load(Ordering::SeqCst),
"second decode ran over budget"
);
drop(first);
waiter.join().expect("waiter");
assert!(started.load(Ordering::SeqCst));
}
/// Decodes that fit together run together.
#[test]
fn small_decodes_share_the_budget() {
let budget = DecodeBudget::new(100);
let _a = budget.acquire(30);
let _b = budget.acquire(30);
let _c = budget.acquire(30);
}
/// An image bigger than the whole budget still decodes, alone, rather than
/// waiting forever.
#[test]
fn an_image_over_the_whole_budget_still_decodes() {
let budget = DecodeBudget::new(100);
drop(budget.acquire(10_000));
let _again = budget.acquire(100);
}
/// The cost of the 102-megapixel JPEG that set off B280 fills the real
/// budget, so it never decodes beside another one.
#[test]
fn a_hundred_megapixel_photo_costs_the_whole_budget() {
let cost = decode_cost(97_266_141, Some((11_656, 8_742)));
assert!(cost >= super::DECODE_BUDGET_BYTES);
let phone = decode_cost(4_000_000, Some((4_000, 3_000)));
assert!(phone * 4 < super::DECODE_BUDGET_BYTES);
}
}
#[cfg(test)]
mod thumb_tests {
use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
VIDEO_HEAD_BYTES, VIDEO_WHOLE_MAX_BYTES, VideoSample, can_make_thumbnail, exiftool_binary,
ffmpeg_available, fit_upload_thumb, is_video_name, ratio_of, sample_video, scale_thumbnail,
scale_thumbnail_with_exiftool, similar_hashes, thumbnail_build_may_start,
upload_thumbnails_with, video_sample_thumbnail,
};
use crate::drive::RevisionRead;
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType;
use proton_drive_rs::proton_sdk::error::Result as DriveResult;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
/// A `width`×`height` JPEG, standing in for a camera photo the server never
@@ -2361,6 +2701,199 @@ mod thumb_tests {
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
}
/// A sample of a video small enough to come whole.
fn whole(bytes: &[u8]) -> VideoSample {
VideoSample {
size: bytes.len() as u64,
head: bytes.to_vec(),
index: None,
}
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(future)
}
/// A Drive revision of `size` bytes, zero except for the parts written in,
/// that counts the bytes read from it. Stands for a recording far larger
/// than a test should hold in memory.
struct FakeRevision {
size: u64,
parts: Vec<(u64, Vec<u8>)>,
read: AtomicU64,
}
impl FakeRevision {
fn new(size: u64) -> Self {
Self {
size,
parts: Vec::new(),
read: AtomicU64::new(0),
}
}
fn with(mut self, offset: u64, bytes: Vec<u8>) -> Self {
self.parts.push((offset, bytes));
self
}
}
#[async_trait::async_trait]
impl RevisionRead for FakeRevision {
fn size(&self) -> u64 {
self.size
}
fn block_sizes(&self) -> &[u64] {
&[]
}
async fn read_at(&self, offset: u64, length: u64) -> DriveResult<Vec<u8>> {
let end = offset.saturating_add(length).min(self.size);
let mut out = vec![0u8; end.saturating_sub(offset) as usize];
for (start, bytes) in &self.parts {
let part_end = start + bytes.len() as u64;
let (from, to) = (offset.max(*start), end.min(part_end));
if from < to {
out[(from - offset) as usize..(to - offset) as usize]
.copy_from_slice(&bytes[(from - start) as usize..(to - start) as usize]);
}
}
self.read.fetch_add(out.len() as u64, Ordering::Relaxed);
Ok(out)
}
}
/// An ISO media box of `len` bytes, of which only the header is returned.
fn iso_box_header(kind: &[u8; 4], len: u64) -> Vec<u8> {
if let Ok(len) = u32::try_from(len) {
[&len.to_be_bytes()[..], kind].concat()
} else {
[&1u32.to_be_bytes()[..], kind, &len.to_be_bytes()[..]].concat()
}
}
const GIB: u64 = 1024 * 1024 * 1024;
#[test]
fn a_large_stream_video_downloads_its_head_alone() {
let mut head = vec![0u8; 188 * 2];
head[0] = 0x47;
head[188] = 0x47;
let revision = FakeRevision::new(9 * GIB).with(0, head);
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.size, 9 * GIB);
assert_eq!(sample.head.len() as u64, VIDEO_HEAD_BYTES);
assert!(sample.index.is_none());
assert!(sample.is_transport_stream());
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
}
/// A camera MP4 keeps its index after gigabytes of media data. The sample
/// is the head, the box headers on the way, and the index itself.
#[test]
fn a_large_mp4_downloads_its_head_and_its_index() {
let ftyp = [
iso_box_header(b"ftyp", 24),
b"isom\0\0\0\0mp41isom".to_vec(),
]
.concat();
let mdat_len = 6 * GIB;
let moov_at = ftyp.len() as u64 + mdat_len;
let moov = [iso_box_header(b"moov", 4096), vec![7u8; 4088]].concat();
let size = moov_at + moov.len() as u64;
let revision = FakeRevision::new(size)
.with(0, ftyp.clone())
.with(ftyp.len() as u64, iso_box_header(b"mdat", mdat_len))
.with(moov_at, moov.clone());
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.index, Some((moov_at, moov)));
assert!(
revision.read.load(Ordering::Relaxed) <= VIDEO_HEAD_BYTES + 4096 + 16,
"read {} bytes",
revision.read.load(Ordering::Relaxed)
);
}
#[test]
fn an_mp4_with_its_index_up_front_downloads_its_head_alone() {
let ftyp = [iso_box_header(b"ftyp", 16), b"isom\0\0\0\0".to_vec()].concat();
let moov = [iso_box_header(b"moov", 64), vec![1u8; 56]].concat();
let revision = FakeRevision::new(GIB)
.with(0, ftyp.clone())
.with(ftyp.len() as u64, moov)
.with(80, iso_box_header(b"mdat", GIB - 80));
let sample = block_on(sample_video(&revision)).unwrap();
assert!(sample.index.is_none());
assert_eq!(revision.read.load(Ordering::Relaxed), VIDEO_HEAD_BYTES);
}
#[test]
fn a_small_video_downloads_whole() {
let revision = FakeRevision::new(VIDEO_WHOLE_MAX_BYTES).with(0, vec![3u8; 64]);
let sample = block_on(sample_video(&revision)).unwrap();
assert_eq!(sample.head.len() as u64, VIDEO_WHOLE_MAX_BYTES);
assert!(sample.index.is_none());
}
/// `.ts` that is TypeScript reaches ffmpeg never: the sync byte is missing,
/// and that is a final answer, not a retry.
#[test]
fn a_ts_without_the_sync_byte_is_no_video() {
let attempt = video_sample_thumbnail(
&whole(b"export const answer = 42;"),
"bundle.ts",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
Duration::from_millis(10),
);
assert!(matches!(attempt, ScaleAttempt::Undecodable));
}
/// ffmpeg reads the index where the file's own boxes put it, so the staging
/// file has the full length and the index at its offset.
#[test]
fn the_index_is_staged_at_its_own_offset() {
let tmp = scratch("video-index");
let frame = tmp.join("frame.jpg");
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(&frame, jpeg(640, 360)).unwrap();
let size: u64 = 3 * GIB;
let at: u64 = size - 4096;
// Arguments: -nostdin -loglevel error -ss <seek> -i <path> …
let ffmpeg = fake_ffmpeg(
&tmp,
&format!(
"[ \"$(stat -c %s \"$7\")\" = {size} ] && \
[ \"$(tail -c +{} \"$7\" | head -c 4)\" = moov ] && cat '{}'",
// The box type, after its 4-byte length; `tail` counts from 1.
at + 5,
frame.display()
),
);
let sample = VideoSample {
size,
head: vec![0u8; 1024],
index: Some((
at,
[iso_box_header(b"moov", 4096), vec![0u8; 4088]].concat(),
)),
};
let attempt = video_sample_thumbnail(
&sample,
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
Duration::from_secs(10),
);
std::fs::remove_dir_all(&tmp).unwrap();
assert!(matches!(attempt, ScaleAttempt::Made(_)));
}
#[test]
fn a_video_frame_becomes_a_thumbnail() {
let tmp = scratch("video-frame");
@@ -2369,8 +2902,8 @@ mod thumb_tests {
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
let ScaleAttempt::Made(thumb) = video_thumbnail(
b"video bytes",
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
&whole(b"video bytes"),
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
@@ -2386,8 +2919,8 @@ mod thumb_tests {
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
let tmp = scratch("video-broken");
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
let attempt = video_thumbnail(
b"not a video",
let attempt = video_sample_thumbnail(
&whole(b"not a video"),
"clip.mp4",
&tmp,
ffmpeg.as_os_str(),
@@ -2399,8 +2932,8 @@ mod thumb_tests {
#[test]
fn missing_ffmpeg_is_retryable() {
let attempt = video_thumbnail(
b"video bytes",
let attempt = video_sample_thumbnail(
&whole(b"video bytes"),
"clip.mp4",
&std::env::temp_dir(),
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
@@ -2416,9 +2949,12 @@ mod thumb_tests {
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
let path = std::path::PathBuf::from(path);
let name = path.file_name().unwrap().to_string_lossy();
let bytes = std::fs::read(&path).unwrap();
let ScaleAttempt::Made(thumb) = video_thumbnail(
&bytes,
// Through the same partial download a Drive video gets.
let revision = FakeRevision::new(std::fs::read(&path).unwrap().len() as u64)
.with(0, std::fs::read(&path).unwrap());
let sample = block_on(sample_video(&revision)).unwrap();
let ScaleAttempt::Made(thumb) = video_sample_thumbnail(
&sample,
&name,
&std::env::temp_dir(),
OsStr::new("ffmpeg"),
+93 -23
View File
@@ -141,19 +141,11 @@ impl Core {
/// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
let folders = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up.
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect();
let folders = profile_folders(
self.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?,
);
let pins = self
.db
.pin_list()
@@ -388,7 +380,8 @@ impl Core {
&self,
items: &[pdfs_core::control::RestoreItem],
device: Option<&str>,
) -> CoreResult<String> {
) -> CoreResult<RestoreOutcome> {
let _registration = super::devices::SYNC_ROOT_REGISTRATION.lock();
if items.is_empty() {
return Err(CoreError::invalid("nothing to restore"));
}
@@ -398,6 +391,11 @@ impl Core {
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// A caller must not smuggle a My files root into a device mapping,
// where destructive device-only operations would otherwise be allowed.
let device_root = super::parse_uid(&device.root_uid)
.ok_or_else(|| CoreError::internal("invalid device root"))?;
let children = self.device_root_nodes(&device_root)?;
let reserved = crate::devices::sync_root_reserved();
let mut roots: Vec<PathBuf> = existing
.iter()
@@ -406,6 +404,18 @@ impl Core {
let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new();
for item in items {
if !children.iter().any(|node| {
node.uid.to_string() == item.remote_uid
&& node.is_folder()
&& !node.trashed
&& node.name != PROFILE_DIR_NAME
}) {
skipped.push(format!(
"{}: not a folder under this device",
item.local_path
));
continue;
}
// The path is proposed from the profile another machine wrote, and
// may hold a folder name from Drive: checked like any new root.
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
@@ -434,10 +444,19 @@ impl Core {
continue;
}
};
let id = self
.db
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let id = match self.db.sync_folder_add(
&local.to_string_lossy(),
&item.remote_uid,
&device.share_id,
) {
Ok(id) => id,
Err(e) => {
// The folders restored before this one still need their
// watches.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Err(CoreError::internal(format!("db: {e:?}")));
}
};
// `ondemand` is applied through the normal request path, which
// mounts the FUSE session and handles the folder being busy — the
// restore does not get its own copy of that logic.
@@ -454,12 +473,48 @@ impl Core {
for reason in &skipped {
info!(reason, "restore skipped a folder");
}
let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
}
info!(restored, skipped = skipped.len(), "restore complete");
Ok(message)
Ok(RestoreOutcome { restored, skipped })
}
}
/// Only device backups belong in the device's cross-machine profile.
fn profile_folders(folders: Vec<pdfs_core::db::StoredSyncFolder>) -> Vec<ProfileFolder> {
folders
.into_iter()
.filter(|f| f.kind == "device")
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
})
.collect()
}
/// What a restore did: how many folders it attached, and why it left out the
/// others.
pub(crate) struct RestoreOutcome {
pub(crate) restored: usize,
pub(crate) skipped: Vec<String>,
}
impl RestoreOutcome {
/// The activity entry's headline.
pub(crate) fn summary(&self) -> String {
match (self.restored, self.skipped.len()) {
(n, 0) => format!("restored {n} folder(s)"),
(0, k) => format!("restored no folders; skipped {k}"),
(n, k) => format!("restored {n} folder(s); skipped {k}"),
}
}
/// Whether the restore did all it was asked. A skipped folder is a
/// failure the user has to see: the reply went out as "restoring folders"
/// before any work, and a restore that skipped every folder used to be
/// logged as a success, so nothing anywhere said that nothing would
/// download (`docs/BUGS.md` B269).
pub(crate) fn complete(&self) -> bool {
self.skipped.is_empty()
}
}
@@ -498,6 +553,21 @@ fn default_local_path(name: &str) -> PathBuf {
mod tests {
use super::*;
#[test]
fn attached_my_files_folders_are_not_in_the_device_profile() {
let dir = crate::sim::daemon::scratch("profile-folder-kinds");
let db = pdfs_core::db::Db::open(&dir.join("cache.db")).unwrap();
db.sync_folder_add("/backup", "vol~backup", "device-share")
.unwrap();
db.sync_folder_attach("/work", "vol~work", "my-files-share")
.unwrap();
let folders = profile_folders(db.sync_folder_list().unwrap());
assert_eq!(folders.len(), 1);
assert_eq!(folders[0].remote_uid, "vol~backup");
drop(db);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn foreign_home_is_not_reused() {
assert!(!path_is_usable_here(Path::new(
+1
View File
@@ -200,6 +200,7 @@ impl Core {
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
let metadata = PhotoUploadMetadata {
capture_time: Some(candidate.wanted),
last_modification_time: pdfs_core::timestamp::modification_time(candidate.wanted),
..Default::default()
};
let uploaded = self.rt.block_on(photos.upload_photo_from(
+1
View File
@@ -555,6 +555,7 @@ mod tests {
fn folder(id: i64, local_path: &str, mode: &str) -> StoredSyncFolder {
StoredSyncFolder {
kind: "device".into(),
id,
local_path: local_path.to_string(),
remote_uid: format!("vol~root{id}"),
+5
View File
@@ -111,6 +111,11 @@ impl Core {
// stale from here on, and the server may not have applied the swap yet.
self.cache.evict(uid);
self.evict_reader(uid);
// A file kept offline gets the restored content back on disk, and
// again when the event of the swap arrives (`docs/BUGS.md` B282).
if self.cache.is_pinned(uid) {
self.recheck_pinned(uid);
}
self.log_activity(
ActivityKind::Restore,
name,
+8 -8
View File
@@ -105,7 +105,7 @@ impl Core {
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.share_node_for_uid(&uid, emails, role, message)
}
@@ -138,7 +138,7 @@ impl Core {
&self,
uid: &str,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.list_share_for_uid(&uid)
}
@@ -215,7 +215,7 @@ impl Core {
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_share_role_for_uid(&uid, id, kind, role)
}
@@ -278,7 +278,7 @@ impl Core {
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_share_entry_for_uid(&uid, id, kind)
}
@@ -349,7 +349,7 @@ impl Core {
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.create_public_link_for_uid(&uid, role, password, expires)
}
@@ -391,7 +391,7 @@ impl Core {
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
@@ -460,7 +460,7 @@ impl Core {
/// [`Core::remove_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.remove_public_link_for_uid(&uid, id)
}
@@ -486,7 +486,7 @@ impl Core {
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
let uid = self.resolve_anywhere(uid)?;
let uid = self.resolve_shareable(uid)?;
self.stop_sharing_for_uid(&uid)
}
+780 -10
View File
@@ -130,6 +130,22 @@ impl Daemon {
}
}
/// Whether nothing is queued and no upload is still landing, as `pdfs
/// status` counts it. An empty [`Self::pending`] is not enough before a
/// read that needs the cache: the op goes before the uploaded bytes are
/// cached, so a test that went offline at once raced the drain.
pub(crate) fn drained(&self) -> bool {
self.pending().is_ok_and(|items| items.is_empty())
&& matches!(
self.request(&Request::Status),
Ok(Response::Status {
pending_uploads: 0,
pending_changes: 0,
..
})
)
}
/// Whether the mount believes Proton is reachable, as `pdfs status` shows
/// it.
pub(crate) fn online(&self) -> io::Result<bool> {
@@ -393,6 +409,43 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A folder with files in it is not mounted over, and its files stay where
/// they are; once it is empty, the mount comes up (B277, #67).
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_mount_over_local_files_is_refused_and_leaves_them_alone() {
let drive = FakeDrive::new();
let dir = scratch("not-empty");
let mountpoint = dir.join("mnt");
std::fs::create_dir_all(mountpoint.join("Work")).unwrap();
std::fs::write(mountpoint.join("notes.txt"), b"local only").unwrap();
let error = Daemon::start(&dir, drive.client(1, Faults::lan()))
.err()
.expect("the mount must be refused");
let found = error
.get_ref()
.and_then(|inner| inner.downcast_ref::<pdfs_core::mountpoint::NotEmpty>())
.unwrap_or_else(|| panic!("refused for another reason: {error}"));
assert_eq!(found.entries, ["Work", "notes.txt"]);
assert!(!is_mounted(&mountpoint));
assert_eq!(
std::fs::read(mountpoint.join("notes.txt")).unwrap(),
b"local only"
);
assert!(
drive.tree().is_empty(),
"nothing went to Drive: {:?}",
drive.tree()
);
std::fs::remove_file(mountpoint.join("notes.txt")).unwrap();
std::fs::remove_dir(mountpoint.join("Work")).unwrap();
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Write `bytes` at `offset` into an existing file, without truncating it.
fn write_at(path: &Path, offset: u64, bytes: &[u8]) -> io::Result<()> {
use std::os::unix::fs::FileExt;
@@ -565,7 +618,7 @@ mod tests {
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}));
daemon.client.set_online(false);
@@ -592,14 +645,22 @@ mod tests {
daemon.pending(),
drive.tree()
);
// The bytes kept for the handle went once the trash landed with it closed.
let cached: Vec<_> = std::fs::read_dir(dir.join("cache"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.file_name())
.collect();
assert!(cached.is_empty(), "{cached:?}");
// The bytes kept for the handle went once the trash landed with it
// closed. The queue empties as the trash's row goes, a moment before
// the eviction, so this waits too.
let cached = || -> Vec<_> {
std::fs::read_dir(dir.join("cache"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.map(|e| e.file_name())
.collect()
};
assert!(
wait_until(Duration::from_secs(30), || cached().is_empty()),
"{:?}",
cached()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
@@ -684,6 +745,55 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_new_file_deleted_while_open_as_its_create_lands_still_reads() {
// The create's op was retired and the tree still had the placeholder.
// The queue read empty, and the delete trashed the node by its real
// uid, which the tree did not have yet: it took the open file for
// closed and dropped the bytes its handle reads (B257).
use std::io::{Read, Seek, SeekFrom, Write};
let drive = FakeDrive::new();
let dir = scratch("delete-open-as-create-lands");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let landing = daemon.client.hold_next_landing_of_any();
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
assert!(!daemon.drained(), "the landing create is counted");
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
drop(landing);
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.client.set_online(false);
std::fs::remove_file(&path).unwrap();
file.seek(SeekFrom::End(0)).unwrap();
file.write_all(b"ab").unwrap();
let mut read = Vec::new();
file.seek(SeekFrom::Start(0)).unwrap();
file.read_to_end(&mut read).unwrap();
assert_eq!(read, b"0123456789ab");
drop(file);
daemon.client.set_online(true);
assert!(
wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.tree().is_empty()
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_restart_does_not_bring_back_the_write_of_a_file_deleted_while_open() {
@@ -698,7 +808,7 @@ mod tests {
std::fs::write(&path, b"0123456789").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
daemon.drained()
&& drive.tree().get("f.txt") == Some(&Entry::File(Arc::new(b"0123456789".to_vec())))
}));
daemon.client.set_online(false);
@@ -1998,6 +2108,21 @@ mod tests {
daemon.write_new(&path, b"first").unwrap();
assert!(wait_until(Duration::from_secs(30), || held.reached()));
std::fs::write(&path, b"second").unwrap();
// The kernel sends the release after `close` returns. Attaching the
// bytes resets the create's attempts, so a release that came after the
// lost reply had it retried at once, and the retry landed before
// anything here saw it fail. The bytes it replaced go only once they
// are attached.
let staged = dir.join("cache").join("staging");
assert!(wait_until(Duration::from_secs(30), || {
let blobs: Vec<Vec<u8>> = std::fs::read_dir(&staged)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter_map(|e| std::fs::read(e.path()).ok())
.collect();
blobs.iter().any(|b| b == b"second") && !blobs.iter().any(|b| b == b"first")
}));
drop(held);
assert!(wait_until(Duration::from_secs(30), || {
daemon
@@ -2787,6 +2912,433 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attaching_my_files_preserves_local_files_and_syncs_both_ways() {
use std::os::unix::fs::MetadataExt;
let drive = FakeDrive::new();
let dir = scratch("attach-my-files");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Work").is_some()
}));
drive.device().write("Work/cloud.txt", b"from cloud");
drive.device().write("Work/common.txt", b"remote version");
drive.device().write("Work/identical.txt", b"same bytes");
let local = dir.join("local");
std::fs::create_dir(&local).unwrap();
std::fs::write(local.join("local.txt"), b"only on this computer").unwrap();
std::fs::write(local.join("common.txt"), b"local version").unwrap();
std::fs::write(local.join("identical.txt"), b"same bytes").unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into(),
})
.unwrap();
let folder = match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_str().unwrap())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
};
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/local.txt").is_some()
&& std::fs::read(local.join("cloud.txt")).is_ok_and(|b| b == b"from cloud")
&& std::fs::read(local.join("common.txt")).is_ok_and(|b| b == b"remote version")
&& !conflict_copies(&local, "common").is_empty()
}));
assert_ne!(
std::fs::metadata(&local).unwrap().dev(),
std::fs::metadata(&daemon.mountpoint).unwrap().dev()
);
assert!(
conflict_copies(&local, "common")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local version"))
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
let duplicate = dir.join("duplicate");
std::fs::create_dir(&duplicate).unwrap();
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "Work".into()
})
.is_err()
);
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: "../Work".into()
})
.is_err()
);
// Parent/child mappings, the account root and files cannot be attached.
std::fs::create_dir(daemon.mountpoint.join("Work/Sub")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Work/Sub").is_some()
}));
for remote in ["Work/Sub", ".", "Work/cloud.txt"] {
assert!(
daemon
.request(&Request::AttachSyncFolder {
local_path: duplicate.to_str().unwrap().into(),
remote_path: remote.into(),
})
.is_err()
);
}
// Local overlap and symlink aliases into the actual cloud mount are refused.
let alias = dir.join("cloud-alias");
std::os::unix::fs::symlink(daemon.mountpoint.join("Work"), &alias).unwrap();
std::fs::create_dir_all(local.join("Sub")).unwrap();
for path in [
local.join("Sub"),
dir.clone(),
daemon.mountpoint.join("Work"),
alias,
] {
let error = daemon
.request(&Request::AttachSyncFolder {
local_path: path.to_string_lossy().into_owned(),
remote_path: "Work/Sub".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("overlaps"), "{error}");
}
assert!(conflict_copies(&local, "identical").is_empty());
assert_eq!(
std::fs::read(local.join("identical.txt")).unwrap(),
b"same bytes"
);
// Simultaneous requests cannot register two local trees for one cloud root.
std::fs::create_dir(daemon.mountpoint.join("Other")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty()) && drive.lookup("Other").is_some()
}));
let candidate_a = dir.join("candidate-a");
let candidate_b = dir.join("candidate-b");
std::fs::create_dir(&candidate_a).unwrap();
std::fs::create_dir(&candidate_b).unwrap();
let outcomes = std::thread::scope(|scope| {
let a = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_a.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
let b = scope.spawn(|| {
daemon
.request(&Request::AttachSyncFolder {
local_path: candidate_b.to_str().unwrap().into(),
remote_path: "Other".into(),
})
.map_err(|e| e.to_string())
});
[a.join().unwrap(), b.join().unwrap()]
});
assert_eq!(
outcomes.iter().filter(|result| result.is_ok()).count(),
1,
"exactly one mapping may own a remote root: {outcomes:?}"
);
drive
.device()
.write("Work/cloud.txt", b"new cloud revision");
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || std::fs::read(
local.join("cloud.txt")
)
.is_ok_and(|b| b == b"new cloud revision")));
std::fs::write(local.join("local.txt"), b"new local revision").unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || drive
.tree()
.get("Work/local.txt")
== Some(&Entry::File(Arc::new(b"new local revision".to_vec())))));
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "idle")));
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: true,
})
.unwrap();
let before = drive.tree();
for entry in std::fs::read_dir(&local).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
std::fs::remove_dir_all(path).unwrap();
} else {
std::fs::remove_file(path).unwrap();
}
}
daemon
.request(&Request::SetSyncFolderPaused {
id: folder,
paused: false,
})
.unwrap();
daemon
.request(&Request::SyncNow { id: Some(folder) })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || listed_folder(
&daemon, folder
)
.is_some_and(|f| f.state == "error")));
assert_eq!(
drive.tree(),
before,
"the wipe guard must preserve the remote tree"
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let db = Db::open(&dir.join("pdfs.db")).unwrap();
assert_eq!(
db.sync_folder_get(folder).unwrap().unwrap().remote_share_id,
"my-files-share"
);
drop(db);
let _ = std::fs::remove_dir_all(&dir);
}
fn attached_my_files(daemon: &Daemon, drive: &FakeDrive, local: &Path) -> i64 {
std::fs::create_dir(daemon.mountpoint.join("Work")).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.drained() && drive.lookup("Work").is_some()
}));
std::fs::create_dir(local).unwrap();
daemon
.request(&Request::AttachSyncFolder {
local_path: local.to_string_lossy().into_owned(),
remote_path: "Work".into(),
})
.unwrap();
match daemon.request(&Request::ListSyncFolders).unwrap() {
Response::SyncFolders { items } => {
items
.into_iter()
.find(|f| f.local_path == local.to_string_lossy())
.unwrap()
.id
}
other => panic!("unexpected reply: {other:?}"),
}
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_views_preserve_concurrent_edits() {
let drive = FakeDrive::new();
let dir = scratch("attach-views");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
let mounted = daemon.mountpoint.join("Work");
// Prime the mount listing before a mirror upload, to catch stale listings.
assert_eq!(std::fs::read_dir(&mounted).unwrap().count(), 0);
std::fs::write(local.join("mirror.txt"), b"mirror bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read_dir(&mounted)
.unwrap()
.any(|e| e.unwrap().file_name() == "mirror.txt")
&& std::fs::read(mounted.join("mirror.txt")).is_ok_and(|b| b == b"mirror bytes")
}),
"mirror upload must reach the mounted view without a restart"
);
std::fs::write(mounted.join("mount.txt"), b"mounted bytes").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mounted bytes")
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}),
"mounted writes must reach the mirror"
);
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state != "syncing")
}));
std::fs::write(mounted.join("mount.txt"), b"mount changed").unwrap();
assert!(wait_until(Duration::from_secs(30), || daemon.drained()));
std::fs::write(local.join("mount.txt"), b"local changed").unwrap();
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
std::fs::read(local.join("mount.txt")).is_ok_and(|b| b == b"mount changed")
&& conflict_copies(&local, "mount")
.iter()
.any(|p| std::fs::read(p).is_ok_and(|b| b == b"local changed"))
&& drive.tree().iter().any(|(path, entry)| {
path.starts_with("Work/mount (sync-conflict")
&& entry == &Entry::File(Arc::new(b"local changed".to_vec()))
})
}),
"both edits must survive the next pass"
);
let error = daemon
.request(&Request::SetSyncFolderMode {
id,
mode: "ondemand".into(),
})
.unwrap_err()
.to_string();
assert!(error.contains("only support mirror"), "{error}");
let error = daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: true,
})
.unwrap_err()
.to_string();
assert!(error.contains("cannot be deleted"), "{error}");
let folder = listed_folder(&daemon, id).unwrap();
assert_eq!(folder.kind, "attached");
assert_eq!(folder.mode, "mirror");
assert!(folder.pending_mode.is_none());
// Restore must not reclassify a My files root as a device backup,
// which would make the device-only destructive operations available.
let restore_path = dir.join("forged-device-restore");
daemon
.request(&Request::RestoreSyncFolders {
items: vec![control::RestoreItem {
remote_uid: folder.remote_uid.clone(),
local_path: restore_path.to_string_lossy().into_owned(),
mode: "ondemand".into(),
}],
})
.unwrap();
assert!(wait_until(Duration::from_secs(30), || {
matches!(daemon.request(&Request::ListActivity { limit: 100 }),
Ok(Response::Activity { items }) if items.iter().any(|item| {
!item.ok && item.detail.contains("not a folder under this device")
}))
}));
assert!(!restore_path.exists());
match daemon.request(&Request::ListLocations).unwrap() {
Response::Locations { items } => assert!(items.iter().any(|m| {
matches!(m.kind, pdfs_core::mounts::MountKind::Attached { sync_folder_id } if sync_folder_id == id)
})),
other => panic!("unexpected reply: {other:?}"),
}
daemon
.request(&Request::RemoveSyncFolder {
id,
delete_remote: false,
})
.unwrap();
assert!(listed_folder(&daemon, id).is_none());
assert!(drive.lookup("Work/mount.txt").is_some());
assert_eq!(
std::fs::read(local.join("mount.txt")).unwrap(),
b"mount changed"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_trashed_root_keeps_local_files() {
attached_root_loss_keeps_local_files(false);
}
#[test]
#[ignore = "mounts FUSE: run explicitly on an isolated fake Drive"]
fn attached_my_files_root_moved_outside_my_files_keeps_local_files() {
attached_root_loss_keeps_local_files(true);
}
fn attached_root_loss_keeps_local_files(move_outside: bool) {
let drive = FakeDrive::new();
let dir = scratch("attach-root-gone");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let local = dir.join("local");
let id = attached_my_files(&daemon, &drive, &local);
std::fs::write(local.join("keep.txt"), b"keep my bytes").unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
drive.lookup("Work/keep.txt").is_some()
&& listed_folder(&daemon, id).is_some_and(|f| f.state == "idle")
}));
daemon
.request(&Request::SetSyncFolderPaused { id, paused: true })
.unwrap();
if move_outside {
use crate::drive::DriveApi;
let rt = daemon.rt.as_ref().unwrap();
let device = rt
.block_on(
daemon
.client
.create_device("other computer", proton_drive_rs::DeviceType::Linux),
)
.unwrap();
let root = drive.lookup("Work").unwrap();
rt.block_on(daemon.client.move_node(&root, &device.root_folder_uid))
.unwrap();
} else {
drive.device().trash("Work");
}
daemon
.request(&Request::SetSyncFolderPaused { id, paused: false })
.unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
assert_eq!(std::fs::read_dir(&local).unwrap().count(), 1);
let daemon = daemon.restart().unwrap();
daemon.request(&Request::SyncNow { id: Some(id) }).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
listed_folder(&daemon, id).is_some_and(|f| f.state == "error")
}));
assert_eq!(
std::fs::read(local.join("keep.txt")).unwrap(),
b"keep my bytes"
);
daemon.stop().unwrap();
let _ = std::fs::remove_dir_all(dir);
}
/// Register `local` as a synced folder in `mode` and wait until the daemon
/// lists it that way, returning its id.
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
@@ -2895,6 +3447,118 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy() {
// Keeping a file offline took only paths under the primary mount, so
// the copy in an on-demand synced folder could not be kept, and a
// program reading it there downloaded it every time (B281).
let drive = FakeDrive::new();
let dir = scratch("pin-in-ondemand");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let ondemand = dir.join("ondemand");
sync_folder(&daemon, &ondemand, "ondemand");
let content = b"not a real png, but kept all the same".repeat(64);
let file = ondemand.join("photo.png");
std::fs::write(&file, &content).unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
}));
daemon.request(&Request::PurgeCache).unwrap();
let path = file.canonicalize().unwrap().to_string_lossy().into_owned();
let pinned = daemon
.request(&Request::Pin { path: path.clone() })
.unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
let pins = || match daemon.request(&Request::ListPins) {
Ok(Response::Pins { pins }) => pins,
other => panic!("pins: {other:?}"),
};
let listed = pins();
assert!(
listed.iter().any(|pin| pin.path == path && pin.cached),
"the pin opens by the path it was made through: {listed:?}"
);
// With the link gone, only a kept copy can answer the read.
daemon.client.set_online(false);
assert_eq!(std::fs::read(&file).unwrap(), content);
daemon.client.set_online(true);
let unpinned = daemon
.request(&Request::Unpin { path: path.clone() })
.unwrap();
assert!(matches!(unpinned, Response::Ok { .. }), "{unpinned:?}");
assert!(pins().is_empty());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere() {
// A change from another device deleted a pinned file's copy and
// nothing downloaded it again, and a file added to a pinned folder
// elsewhere was never downloaded: both were listed as kept offline
// and read only with a network (B282).
let drive = FakeDrive::new();
let dir = scratch("pin-keeper");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"first").unwrap();
std::fs::write(daemon.mountpoint.join("solo.txt"), b"alone").unwrap();
assert!(wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.lookup("Docs/a.txt").is_some()
&& drive.lookup("solo.txt").is_some()
}));
daemon.request(&Request::PurgeCache).unwrap();
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
path: folder.into(),
}) {
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
_ => None,
};
for path in ["Docs", "solo.txt"] {
let pinned = daemon.request(&Request::Pin { path: path.into() }).unwrap();
assert!(matches!(pinned, Response::Ok { .. }), "{pinned:?}");
}
assert!(wait_until(Duration::from_secs(30), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached)
&& entry("", "solo.txt").is_some_and(|e| e.cached)
}));
// Another device edits both files and adds one to the kept folder.
drive.device().write("Docs/a.txt", b"second, and longer");
drive.device().write("solo.txt", b"alone, edited");
drive.device().write("Docs/new.txt", b"made elsewhere");
assert!(
wait_until(Duration::from_secs(60), || {
entry("Docs", "a.txt").is_some_and(|e| e.cached && e.size == 18)
&& entry("", "solo.txt").is_some_and(|e| e.cached && e.size == 13)
&& entry("Docs", "new.txt").is_some_and(|e| e.cached)
}),
"the keeper downloaded the new content: {:?} {:?} {:?}",
entry("Docs", "a.txt"),
entry("", "solo.txt"),
entry("Docs", "new.txt"),
);
// With the link gone, only the kept copies can answer.
daemon.client.set_online(false);
let read = |rel: &str| std::fs::read(daemon.mountpoint.join(rel)).unwrap();
assert_eq!(read("Docs/a.txt"), b"second, and longer");
assert_eq!(read("solo.txt"), b"alone, edited");
assert_eq!(read("Docs/new.txt"), b"made elsewhere");
daemon.client.set_online(true);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// The synced folder `id` as the daemon lists it.
fn listed_folder(daemon: &Daemon, id: i64) -> Option<control::SyncFolderInfo> {
match daemon.request(&Request::ListSyncFolders) {
@@ -3067,4 +3731,110 @@ mod tests {
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
/// A video in My files gets a thumbnail from a frame of its own, but a
/// recording can be gigabytes: only its start, and an MP4's index, are
/// downloaded for it. A video with a thumbnail from its uploader is not
/// downloaded at all, and a `.ts` that is TypeScript gets no thumbnail.
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_video_thumbnail_downloads_a_small_part_of_the_video() {
if std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_err()
{
eprintln!("skipped: needs ffmpeg");
return;
}
let dir = scratch("video-thumbs");
std::fs::create_dir_all(&dir).unwrap();
// Twenty seconds at 30 Mbit/s: larger than a thumbnail downloads whole.
// An MP4 written this way has its index at the end.
let encode = |name: &str| {
let path = dir.join(name);
let status = std::process::Command::new("ffmpeg")
.args(["-nostdin", "-loglevel", "error", "-y", "-f", "lavfi"])
.args(["-i", "testsrc2=size=1280x720:rate=30", "-t", "20"])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.args(["-b:v", "30M", "-maxrate", "30M", "-bufsize", "30M"])
.arg(&path)
.status()
.unwrap();
assert!(status.success());
let bytes = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(bytes.len() > 30 << 20, "{name} is {} bytes", bytes.len());
bytes
};
let (recording, clip) = (encode("recording.ts"), encode("clip.mp4"));
let source = b"export const answer = 42;\n".repeat(64 * 1024);
let drive = FakeDrive::new();
drive.device().write("recording.ts", &recording);
drive.device().write("clip.mp4", &clip);
drive.device().write("app.ts", &source);
let phone = drive.device().write("phone.mp4", &clip);
let mut frame = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut frame)
.encode_image(&image::RgbImage::new(320, 180))
.unwrap();
let client = drive.client(1, Faults::lan());
client.set_thumbnail(&phone, frame);
let daemon = Daemon::start(&dir.join("daemon"), client.clone()).unwrap();
let Ok(Response::Entries { entries }) = daemon.request(&Request::ListDir {
path: String::new(),
}) else {
panic!("listing");
};
let items: Vec<control::FileThumbRequest> = entries
.iter()
.filter(|entry| !entry.is_dir)
.map(|entry| control::FileThumbRequest {
uid: entry.uid.clone(),
modified: entry.modified,
name: entry.name.clone(),
})
.collect();
let Ok(Response::FileThumbGeneration { generation }) =
daemon.request(&Request::ReserveFileThumbGeneration)
else {
panic!("generation");
};
let read_before = client.content_read();
let mut paths = std::collections::HashMap::new();
let settled = wait_until(Duration::from_secs(120), || {
let Ok(Response::Thumbs { items: replies }) = daemon.request(&Request::FileThumbs {
items: items.clone(),
generation,
}) else {
return false;
};
paths = replies
.iter()
.filter_map(|reply| {
let name = &items.iter().find(|item| item.uid == reply.uid)?.name;
Some((name.clone(), (reply.path.clone(), reply.pending)))
})
.collect();
paths.values().all(|(_, pending)| !pending) && paths.len() == 4
});
assert!(settled, "{paths:?}");
let read = client.content_read() - read_before;
for name in ["recording.ts", "clip.mp4", "phone.mp4"] {
assert!(paths[name].0.is_some(), "{name} has a thumbnail: {paths:?}");
}
assert_eq!(paths["app.ts"].0, None, "TypeScript is no video");
// Two 16 MiB heads, one MP4 index and the TypeScript file, and nothing
// of the video that came with a thumbnail.
assert!(
read < 34 << 20,
"downloaded {} MiB for three thumbnails",
read >> 20
);
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
}
+51 -3
View File
@@ -62,7 +62,7 @@ use proton_drive_rs::proton_sdk::ids::{
};
use proton_drive_rs::{
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
NodeKind, NodeMoveItem, RevisionState, Thumbnail, ThumbnailType,
};
use sha1::{Digest as _, Sha1};
@@ -396,6 +396,8 @@ impl FakeDrive {
hold_answer: Mutex::new(None),
hold_trash: Mutex::new(None),
hold_download: Mutex::new(None),
thumbnails: Mutex::new(HashMap::new()),
content_read: AtomicU64::new(0),
hold_any_landing: AtomicBool::new(false),
hold_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false),
@@ -677,6 +679,10 @@ struct ClientInner {
hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>,
/// Thumbnails set by [`FakeClient::set_thumbnail`].
thumbnails: Mutex<HashMap<NodeUid, Vec<u8>>>,
/// File content bytes served, by downloads and ranged reads alike.
content_read: AtomicU64,
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
hold_landing: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
@@ -821,6 +827,16 @@ impl FakeClient {
/// Hold the next download of `uid`'s content once Drive has answered it,
/// before the bytes reach the daemon, until the answer is dropped.
/// Give `uid` a server thumbnail, as an upload that carried one would.
pub(crate) fn set_thumbnail(&self, uid: &NodeUid, bytes: Vec<u8>) {
self.0.thumbnails.lock().insert(uid.clone(), bytes);
}
/// File content bytes served so far, by whole downloads and ranged reads.
pub(crate) fn content_read(&self) -> u64 {
self.0.content_read.load(Ordering::SeqCst)
}
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
let state = Arc::new(HoldState {
uid: uid.clone(),
@@ -1123,6 +1139,14 @@ fn delete_one(server: &mut Server, uid: &NodeUid) -> Result<()> {
#[async_trait]
impl DriveApi for FakeClient {
async fn context_share_id(&self, uid: &NodeUid) -> Result<ShareId> {
self.request(false, |server| {
assert_eq!(uid, &server.root);
Ok(ShareId::new("my-files-share"))
})
.await
}
async fn get_my_files_folder(&self) -> Result<Node> {
self.request(false, |server| {
let root = server.root.clone();
@@ -1467,6 +1491,21 @@ impl DriveApi for FakeClient {
}))
}
async fn download_thumbnail(
&self,
uid: &NodeUid,
_thumbnail_type: ThumbnailType,
) -> Result<Option<Vec<u8>>> {
self.request(false, |server| {
server
.current(uid)
.map(|_| ())
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
})
.await?;
Ok(self.0.thumbnails.lock().get(uid).cloned())
}
async fn download_file_to_dyn(
&self,
uid: &NodeUid,
@@ -1482,6 +1521,9 @@ impl DriveApi for FakeClient {
.await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await;
self.0
.content_read
.fetch_add(content.len() as u64, Ordering::SeqCst);
output.write_all(&content).map_err(io_error)
}
@@ -1725,13 +1767,19 @@ impl RevisionRead for FakeRevision {
async fn read_at(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
let content = self.content.clone();
self.client
let read = self
.client
.request(false, move |_| {
let start = (offset as usize).min(content.len());
let end = (offset.saturating_add(length) as usize).min(content.len());
Ok(content[start..end].to_vec())
})
.await
.await?;
self.client
.0
.content_read
.fetch_add(read.len() as u64, Ordering::SeqCst);
Ok(read)
}
}
+96 -4
View File
@@ -36,6 +36,11 @@ const TICK: Duration = Duration::from_secs(15);
/// would train everyone to ignore the real one.
const STALL_AFTER: Duration = Duration::from_secs(120);
/// How long a sync pass may go without reporting progress before it is called
/// out. Much longer than [`STALL_AFTER`]: one large file in a pass reports
/// nothing until its whole transfer is done.
const SYNC_STALL_AFTER: Duration = Duration::from_secs(900);
/// How often resident size goes into the log.
const RSS_EVERY: Duration = Duration::from_secs(300);
@@ -52,7 +57,10 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
}
let mut last_rss = Instant::now();
let mut last_ping = Instant::now();
// `None` until the first probe, which runs on the first tick: startup has
// already used up much of the watchdog's time (`docs/BUGS.md` B279).
let mut last_ping: Option<Instant> = None;
let mut restored = false;
// Per-lane completion counts from the previous tick, to tell a queue that is
// being worked through from one that is not moving at all.
let mut last_completed = (0_u64, 0_u64);
@@ -79,16 +87,22 @@ pub(crate) fn run(core: Core, control_socket: PathBuf) {
// 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
&& ping_due(last_ping, period)
{
if probe(&control_socket) {
if !restored {
// Startup ran on the longer timeout the daemon asked for.
// The socket answers now, so the unit's own one applies.
systemd::restore_watchdog();
restored = true;
}
systemd::watchdog_ping();
} else {
warn!(
"daemon did not answer its own control socket; withholding the watchdog ping"
);
}
last_ping = Instant::now();
last_ping = Some(Instant::now());
}
}
}
@@ -137,9 +151,34 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
"the transfer lane has not completed a job since the last check"
);
}
// The engine is one thread for every synced folder, so a dead or stuck one
// stops all of them while the worker pool looks idle (B259).
if let Some(sync) = &report.sync {
if !sync.running {
stalled = true;
warn!(
"the sync engine is not running; synced folders do not sync until the daemon restarts"
);
} else if let Some(folder) = sync.pass_folder
&& sync.pass_idle_secs >= SYNC_STALL_AFTER.as_secs()
{
stalled = true;
warn!(
folder,
age_secs = sync.pass_age_secs,
idle_secs = sync.pass_idle_secs,
"a sync pass has not reported progress for a long time"
);
}
}
stalled
}
/// Whether the watchdog wants its next ping. The first one is due at once.
fn ping_due(last_ping: Option<Instant>, period: Duration) -> bool {
last_ping.is_none_or(|at| at.elapsed() >= period)
}
/// One round trip over the control socket: connect, ask for the cheapest
/// request there is, read the answer.
///
@@ -169,7 +208,7 @@ fn probe(control_socket: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
use pdfs_core::control::{Diagnostics, InflightRequest, SyncEngineState, WorkerState};
fn busy_worker(age_secs: u64) -> WorkerState {
WorkerState {
@@ -180,6 +219,18 @@ mod tests {
}
}
/// The first ping goes out on the first tick, not a full period after the
/// supervisor starts: startup has already spent most of the watchdog's time
/// (`docs/BUGS.md` B279).
#[test]
fn first_watchdog_ping_is_due_at_once() {
let period = Duration::from_secs(60);
assert!(ping_due(None, period));
assert!(!ping_due(Some(Instant::now()), period));
let long_ago = Instant::now().checked_sub(period).expect("uptime");
assert!(ping_due(Some(long_ago), period));
}
/// A worker doing normal work must not be reported: a WARN every tick is a
/// WARN nobody reads.
#[test]
@@ -226,4 +277,45 @@ mod tests {
assert!(!report_stalls(&report, (0, 30)));
assert!(report_stalls(&report, (0, 40)));
}
fn with_sync(sync: SyncEngineState) -> Diagnostics {
Diagnostics {
sync: Some(sync),
..Default::default()
}
}
/// A sync engine that has ended stops every synced folder while the
/// workers look idle, which is what issue #53 looked like (B259).
#[test]
fn a_stopped_sync_engine_is_a_stall() {
assert!(report_stalls(
&with_sync(SyncEngineState::default()),
(0, 0)
));
}
/// A pass that keeps reporting progress is working, however long it runs;
/// one that has gone quiet past the threshold is not.
#[test]
fn only_a_quiet_sync_pass_is_a_stall() {
let busy = SyncEngineState {
running: true,
pass_folder: Some(3),
pass_age_secs: SYNC_STALL_AFTER.as_secs() * 4,
pass_idle_secs: 5,
panics: 0,
};
assert!(!report_stalls(&with_sync(busy.clone()), (0, 0)));
let quiet = SyncEngineState {
pass_idle_secs: SYNC_STALL_AFTER.as_secs(),
..busy
};
assert!(report_stalls(&with_sync(quiet), (0, 0)));
let idle = SyncEngineState {
running: true,
..Default::default()
};
assert!(!report_stalls(&with_sync(idle), (0, 0)));
}
}
+159 -9
View File
@@ -49,7 +49,9 @@ mod local_scan;
mod model;
mod planner;
pub(crate) use engine::SYNC_CONCURRENCY;
pub(crate) use engine::{SyncMsg, spawn};
pub(crate) use engine::{EngineHealth, SyncMsg, spawn};
#[cfg(test)]
use engine::{Running, burst_order, keep_wanted};
#[cfg(test)]
use engine::{classify, event_target, settle_with};
#[cfg(test)]
@@ -67,6 +69,11 @@ use planner::{
plan_file, rel_to_path, remote_sig, remote_subtree_changed, revision_moved, tracked_baseline,
unchanged_remote_size,
};
/// A pass that took at least this long is logged at info even when it changed
/// nothing, so the journal says where a slow engine spends its time.
const SLOW_PASS: Duration = Duration::from_secs(30);
/// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task).
enum Applied {
@@ -154,8 +161,33 @@ impl Core {
// The cost is one small file read against a pass that is about to walk the
// whole tree.
let rules = self.ignore_rules(&local_root);
// A first pass is the one a user waits on after adding or restoring a
// folder, so it is said at info; the poll's passes stay at debug.
if folder.last_sync == 0 {
info!(id = folder.id, path = %folder.local_path, "sync: first pass starting");
} else {
debug!(id = folder.id, "sync: pass starting");
}
let started = Instant::now();
self.progress_begin(folder.id);
let result = if push_only {
// An attachment's root can be trashed or moved by the web app. A
// missing root must never turn into a scan of an empty remote tree.
let attached_root = if current.kind == "attached" {
self.attach_ancestors(&remote_root).and_then(|ancestors| {
if ancestors.contains(&self.primary_root_uid) {
Ok(())
} else {
Err(pdfs_core::CoreError::invalid(
"attached folder is no longer below My files",
))
}
})
} else {
Ok(())
};
let result = if let Err(error) = attached_root {
Err(PassAbort::Failed(error.to_string()))
} else if push_only {
self.push_pass(folder.id, &local_root, &remote_root, &rules)
} else {
match self.do_reconcile(folder.id, &local_root, &remote_root, &rules) {
@@ -195,6 +227,18 @@ impl Core {
"idle"
};
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
let elapsed = started.elapsed();
if folder.last_sync == 0 || !outcome.is_empty() || elapsed >= SLOW_PASS {
info!(
id = folder.id,
state,
elapsed_ms = elapsed.as_millis() as u64,
summary = %outcome.summary(),
"sync: pass finished"
);
} else {
debug!(id = folder.id, state, "sync: pass finished");
}
self.report_unscanned(folder.id, name, &outcome.unscanned);
// Summarise a pass that actually moved something. A poll over an
// unchanged folder does nothing and says nothing — otherwise the
@@ -1135,14 +1179,22 @@ impl Core {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(folder))
.map_err(|e| format!("enumerate {folder}: {e}"))?;
.map_err(|e| format!("enumerate {folder}: {e}"));
let uids = match unlisted_gap(walk, prefix, uids)? {
Some(uids) => uids,
None => return Ok(()),
};
if uids.is_empty() {
return Ok(());
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes_light(&uids))
.map_err(|e| format!("resolve nodes: {e}"))?;
.map_err(|e| format!("resolve nodes: {e}"));
let nodes = match unlisted_gap(walk, prefix, nodes)? {
Some(nodes) => nodes,
None => return Ok(()),
};
// The SDK leaves out a node it cannot decrypt, and says so only in its
// log. Its name went with it, so all this can record is the folder.
if nodes.len() < uids.len() {
@@ -1186,10 +1238,14 @@ impl Core {
let sized: HashMap<String, (i64, Option<String>)> = if stale.is_empty() {
HashMap::new()
} else {
self.rt
let full = self
.rt
.block_on(self.drive.enumerate_nodes(&stale))
.map_err(|e| format!("resolve nodes: {e}"))?
.iter()
.map_err(|e| format!("resolve nodes: {e}"));
let Some(full) = unlisted_gap(walk, prefix, full)? else {
return Ok(());
};
full.iter()
.map(|n| {
(
n.uid.to_string(),
@@ -2007,6 +2063,34 @@ struct RemoteWalk<'a> {
gaps: &'a mut RemoteGaps,
}
/// A listing call's result in [`Core::walk_remote`]. A failure below the root
/// records `prefix` as unlisted and returns `None`, so the walk goes on
/// without that folder. A failure at the root still fails the pass, since
/// nothing would be left to compare.
///
/// One listing error, after the SDK's own retries, used to fail the whole
/// walk. Nothing is applied before the walk ends, so on a link that drops a
/// request now and then a large first sync started over from nothing on
/// every pass and never got past it (`docs/BUGS.md` B271).
fn unlisted_gap<T>(
walk: &mut RemoteWalk<'_>,
prefix: &str,
result: Result<T, String>,
) -> Result<Option<T>, PassAbort> {
match result {
Ok(value) => Ok(Some(value)),
Err(error) if prefix.is_empty() => Err(error.into()),
Err(error) => {
warn!(
folder = prefix,
error, "sync: a folder on Drive could not be listed; leaving it out of this pass"
);
walk.gaps.unlisted(prefix);
Ok(None)
}
}
}
/// Whether the file at `path` is no longer what the scan saw as `planned`.
/// A file that is gone has nothing left to lose, so it has not changed.
fn changed_since_scan(path: &Path, planned: &LocalItem) -> bool {
@@ -2438,6 +2522,72 @@ fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
mod tests {
use super::*;
/// A panic in a pass ends that pass and is counted; the engine is not
/// left believing the pass still runs (`docs/BUGS.md` B259).
#[test]
fn a_panicking_pass_is_contained_and_counted() {
let health = EngineHealth::default();
assert!(!health.contain(7, || panic!("a pass that panics")));
let report = health.report();
assert_eq!(report.panics, 1);
assert_eq!(report.pass_folder, None);
assert!(health.contain(7, || {}));
assert_eq!(health.report().panics, 1);
}
/// Folders named by id run before the rest of a `ReconcileAll` in the
/// same burst, so a folder just added does not wait behind a pass over
/// every other folder (`docs/BUGS.md` B260).
#[test]
fn named_folders_run_before_the_rest_of_a_burst() {
let ids: HashSet<i64> = [9, 4].into();
assert_eq!(burst_order(&ids, &[1, 4, 6, 9]), [4, 9, 1, 6]);
assert_eq!(burst_order(&HashSet::new(), &[1, 4]), [1, 4]);
// A folder the list no longer has still gets the pass it asked for.
assert_eq!(burst_order(&[12].into(), &[1]), [12, 1]);
}
/// A folder removed and added again keeps its watch under the new id;
/// a folder that is gone loses its watch (`docs/BUGS.md` B272).
#[test]
fn a_re_added_folder_keeps_its_watch_under_the_new_id() {
let mut have = vec![(PathBuf::from("/a"), 1), (PathBuf::from("/b"), 2)];
let dropped = keep_wanted(&mut have, &[(PathBuf::from("/a"), 7)]);
assert_eq!(have, [(PathBuf::from("/a"), 7)]);
assert_eq!(dropped, [PathBuf::from("/b")]);
}
/// While a pass runs, the report names its folder, which is what tells a
/// long pass apart from an idle engine.
#[test]
fn the_running_pass_is_reported() {
let health = EngineHealth::default();
let mut seen = None;
health.contain(7, || seen = health.report().pass_folder);
assert_eq!(seen, Some(7));
}
/// The engine reads as running only while its thread lives, including
/// when the thread ends in a panic.
#[test]
fn the_engine_reads_as_stopped_once_its_thread_ends() {
let health = Arc::new(EngineHealth::default());
let thread_health = health.clone();
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (end_tx, end_rx) = std::sync::mpsc::channel::<()>();
let thread = std::thread::spawn(move || {
let _running = Running::start(&thread_health);
started_tx.send(()).unwrap();
let _ = end_rx.recv();
panic!("the engine thread panics");
});
started_rx.recv().unwrap();
assert!(health.report().running);
end_tx.send(()).unwrap();
assert!(thread.join().is_err());
assert!(!health.report().running);
}
/// 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 (`docs/BUGS.md` B42).
@@ -2734,7 +2884,7 @@ mod tests {
/// from being read as "the user deleted everything" and trashing the folder
/// on Drive.
#[test]
fn guard_local_wipe_blocks_only_a_total_disappearance() {
fn guard_local_wipe_blocks_a_total_disappearance() {
let base: HashMap<String, ()> = ["a.txt", "b.txt", "d/c.txt"]
.iter()
.map(|r| ((*r).to_string(), ()))
@@ -2748,7 +2898,7 @@ mod tests {
);
assert!(
guard_local_wipe(&base, &one).is_ok(),
"one survivor means the tree is readable; the rest are real deletions"
"in a small folder, one survivor means the tree is readable; the rest are real deletions"
);
// A one-entry folder can disappear because its mount or parent became
// unavailable just as easily as a larger tree can.
+253 -72
View File
@@ -1,5 +1,11 @@
//! Filesystem watcher, polling, and debounce control plane.
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use pdfs_core::control::SyncEngineState;
use tracing::error;
use super::*;
/// How long the watcher must go quiet before a burst counts as finished.
@@ -23,6 +29,9 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120);
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
/// engine does.
pub(crate) const SYNC_CONCURRENCY: usize = 8;
/// How long after a failed watch the engine tries the watches again. Each try
/// walks the folder's whole tree, so not on every burst.
const REWATCH_RETRY: Duration = Duration::from_secs(600);
/// A message to the sync engine's control thread.
pub(crate) enum SyncMsg {
@@ -53,32 +62,159 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
}
}
/// Whether the engine is alive and which pass it is on, for
/// [`Core::diagnostics`] and the supervisor.
///
/// A panic in a pass used to end the engine thread. Every later add, sync-now,
/// file event and poll then went into a channel nobody read, the folder stayed
/// `syncing`, and nothing said so until a restart (`docs/BUGS.md` B259).
#[derive(Default)]
pub(crate) struct EngineHealth {
running: AtomicBool,
pass: Mutex<Option<PassClock>>,
panics: AtomicU64,
}
#[derive(Clone, Copy)]
struct PassClock {
folder: i64,
started: Instant,
moved: Instant,
}
impl EngineHealth {
/// Note that the running pass reported progress.
pub(crate) fn moved(&self) {
if let Some(pass) = self.pass.lock().as_mut() {
pass.moved = Instant::now();
}
}
/// The engine's state now. `try_lock`, like the rest of the diagnostics:
/// a report must not wait on the thing it reports about.
pub(crate) fn report(&self) -> SyncEngineState {
let pass = self.pass.try_lock().and_then(|pass| *pass);
SyncEngineState {
running: self.running.load(Ordering::Relaxed),
pass_folder: pass.map(|p| p.folder),
pass_age_secs: pass.map_or(0, |p| p.started.elapsed().as_secs()),
pass_idle_secs: pass.map_or(0, |p| p.moved.elapsed().as_secs()),
panics: self.panics.load(Ordering::Relaxed),
}
}
/// Run `pass` over `folder` as the current pass. Returns `false` when it
/// panicked: the panic ends that pass, not the engine.
pub(super) fn contain(&self, folder: i64, pass: impl FnOnce()) -> bool {
let now = Instant::now();
*self.pass.lock() = Some(PassClock {
folder,
started: now,
moved: now,
});
let finished = std::panic::catch_unwind(AssertUnwindSafe(pass)).is_ok();
*self.pass.lock() = None;
if !finished {
self.panics.fetch_add(1, Ordering::Relaxed);
}
finished
}
}
/// Marks the engine running for as long as it lives.
pub(super) struct Running<'a>(&'a EngineHealth);
impl<'a> Running<'a> {
pub(super) fn start(health: &'a EngineHealth) -> Self {
health.running.store(true, Ordering::Relaxed);
Self(health)
}
}
impl Drop for Running<'_> {
fn drop(&mut self) {
self.0.running.store(false, Ordering::Relaxed);
if std::thread::panicking() {
error!("sync engine stopped by a panic; nothing syncs until the daemon restarts");
}
}
}
/// One folder's pass, contained: a panic in it sets the folder to `error` and
/// leaves the engine running for the next one.
fn run_pass(core: &Core, folder: &StoredSyncFolder) {
if core
.sync_engine
.contain(folder.id, || core.reconcile_folder(folder))
{
return;
}
core.progress_end(folder.id);
let _ = core
.db
.sync_folder_set_state(folder.id, "error", folder.last_sync);
error!(
id = folder.id,
path = %folder.local_path,
"sync: pass panicked; the folder is set to error and tried again on the next poll"
);
core.log_activity(
ActivityKind::Sync,
format!("sync failed for {}", base_name(&folder.local_path)),
"an internal error stopped the pass; it is tried again on the next poll",
false,
);
}
/// The pass a `Reconcile(id)` asked for. A folder removed since, or one the
/// database cannot read, is said so rather than dropped in silence.
fn run_pass_by_id(core: &Core, id: i64) {
match core.db.sync_folder_get(id) {
Ok(Some(folder)) => run_pass(core, &folder),
Ok(None) => debug!(id, "sync: folder removed before its pass; skipping"),
Err(e) => warn!(id, error = ?e, "sync: cannot read folder; skipping its pass"),
}
}
/// The engine control loop. Single-threaded so reconcile passes never overlap.
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
let health = core.sync_engine.clone();
let _running = Running::start(&health);
// Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to.
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
let tx_events = core.sync_tx.clone();
let watched_events = watched.clone();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
// Ignore pure metadata/access noise; content changes are what matter.
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
}) {
Ok(w) => w,
let handler = move |res: notify::Result<notify::Event>| {
let event = match res {
Ok(event) => event,
// Running out of inotify watches for a new subfolder, say: that
// subfolder's changes are then found by the poll only (B262).
Err(e) => {
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
// Still run the loop so polling and explicit SyncNow work.
poll_only_loop(&core, rx);
warn!(error = %e, "sync: filesystem watcher error");
return;
}
};
// Ignore pure metadata/access noise; content changes are what matter.
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
};
// The local walk skips symlinks, so following them here would only watch
// trees outside the folder, and run passes over it for changes made there.
let config = notify::Config::default().with_follow_symlinks(false);
let mut watcher = match notify::RecommendedWatcher::new(handler, config) {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
// Still run the loop so polling and explicit SyncNow work.
poll_only_loop(&core, rx);
return;
}
};
// Periodic remote poll.
{
@@ -96,8 +232,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
.ok();
}
rewatch(&core, &mut watcher, &watched);
reconcile_all(&core);
let mut unwatched = rewatch(&core, &mut watcher, &watched);
let mut last_rewatch = Instant::now();
run_burst(&core, &HashSet::new(), true);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
@@ -110,21 +247,16 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
return;
}
// A folder whose watch failed is tried again now and then, not only
// when the folder set changes.
if unwatched && last_rewatch.elapsed() >= REWATCH_RETRY {
do_rewatch = true;
}
if do_rewatch {
rewatch(&core, &mut watcher, &watched);
}
if all {
reconcile_all(&core);
} else {
for id in ids {
if core.sync_paused() {
break;
}
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
}
unwatched = rewatch(&core, &mut watcher, &watched);
last_rewatch = Instant::now();
}
run_burst(&core, &ids, all);
}
}
@@ -141,7 +273,7 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
}
});
}
reconcile_all(core);
run_burst(core, &HashSet::new(), true);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
@@ -152,21 +284,59 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
info!("sync engine stopping");
return;
}
if all {
reconcile_all(core);
} else {
for id in ids {
if core.sync_paused() {
break;
}
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
run_burst(core, &ids, all);
}
}
/// Run the passes one burst asked for: the folders named by id, then, when the
/// burst also holds a `ReconcileAll`, every other folder.
///
/// The named folders go first. A poll's `ReconcileAll` in the same burst used
/// to take their place, and a folder just added, which has the highest id,
/// waited behind a full pass over every other folder (`docs/BUGS.md` B260).
///
/// Skipped while sync is paused, and cut short when a pause lands between
/// passes: the resume sends a fresh [`SyncMsg::ReconcileAll`], which walks
/// everything the watcher reported meanwhile.
fn run_burst(core: &Core, ids: &HashSet<i64>, all: bool) {
if core.sync_paused() {
debug!("sync: paused, skipping reconcile");
return;
}
let folders = if all {
match core.db.sync_folder_list() {
Ok(folders) => folders,
Err(e) => {
warn!(error = ?e, "sync: cannot list folders");
Vec::new()
}
}
} else {
Vec::new()
};
let listed: Vec<i64> = folders.iter().map(|f| f.id).collect();
for id in burst_order(ids, &listed) {
if core.sync_paused() {
break;
}
// A folder named by id is read again: it may have been added or
// changed after the list was read.
match folders.iter().find(|f| f.id == id) {
Some(folder) if !ids.contains(&id) => run_pass(core, folder),
_ => run_pass_by_id(core, id),
}
}
}
/// The order of a burst's passes: the folders named by id, lowest first, then
/// the rest of `listed` in its own order.
pub(super) fn burst_order(ids: &HashSet<i64>, listed: &[i64]) -> Vec<i64> {
let mut order: Vec<i64> = ids.iter().copied().collect();
order.sort_unstable();
order.extend(listed.iter().copied().filter(|id| !ids.contains(id)));
order
}
/// Absorb the rest of an event burst, returning once the watcher has been quiet
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
/// first. The caller has already classified the event that opened the burst.
@@ -238,26 +408,6 @@ pub(super) fn classify(
true
}
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
/// watcher reported meanwhile.
fn reconcile_all(core: &Core) {
if core.sync_paused() {
debug!("sync: paused, skipping reconcile");
return;
}
let folders = match core.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {
warn!(error = ?e, "sync: cannot list folders");
return;
}
};
for folder in folders {
core.reconcile_folder(&folder);
}
}
/// The reconcile a watcher event asks for: the one mirror folder all its paths
/// lie in, so an edit in one folder does not walk every other one too. An event
/// that spans folders, or that no watched folder claims (a watch list a moment
@@ -278,7 +428,8 @@ pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> Syn
}
/// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones.
/// watch newly-added folders, drop removed ones. Returns whether a folder is
/// left unwatched because its watch failed.
///
/// The watch calls run on a copy, outside the lock: the watcher's event
/// callback takes the same lock, and a backend that hands a watch request to
@@ -287,7 +438,7 @@ fn rewatch(
core: &Core,
watcher: &mut notify::RecommendedWatcher,
watched: &Mutex<Vec<(PathBuf, i64)>>,
) {
) -> bool {
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
Ok(f) => f
.into_iter()
@@ -296,30 +447,60 @@ fn rewatch(
.collect(),
Err(e) => {
warn!(error = ?e, "sync: cannot list folders to watch");
return;
return false;
}
};
let mut have = watched.lock().clone();
// Drop watches no longer wanted.
have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) {
true
} else {
let _ = watcher.unwatch(path);
false
}
});
for path in keep_wanted(&mut have, &want) {
let _ = watcher.unwatch(&path);
}
// Add newly wanted watches.
let mut failed = false;
for (path, id) in &want {
if have.iter().any(|(p, _)| p == path) {
continue;
}
match watcher.watch(path, RecursiveMode::Recursive) {
Ok(()) => have.push((path.clone(), *id)),
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
Err(e) => {
// A recursive watch that fails partway keeps the watches it had
// added. Their events would match no folder and run a pass over
// every one, and they hold watches the next folder needs (B262).
let _ = watcher.unwatch(path);
failed = true;
warn!(
path = %path.display(),
error = %e,
"sync: watch failed; the poll finds this folder's changes until a retry \
works. A low fs.inotify.max_user_watches is the usual cause"
);
}
}
}
*watched.lock() = have;
failed
}
/// Keep the watches in `have` that `want` still asks for, and return the paths
/// of the others, to unwatch.
///
/// A path is matched alone, but takes the id `want` gives it: a folder removed
/// and added again in one burst keeps its watch, and its events go to the new
/// row rather than to the removed one (`docs/BUGS.md` B272).
pub(super) fn keep_wanted(have: &mut Vec<(PathBuf, i64)>, want: &[(PathBuf, i64)]) -> Vec<PathBuf> {
let mut dropped = Vec::new();
have.retain_mut(|(path, id)| match want.iter().find(|(p, _)| p == path) {
Some((_, wanted)) => {
*id = *wanted;
true
}
None => {
dropped.push(path.clone());
false
}
});
dropped
}
// ---- reconcile ------------------------------------------------------------
+112 -16
View File
@@ -180,18 +180,42 @@ pub(super) fn local_deletions<B, L>(
missing
}
/// Refuse to run a pass whose local side has vanished in its entirety.
/// How many tracked paths a folder needs before a mass disappearance, short of
/// a total one, trips a wipe guard. Below this, a few survivors say little.
const MASS_LOSS_MIN_PATHS: usize = 20;
/// The share of a large folder's tracked paths that may vanish in one pass, in
/// tenths, before a wipe guard stops it: 9 of 10 and up is refused.
const MASS_LOSS_TENTHS: usize = 9;
/// Whether `missing` of `total` tracked paths gone at once looks like a lost
/// tree rather than deletions. All of them always does. Most of a large
/// folder does too: one surviving file used to clear the guard, so a
/// half-mounted disk, or a script that emptied the folder and wrote one file
/// back, trashed everything else on Drive (B270).
fn looks_like_a_wipe(missing: usize, total: usize) -> bool {
total > 0
&& (missing == total
|| (total >= MASS_LOSS_MIN_PATHS && missing * 10 >= total * MASS_LOSS_TENTHS))
}
/// Refuse to run a pass whose local side has vanished in its entirety, or
/// nearly so ([`looks_like_a_wipe`]).
///
/// When every baseline path is absent locally, the likely cause is an unavailable
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
/// One surviving path is enough to clear the guard.
/// When the synced paths are all or nearly all absent locally, the likely
/// cause is an unavailable mount or unreadable folder rather than a deliberate
/// deletion of the whole tree.
pub(super) fn guard_local_wipe<B, L>(
baseline: &HashMap<String, B>,
local: &HashMap<String, L>,
) -> Result<(), String> {
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
let missing = baseline
.keys()
.filter(|rel| !local.contains_key(*rel))
.count();
if looks_like_a_wipe(missing, baseline.len()) {
return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \
"{missing} of the {} synced paths are missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable; to download \
them again, sync the folder from scratch.",
baseline.len()
@@ -200,20 +224,24 @@ pub(super) fn guard_local_wipe<B, L>(
Ok(())
}
/// Refuse to run a pass whose remote side has vanished in its entirety: the
/// remote twin of [`guard_local_wipe`].
/// Refuse to run a pass whose remote side has vanished in its entirety, or
/// nearly so: the remote twin of [`guard_local_wipe`].
///
/// An empty or truncated remote walk reads to the planner as "Drive deleted
/// everything", which a pass answers by deleting the local copies. When not
/// one synced path is left on Drive, a failed listing is far likelier than a
/// deliberate wipe of the whole folder, so the pass stops instead.
/// everything", which a pass answers by deleting the local copies. When all or
/// nearly all synced paths are gone from Drive at once, a failed listing is far
/// likelier than a deliberate wipe of the folder, so the pass stops instead.
pub(super) fn guard_remote_wipe<B, R>(
baseline: &HashMap<String, B>,
remote: &HashMap<String, R>,
) -> Result<(), String> {
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
let missing = baseline
.keys()
.filter(|rel| !remote.contains_key(*rel))
.count();
if looks_like_a_wipe(missing, baseline.len()) {
return Err(format!(
"every one of the {} synced paths is missing on Proton Drive; refusing to \
"{missing} of the {} synced paths are missing on Proton Drive; refusing to \
delete them here. If they were deleted on purpose, remove the folder from sync; \
to upload them again, sync the folder from scratch.",
baseline.len()
@@ -236,6 +264,9 @@ pub(super) struct RemoteGaps {
short: HashSet<String>,
/// Paths seen, but whose entry could not be read.
unread: HashSet<String>,
/// Folders whose listing failed outright, after the SDK's retries. Nothing
/// under one is known, so all of it is left out of the pass (B271).
unlisted: HashSet<String>,
}
impl RemoteGaps {
@@ -249,8 +280,13 @@ impl RemoteGaps {
self.unread.insert(rel);
}
/// The listing of `folder` failed, so nothing under it was walked.
pub(super) fn unlisted(&mut self, folder: &str) {
self.unlisted.insert(folder.to_string());
}
pub(super) fn is_empty(&self) -> bool {
self.short.is_empty() && self.unread.is_empty()
self.short.is_empty() && self.unread.is_empty() && self.unlisted.is_empty()
}
/// Whether `rel` may be missing from `remote` only because the walk could
@@ -272,7 +308,7 @@ impl RemoteGaps {
let mut at = parent_rel(rel);
loop {
if remote_dirs.contains_key(at) {
return self.short.contains(at);
return self.short.contains(at) || self.unlisted.contains(at);
}
if at.is_empty() {
return false;
@@ -295,6 +331,9 @@ impl RemoteGaps {
.iter()
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
)
.chain(self.unlisted.iter().map(|rel| {
format!("{rel} (could not be listed on Proton Drive; tried again next pass)")
}))
.collect();
lines.sort_unstable();
lines
@@ -805,7 +844,7 @@ mod file_plan_tests {
/// An empty or failed remote listing must not read as "Drive deleted
/// everything" and delete the local copies.
#[test]
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() {
fn the_remote_wipe_guard_blocks_a_total_disappearance() {
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
let none: HashMap<String, RemoteItem> = HashMap::new();
assert!(guard_remote_wipe(&base, &none).is_err());
@@ -815,6 +854,37 @@ mod file_plan_tests {
assert!(guard_remote_wipe(&empty, &none).is_ok());
}
/// One path left of a large folder no longer clears either guard: most
/// of it gone at once is a lost tree, not a cleanup (B270).
#[test]
fn one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards() {
let base: HashMap<String, ()> = (0..40).map(|n| (format!("f{n}.txt"), ())).collect();
let survivors = |n: usize| -> HashMap<String, ()> {
(0..n).map(|n| (format!("f{n}.txt"), ())).collect()
};
assert!(guard_local_wipe(&base, &survivors(1)).is_err());
assert!(guard_remote_wipe(&base, &survivors(1)).is_err());
// 36 of 40 gone is nine tenths: still refused.
assert!(guard_local_wipe(&base, &survivors(4)).is_err());
// 35 of 40 gone is under nine tenths: a pass may delete that much.
assert!(guard_local_wipe(&base, &survivors(5)).is_ok());
assert!(guard_remote_wipe(&base, &survivors(5)).is_ok());
let reason = guard_local_wipe(&base, &survivors(1)).unwrap_err();
assert!(reason.starts_with("39 of the 40 synced paths"), "{reason}");
}
/// A small folder still loses most of its files to an ordinary cleanup, so
/// only a total loss stops it.
#[test]
fn a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone() {
let base: HashMap<String, ()> = (0..19).map(|n| (format!("f{n}.txt"), ())).collect();
let one: HashMap<String, ()> = [("f0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&base, &one).is_ok());
assert!(guard_remote_wipe(&base, &one).is_ok());
let none: HashMap<String, ()> = HashMap::new();
assert!(guard_local_wipe(&base, &none).is_err());
}
/// A node that failed to decrypt has no name, so everything a short
/// listing did not return is left out, and nothing else.
#[test]
@@ -844,6 +914,32 @@ mod file_plan_tests {
assert!(gaps.hides("top.txt", &remote, &dirs));
}
/// A folder whose listing failed was walked no further: nothing under it
/// reads as deleted on Drive, and a sibling that listed still does
/// (`docs/BUGS.md` B271).
#[test]
fn a_folder_that_failed_to_list_hides_everything_under_it() {
let remote = HashMap::from([
("docs".to_string(), folder()),
("pics".to_string(), folder()),
]);
let dirs = HashMap::from([
(String::new(), uid("root")),
("docs".to_string(), uid("docs")),
("pics".to_string(), uid("pics")),
]);
let mut gaps = RemoteGaps::default();
gaps.unlisted("docs");
assert!(gaps.hides("docs/a.txt", &remote, &dirs));
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
assert!(!gaps.hides("docs", &remote, &dirs));
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
assert_eq!(
gaps.report(),
["docs (could not be listed on Proton Drive; tried again next pass)"]
);
}
#[test]
fn a_file_whose_size_could_not_be_read_is_hidden() {
let remote: HashMap<String, RemoteItem> = HashMap::new();
+40 -1
View File
@@ -64,11 +64,19 @@ fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
/// 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") {
if notify("READY=1\nSTATUS=Mounted\n") {
debug!("notified systemd: ready");
}
}
/// Set the one-line status `systemctl --user status` shows for the unit, such
/// as why the mount keeps failing (`docs/BUGS.md` B263).
pub fn status(text: &str) {
// A newline would end the `STATUS=` line and start another variable.
let line = text.replace('\n', " ");
notify(&format!("STATUS={line}\n"));
}
/// Tell the manager the daemon is still healthy.
pub(crate) fn watchdog_ping() {
let _ = notify("WATCHDOG=1\n");
@@ -94,6 +102,37 @@ pub fn idle_sleep(duration: Duration) {
}
}
/// How long a mount may take to start before the watchdog counts it as hung.
///
/// Startup has no control socket to probe yet, and on a large account it is
/// long: hydrating 48 000 nodes took 70 s on 2026-11-12, and the secondary
/// mounts followed. Under the 2-minute `WatchdogSec` that run was aborted just
/// before its first ping, and left six dead mounts behind (`docs/BUGS.md` B279).
const STARTUP_WATCHDOG: Duration = Duration::from_secs(600);
/// Give the mount about to start [`STARTUP_WATCHDOG`] before the watchdog fires.
///
/// Sends `WATCHDOG_USEC=`, which replaces the unit's timeout and restarts its
/// timer. The supervisor puts the configured timeout back with
/// [`restore_watchdog`] once it can prove the daemon answers. A startup that
/// wedges for good is still aborted, only later.
pub fn stretch_watchdog_for_startup() {
if watchdog_period().is_some() {
notify(&format!("WATCHDOG_USEC={}\n", STARTUP_WATCHDOG.as_micros()));
}
}
/// Put back the timeout the unit configured, undoing
/// [`stretch_watchdog_for_startup`].
pub(crate) fn restore_watchdog() {
if watchdog_period().is_none() {
return;
}
if let Ok(usec) = std::env::var("WATCHDOG_USEC") {
notify(&format!("WATCHDOG_USEC={usec}\n"));
}
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
+9 -3
View File
@@ -344,6 +344,9 @@ impl Core {
);
let metadata = PhotoUploadMetadata {
capture_time: photo.capture_time,
last_modification_time: photo
.capture_time
.and_then(pdfs_core::timestamp::modification_time),
..Default::default()
};
// A thumbnail is made from a file, so a photo that can have one
@@ -365,10 +368,13 @@ impl Core {
let thumbnails = self.upload_thumbnails(path, &photo.name);
// No sidecar and no date in the name: the photo's own
// EXIF, before Drive files it under today.
let capture_time = metadata
.capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path));
let metadata = PhotoUploadMetadata {
capture_time: metadata
.capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
capture_time,
last_modification_time: capture_time
.and_then(pdfs_core::timestamp::modification_time),
..metadata
};
std::fs::File::open(path)
+1 -1
View File
@@ -40,4 +40,4 @@ pangocairo = "0.22"
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
# The system libintl (glibc) rather than a bundled gettext build.
gettext-rs = { version = "0.7", features = ["gettext-system"] }
gettext-rs = { version = "0.8", features = ["gettext-system"] }
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Album: a photo in front of the edge of another, a stack of them. -->
<path fill="#222222" d="M 4,1 H 13.5 A 1.5,1.5 0 0 1 15,2.5 V 12 H 13.5 V 2.5 H 4 Z"/>
<path fill="#222222" fill-rule="evenodd" d="M 2.5,4 H 10.5 A 1.5,1.5 0 0 1 12,5.5 V 13.5 A 1.5,1.5 0 0 1 10.5,15 H 2.5 A 1.5,1.5 0 0 1 1,13.5 V 5.5 A 1.5,1.5 0 0 1 2.5,4 Z M 2.5,5.5 V 13.5 H 10.5 V 5.5 Z"/>
<path fill="#222222" d="M 3.5,12.5 L 5.5,9.2 L 7.2,11.2 L 8.2,10.2 L 9.5,12.5 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Photos: a framed landscape with a sun. Stock themes only have
image-x-generic, a file type icon, which reads as "an image file"
rather than "your photo library". -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 3.5,11.5 L 6.5,7 L 9,10.2 L 10.5,8.6 L 12.5,11.5 Z"/>
<circle fill="#222222" cx="10.5" cy="5.8" r="1.3"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Slideshow: a play mark inside a photo frame, so it is not mistaken for
playing the video on screen. -->
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
<path fill="#222222" d="M 6.5,5.2 L 11,8 L 6.5,10.8 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -15,6 +15,9 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-photos-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-album-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-slideshow-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
+26 -7
View File
@@ -53,7 +53,7 @@ button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
scale.browser-status-meter trough { min-width: 140px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
@@ -61,14 +61,27 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
/* Photo viewer: always light-on-dark, whatever the system style */
.photo-viewer-window { background-color: #111014; }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
.media-player-window video { background-color: black; }
.media-player-note { color: rgba(255, 255, 255, 0.5); }
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.45) 60%, rgba(0, 0, 0, 0)); padding: 10px 16px 40px 20px; color: white; }
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
/* Dark discs with a white glyph read on a white photo as well as on a black one. */
.viewer-action-btn { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; min-width: 36px; min-height: 36px; margin-left: 4px; -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
.viewer-action-btn:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
.viewer-close-btn { margin-left: 12px; }
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
.viewer-group-pill { color: white; background-color: rgba(0, 0, 0, 0.5); border-radius: 999px; min-height: 36px; padding: 0 14px; font-weight: 700; font-size: 0.85rem; }
.viewer-group-pill:hover { background-color: rgba(0, 0, 0, 0.75); color: white; }
.viewer-group-pill.lit { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
/* The thumbnail strip along the lightbox's bottom edge, the photo on screen ringed in the accent. */
.viewer-strip-bar { background: linear-gradient(to top, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)); padding: 28px 12px 10px 12px; }
.viewer-strip { background: none; }
.viewer-strip > row { padding: 2px; margin: 0 2px; border-radius: 8px; background: none; }
.viewer-strip > row:hover { background-color: rgba(255, 255, 255, 0.15); }
.viewer-strip > row:selected { background-color: var(--accent-bg-color); }
.viewer-strip-thumb { border-radius: 6px; background-color: rgba(255, 255, 255, 0.08); }
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
.viewer-nav-btn:disabled { opacity: 0; }
@@ -80,7 +93,12 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
/* Photos grid */
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
.gallery-day { font-size: 1.1rem; font-weight: 700; padding: 4px 2px 8px 2px; }
/* The check that picks a whole section: on hover, and always while selecting. */
.gallery-day-check { opacity: 0; transition: opacity 160ms ease; min-width: 24px; min-height: 24px; margin-bottom: 4px; }
.gallery-heading:hover .gallery-day-check, .gallery-heading.selecting .gallery-day-check, .gallery-day-check:focus-visible { opacity: 1; }
/* The section at the top of the grid, floating once its heading scrolled off. */
.gallery-float-heading { font-weight: 700; padding: 4px 12px; border-radius: 999px; background-color: color-mix(in srgb, var(--popover-bg-color) 92%, transparent); color: var(--popover-fg-color); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25); }
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
.photo-thumb { transition: transform 220ms ease; }
@@ -94,6 +112,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
.photo-favorite-badge { color: white; -gtk-icon-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
.memory-card { padding: 0; border-radius: 12px; }
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
@@ -103,7 +122,7 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
.album-card:hover .photo-thumb { transform: scale(1.05); }
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
.view-switch button, .view-switch toggle { padding-left: 12px; padding-right: 12px; }
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
+12 -2
View File
@@ -128,8 +128,18 @@ fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
if changed(Topic::Shares) {
refresh_invitations_badge(ui, Duration::ZERO);
}
match ui.stack.visible_child_name().as_deref() {
Some("browser") if changed(Topic::Files) => load_browser(ui),
let page = ui.stack.visible_child_name();
// A timeline out of sight is read again when the user comes back to it;
// one on screen is left alone (below).
if changed(Topic::Photos) && (page.as_deref() != Some("gallery") || !photo_grid_shown(ui)) {
ui.gallery.timeline_stale.set(true);
}
match page.as_deref() {
// A search on screen is left alone: reloading would swap its hits for
// the folder's listing while the query stays in the box.
Some("browser") if changed(Topic::Files) && ui.browser.search.text().trim().is_empty() => {
load_browser(ui)
}
// The timeline is left alone: reloading it would throw away the place
// the user scrolled to. F5 reloads it on request.
Some("gallery")
+206 -27
View File
@@ -40,6 +40,8 @@ use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
use widgets::photo_drag::*;
use widgets::player::*;
use widgets::scrubber::*;
use widgets::share_dialog::*;
use widgets::thumbnails::*;
@@ -71,7 +73,7 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth;
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
use pdfs_core::config::{AppDirs, FileSort, FilesView, PhotosView, WindowState};
use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
@@ -125,6 +127,9 @@ struct Ui {
/// Whether this run already asked for the mailbox password of a locked
/// account, so a refresh never re-opens a dialog the user cancelled.
unlock_offered: Cell<bool>,
/// The `since` of the run of mount failures whose "folder isn't empty"
/// dialog this run already showed, so a page reload never shows it again.
not_empty_offered: Cell<i64>,
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
/// (which need the daemon to evict + re-hydrate) and every mutating action.
mounted: RefCell<bool>,
@@ -403,6 +408,7 @@ fn build_window(app: &adw::Application) {
opening: RefCell::new(HashSet::new()),
session: RefCell::new(startup_session),
unlock_offered: Cell::new(false),
not_empty_offered: Cell::new(0),
mounted: RefCell::new(false),
sidebar: sidebar_list.clone(),
nav: split.clone(),
@@ -453,6 +459,8 @@ fn build_window(app: &adw::Application) {
files: browser_widgets.files.clone(),
history: RefCell::new(Vec::new()),
future: RefCell::new(Vec::new()),
places: RefCell::new(HashMap::new()),
restore: RefCell::new(None),
crumb: browser_widgets.crumb.clone(),
content: browser_widgets.content.clone(),
status: browser_widgets.status.clone(),
@@ -484,6 +492,7 @@ fn build_window(app: &adw::Application) {
empty_actions: browser_widgets.empty_actions.clone(),
summary: browser_widgets.summary.clone(),
zoom: browser_widgets.zoom.clone(),
zoom_save: RefCell::new(None),
},
details: DetailsState {
details: browser_widgets.details,
@@ -518,6 +527,7 @@ fn build_window(app: &adw::Application) {
jump: Cell::new(None),
burst: Cell::new(false),
import_banner: gallery_widgets.import_banner.clone(),
upload_bar: gallery_widgets.upload_bar.clone(),
upload: gallery_widgets.upload.clone(),
empty_actions: gallery_widgets.empty_actions.clone(),
title: gallery_widgets.title.clone(),
@@ -583,10 +593,13 @@ fn build_window(app: &adw::Application) {
selected: RefCell::new(HashSet::new()),
select_anchor: RefCell::new(None),
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(),
select_album: gallery_widgets.select_album.clone(),
selection: gallery_widgets.selection.clone(),
more: gallery_widgets.more.clone(),
upload_face: gallery_widgets.upload_face.clone(),
float_heading: gallery_widgets.float_heading.clone(),
float_label: gallery_widgets.float_label.clone(),
square: Cell::new(false),
zoom_save: RefCell::new(None),
},
shared: SharedState::new(&shared_widgets),
shared_by_me: SharedByMeState::new(&shared_by_me_widgets),
@@ -689,7 +702,7 @@ fn build_window(app: &adw::Application) {
&ui,
&gallery_widgets.list,
&gallery_widgets.scroll,
&gallery_widgets.select_done,
&gallery_widgets.square_action,
);
wire_gallery_empty(
&ui,
@@ -718,17 +731,26 @@ fn build_window(app: &adw::Application) {
cancel_file_thumbnails(&ui_nav);
sync_sidebar(&ui_nav);
match st.visible_child_name().as_deref() {
Some("browser") => load_browser(&ui_nav),
Some("browser") => reload_browser_view(&ui_nav),
// The Albums and Places listings are re-read in place. Loading the
// timeline instead would swap it in under the Albums or Places toggle.
// The timeline, an album or a place comes back as it was left,
// scrolled where it was, unless photos changed while it was out of
// sight (see `follow_changes`) or nothing is loaded yet.
Some("gallery") if !ui_nav.gallery.in_collection() => {
match ui_nav.gallery.view_switch.active() {
VIEW_ALBUMS => load_albums(&ui_nav),
VIEW_PLACES => load_places(&ui_nav),
_ => load_gallery(&ui_nav, false),
_ if ui_nav.gallery.timeline_stale.replace(false)
|| ui_nav.gallery.model.n_items() == 0 =>
{
load_gallery(&ui_nav, false)
}
_ => {}
}
}
Some("gallery") => load_gallery(&ui_nav, false),
Some("gallery") if ui_nav.gallery.model.n_items() == 0 => load_gallery(&ui_nav, false),
Some("gallery") => {}
// Every visit reads the page again. The rows already on screen stay
// up while it does, so arriving costs no "Loading…" flash.
Some("sharedbyme") => load_shared_by_me(&ui_nav),
@@ -791,6 +813,7 @@ fn build_window(app: &adw::Application) {
/// split collapsed whatever the width.
fn set_narrow(ui: &Ui, narrow: bool) {
ui.narrow.set(narrow);
set_photos_narrow(ui, narrow);
if ui.session.borrow().is_some() {
ui.nav.set_collapsed(narrow);
}
@@ -854,11 +877,7 @@ fn window_hidden(window: &adw::ApplicationWindow) -> bool {
/// sync, set off by a separator.
const DESTINATIONS: [(&str, &str, &str); 7] = [
("browser", gettext_noop("My files"), "folder-symbolic"),
(
"gallery",
gettext_noop("Photos"),
"image-x-generic-symbolic",
),
("gallery", gettext_noop("Photos"), "pdfs-photos-symbolic"),
(
"shared",
gettext_noop("Shared with me"),
@@ -1061,7 +1080,7 @@ fn reload_current_page(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("browser") => {
let path = ui.browser.path.borrow().clone();
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
refresh_then(ui, RefreshScope::Dir { path }, reload_browser_view);
}
// One scope covers the whole photos view, so Refresh reloads whichever of
// the views — a grid, or the timeline, an album or a place — is on screen.
@@ -1323,7 +1342,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
// The Albums view's New Album button.
let new_album = gio::SimpleAction::new("new-album", None);
let ui_album = ui.clone();
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new()));
new_album.connect_activate(move |_, _| prompt_new_album(&ui_album, Vec::new(), None));
window.add_action(&new_album);
let shortcuts = gio::SimpleAction::new("shortcuts", None);
@@ -1501,7 +1520,7 @@ fn debug_info(ui: &Rc<Ui>) -> String {
/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what
/// [`install_shortcuts`], the Photos page and the photo viewer bind.
fn show_shortcuts(window: &adw::ApplicationWindow) {
const GROUPS: [(&str, &[(&str, &str)]); 4] = [
const GROUPS: [(&str, &[(&str, &str)]); 5] = [
(
gettext_noop("General"),
&[
@@ -1519,6 +1538,9 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Alt>Left <Alt>Right", gettext_noop("Back / forward")),
("<Alt>Up", gettext_noop("Parent folder")),
("<Control>1 <Control>2", gettext_noop("Grid / list")),
("<Control>plus", gettext_noop("Larger thumbnails")),
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("F2", gettext_noop("Rename")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Clear the selection")),
@@ -1531,6 +1553,7 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("<Control>minus", gettext_noop("Smaller thumbnails")),
("<Control>0", gettext_noop("Reset thumbnail size")),
("<Control>a", gettext_noop("Select all")),
("Escape <Alt>Left", gettext_noop("Back")),
],
),
(
@@ -1540,8 +1563,20 @@ fn show_shortcuts(window: &adw::ApplicationWindow) {
("Home End", gettext_noop("First / last photo")),
("i", gettext_noop("Show details")),
("f", gettext_noop("Fullscreen")),
("F5", gettext_noop("Slideshow")),
("F10", gettext_noop("More")),
("<Control>c", gettext_noop("Copy Image")),
("Delete", gettext_noop("Move to Trash")),
("Escape", gettext_noop("Close")),
("Escape <Alt>Left", gettext_noop("Close")),
],
),
(
gettext_noop("Video Player"),
&[
("space k", gettext_noop("Play / pause")),
("j l", gettext_noop("Back / forward 10 seconds")),
("m", gettext_noop("Mute")),
("f", gettext_noop("Fullscreen")),
],
),
];
@@ -1567,8 +1602,17 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
controller.connect_key_pressed(move |_, key, _, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser");
let on_trash = ui.stack.visible_child_name().as_deref() == Some("trash");
let page = ui.stack.visible_child_name();
let on_browser = page.as_deref() == Some("browser");
let on_trash = page.as_deref() == Some("trash");
let on_gallery = page.as_deref() == Some("gallery");
// Pages with a level to go back to, besides My files' own history.
let in_collection = on_gallery && ui.gallery.in_collection();
let in_shared_folder =
page.as_deref() == Some("shared") && !ui.shared.nav.borrow().is_empty();
let in_device_folder =
page.as_deref() == Some("devices") && !ui.devices.nav.borrow().is_empty();
let back_key = (key == gtk4::gdk::Key::Left && alt) || key == gtk4::gdk::Key::BackSpace;
match key {
// Refresh works on every page, so it is matched before the
// browser-only bindings.
@@ -1584,6 +1628,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
ui.browser.search.grab_focus();
}
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
gtk4::gdk::Key::plus | gtk4::gdk::Key::equal | gtk4::gdk::Key::KP_Add
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, 1)
}
gtk4::gdk::Key::minus | gtk4::gdk::Key::KP_Subtract
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, -1)
}
gtk4::gdk::Key::_0 | gtk4::gdk::Key::KP_0
if ctrl && on_browser && !ui.browser.files.shows_list() =>
{
zoom_files(&ui, 0)
}
gtk4::gdk::Key::_1 | gtk4::gdk::Key::_2 if ctrl && on_browser => set_files_view(
&ui,
FilesView {
@@ -1610,6 +1669,21 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
gtk4::gdk::Key::BackSpace if on_browser => {
ui.browser.actions.activate_action("back", None)
}
// Selection mode ends before Escape leaves anything; the grid
// handles this itself while it has the focus.
gtk4::gdk::Key::Escape if on_gallery && ui.gallery.selecting.get() => {
set_selection_mode(&ui, false)
}
// An album or a place is a level below Photos, as a folder is
// below its parent: Back leaves it, and so does Escape.
_ if in_collection
&& !ui.gallery.selecting.get()
&& (back_key || key == gtk4::gdk::Key::Escape) =>
{
ui.gallery.back.emit_clicked()
}
_ if in_shared_folder && back_key => shared_back(&ui),
_ if in_device_folder && back_key => devices_back(&ui),
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
gtk4::gdk::Key::Menu if visible_file_list(&ui).is_some() => {
if let Some(list) = visible_file_list(&ui) {
@@ -1797,13 +1871,52 @@ pub(crate) fn service_unreachable(
);
return;
}
status(
&ui,
"network-idle-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive service to start."),
false,
);
// Running but not answering may be a mount that fails on every try,
// which never opens the socket. Its reason is in a file the daemon
// keeps for this (B263); "Connecting…" alone looked like a slow start
// for as long as it lasted.
let state_dir = ui.dirs.state_dir();
let failure = gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
.await
.ok()
.flatten();
match failure {
Some(failure) if failure.not_empty.is_some() => {
let found = failure.not_empty.as_ref().expect("checked above");
status(
&ui,
"dialog-warning-symbolic",
&gettext("Can't open your Drive"),
// Translators: {path} is the folder My files is mounted at.
&gettext_f(
"“{path}” already has files in it, and My files would hide them. Proton Drive tries again on its own once the folder is empty. You can also choose another folder for My files in Preferences.",
&[("path", &found.path.display().to_string())],
),
true,
);
if ui.not_empty_offered.replace(failure.since) != failure.since {
offer_not_empty(&ui, found);
}
}
Some(failure) => status(
&ui,
"dialog-warning-symbolic",
&gettext("Can't open your Drive"),
// Translators: {error} is an error message, in English.
&gettext_f(
"The Proton Drive service keeps failing to start and tries again on its own. The last error was: {error}",
&[("error", &failure.error)],
),
true,
),
None => status(
&ui,
"network-idle-symbolic",
&gettext("Connecting…"),
&gettext("Waiting for the Proton Drive service to start."),
false,
),
}
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some(page) {
reload(&ui);
@@ -1812,6 +1925,37 @@ pub(crate) fn service_unreachable(
});
}
/// Tell the user the daemon will not mount My files over a folder with files
/// in it (B277), and offer the two ways out: open the folder to move them, or
/// choose another one.
fn offer_not_empty(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
let path = found.path.display().to_string();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Your Proton Drive Folder Isn't Empty"))
// Translators: {path} is the folder My files is mounted at; {n} is how
// many files and folders are already in it.
.body(ngettext_f(
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive does not open there. Move the item to another folder, or choose another folder for My files.",
"“{path}” already has {n} items in it. My files would hide them, so Proton Drive does not open there. Move the items to another folder, or choose another folder for My files.",
found.count as u64,
&[("path", &path)],
))
.build();
dialog.add_response("close", &gettext("Not Now"));
dialog.add_response("open", &gettext("Open Folder"));
dialog.add_response("choose", &gettext("Choose Another Folder…"));
dialog.set_response_appearance("choose", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("choose"));
dialog.set_close_response("close");
let ui_response = ui.clone();
dialog.connect_response(None, move |_, response| match response {
"open" => open_path(&path),
"choose" => prompt_mountpoint(&ui_response),
_ => {}
});
dialog.present(ui_window(ui).as_ref());
}
/// How long a copy button shows its check after copying.
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
@@ -1881,6 +2025,7 @@ mod tests {
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
place_id: None,
}
}
@@ -1903,6 +2048,40 @@ mod tests {
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The upload bar counts the upload under way, not the ones finished.
#[test]
fn the_upload_bar_counts_the_upload_under_way() {
assert_eq!(upload_progress_text(0, 12), "Uploading 1 of 12 items…");
assert_eq!(upload_progress_text(11, 12), "Uploading 12 of 12 items…");
}
/// The pill names what kind of shot it switches through, and lights up
/// on the file that is not the shot's main one.
#[test]
fn the_group_pill_names_the_kind_of_shot() {
let pair = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
];
assert_eq!(group_pill(&pair, "jpeg"), Some(("RAW".into(), false)));
assert_eq!(group_pill(&pair, "raw"), Some(("RAW".into(), true)));
let live = [
member("still", Some("IMG_2.HEIC"), PhotoKind::Photo),
member("clip", Some("IMG_2.MOV"), PhotoKind::Video),
];
assert_eq!(group_pill(&live, "still"), Some(("Live".into(), false)));
assert_eq!(group_pill(&live, "clip"), Some(("Live".into(), true)));
let burst = [
member("a", None, PhotoKind::Photo),
member("b", None, PhotoKind::Photo),
member("c", None, PhotoKind::Photo),
];
assert_eq!(group_pill(&burst, "b"), Some(("2 of 3".into(), false)));
assert_eq!(group_pill(&burst, "elsewhere"), None);
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
+35 -26
View File
@@ -40,7 +40,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
albums_status(
ui,
"view-grid-symbolic",
"pdfs-album-symbolic",
&gettext("Loading albums…"),
&gettext("Reading your Proton Drive albums."),
);
@@ -62,7 +62,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
),
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
&ui,
"view-grid-symbolic",
"pdfs-album-symbolic",
&gettext("No albums"),
&gettext("Create one with New Album, or in Proton Photos."),
),
@@ -136,7 +136,7 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
picture.add_css_class("photo-thumb");
let placeholder = gtk4::Image::builder()
.icon_name("view-grid-symbolic")
.icon_name("pdfs-album-symbolic")
.pixel_size(32)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
@@ -225,12 +225,13 @@ fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: &gtk4::Widget, x: f64
/// while the entry is blank.
fn prompt_album_name(
ui: &Rc<Ui>,
parent: Option<gtk4::Window>,
heading: &str,
action: &str,
current: &str,
on_name: impl Fn(String) + 'static,
) {
let win = ui_window(ui);
let win = parent.or_else(|| ui_window(ui));
let dialog = adw::AlertDialog::builder().heading(heading).build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
@@ -260,10 +261,12 @@ fn prompt_album_name(
}
/// Ask for a name and create an album, then add `photos` to it, if any.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
///
/// The dialogs open over `parent`, or over the main window without one.
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let ui_c = ui.clone();
let (heading, action) = (gettext("New Album"), gettext("Create"));
prompt_album_name(ui, &heading, &action, "", move |name| {
prompt_album_name(ui, parent, &heading, &action, "", move |name| {
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
let ui = ui_c.clone();
let photos = photos.clone();
@@ -296,7 +299,7 @@ pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
let (ui_c, uid) = (ui.clone(), album.uid.clone());
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
prompt_album_name(ui, None, &heading, &action, &album.name, move |name| {
run_album_request(
&ui_c,
Request::RenameAlbum {
@@ -368,7 +371,7 @@ fn reload_album_grid(ui: &Rc<Ui>) {
}
/// Offer our own albums to add `photos` to, plus a new one.
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>, parent: Option<gtk4::Window>) {
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
let ui = ui.clone();
glib::spawn_future_local(async move {
@@ -390,7 +393,7 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
}
};
if albums.is_empty() {
prompt_new_album(&ui, photos);
prompt_new_album(&ui, photos, parent);
return;
}
@@ -436,11 +439,11 @@ pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
}
});
let ui_c = ui.clone();
let (ui_c, parent_c) = (ui.clone(), parent.clone());
dialog.connect_response(Some("new"), move |_, _| {
prompt_new_album(&ui_c, photos.clone())
prompt_new_album(&ui_c, photos.clone(), parent_c.clone())
});
dialog.present(ui_window(&ui).as_ref());
dialog.present(parent.or_else(|| ui_window(&ui)).as_ref());
});
}
@@ -595,10 +598,7 @@ pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: &gtk4::Picture) {
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
return;
}
ui.gallery
.thumb_wanted
.borrow_mut()
.insert(uid.clone(), picture.clone());
ui.gallery.await_thumb(&uid, picture);
let mut queue = ui.gallery.thumb_queue.borrow_mut();
if !queue.contains(&uid) {
queue.push_back(uid);
@@ -618,9 +618,7 @@ pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
// would silently do something else. The Photos/Albums switcher goes too: an
// album is a level below it, and back is the way out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
sync_header(ui);
// A stale kind/date filter would otherwise be sent with the album request.
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
@@ -636,9 +634,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
return;
}
ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
sync_header(ui);
}
/// Show the album grid's own status page (loading, empty, or an error).
@@ -668,12 +664,25 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
}
close_album(&ui_photos);
close_place(&ui_photos);
ui_photos.gallery.filters.set_visible(true);
ui_photos.gallery.content.set_visible_child_name("timeline");
let gallery = &ui_photos.gallery;
gallery.filters.set_visible(true);
gallery.content.set_visible_child_name("timeline");
// An album or a place cleared the kind and the month it does not use;
// the filter controls still show them, so the timeline comes back
// filtered the way they say.
let kind = kind_for_tab(gallery.tabs.active() as usize);
let range = gallery
.date_ranges
.borrow()
.get(gallery.dates.selected() as usize)
.copied()
.flatten();
let kind_changed = gallery.kind.replace(kind) != kind;
let range_changed = gallery.range.replace(range) != range;
let refiltered = kind_changed || range_changed;
// The timeline comes back as it was left unless an album took over the
// model since; then it has to be reloaded.
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
{
if gallery.timeline_stale.replace(false) || refiltered || gallery.model.n_items() == 0 {
load_gallery(&ui_photos, false);
} else {
update_gallery_subtitle(&ui_photos);
+195 -65
View File
@@ -1,6 +1,14 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
use crate::*;
/// How a folder in My files was left: scrolled `offset` px down, with the
/// entries named `selected` highlighted.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct FolderPlace {
pub(crate) offset: f64,
pub(crate) selected: Vec<String>,
}
pub(crate) struct BrowserState {
// Files (browser) page.
/// The grid and list; the model is repopulated per directory.
@@ -10,6 +18,13 @@ pub(crate) struct BrowserState {
/// breadcrumb.
pub(crate) history: RefCell<Vec<String>>,
pub(crate) future: RefCell<Vec<String>>,
/// Where each folder left by Back, Forward, Up or a descent was scrolled
/// and what was selected in it, by path. Back, Forward and Up put the
/// folder back that way; opening it afresh starts at the top.
pub(crate) places: RefCell<HashMap<String, FolderPlace>>,
/// The folder whose [`Self::places`] entry the next listing restores, set
/// by Back, Forward and Up.
pub(crate) restore: RefCell<Option<String>>,
/// Clickable breadcrumb trail (a button per path segment); rebuilt per load
/// by [`repaint_crumb`] so each ancestor folder navigates on click.
pub(crate) crumb: gtk4::Box,
@@ -77,6 +92,8 @@ pub(crate) struct BrowserState {
/// zoom value, and Proton account storage usage.
pub(crate) summary: gtk4::Label,
pub(crate) zoom: gtk4::Scale,
/// Saves the zoom once the slider rests, not on every step.
pub(crate) zoom_save: RefCell<Option<glib::SourceId>>,
}
/// Idle pause after the last keystroke before a search query is sent, so typing
@@ -87,9 +104,11 @@ pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250);
pub(crate) const SEARCH_LIMIT: usize = 200;
/// Browser grid thumbnail size controlled by the bottom zoom slider. The
/// default preserves the former fixed 72 px presentation.
/// default preserves the former fixed 72 px presentation. The largest size is
/// big enough to tell photos apart by their thumbnail, and thumbnails are
/// 512 px, so they stay sharp up to it on a 2× screen.
pub(crate) const GRID_THUMB_MIN: i32 = 48;
pub(crate) const GRID_THUMB_MAX: i32 = 144;
pub(crate) const GRID_THUMB_MAX: i32 = 256;
pub(crate) const GRID_THUMB_DEFAULT: i32 = 72;
pub(crate) const GRID_THUMB_STEP: i32 = 8;
@@ -801,7 +820,35 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>) {
&[],
)));
ui_zoom.browser.files.set_thumbnail_size(size);
save_icon_size(&ui_zoom, size);
});
// Ctrl+scroll steps the icon size, as in Photos. Capture phase so the
// scrolled window does not scroll the grid as well. A touchpad sends many
// small deltas, so they add up to a whole notch before each step.
if let Some(grid_scroll) = files.views.child_by_name("grid") {
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
let ui_wheel = ui.clone();
let pending = Cell::new(0.0_f64);
wheel.connect_scroll(move |controller, _dx, dy| {
if !controller
.current_event_state()
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
{
pending.set(0.0);
return glib::Propagation::Proceed;
}
let total = pending.get() + dy;
let steps = total.trunc();
pending.set(total - steps);
if steps != 0.0 {
// Scrolling up (negative dy) makes the icons bigger.
zoom_files(&ui_wheel, -(steps as i32));
}
glib::Propagation::Stop
});
grid_scroll.add_controller(wheel);
}
files
.column_view
@@ -851,27 +898,15 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
// Open does what a double-click does: a video or a song plays in the app,
// and Open With is the way to another player.
menu.item(&pgettext("verb", "Open"), move || {
// Open always means "download a local copy and hand off", even for a
// media file — `activate_entry` would otherwise stream it.
if is_streamable_media_entry(&entry_c) {
download_and_open(&ui_c, &entry_c);
} else {
activate_entry(&ui_c, &entry_c);
}
activate_entry(&ui_c, &entry_c)
});
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
// Play streams from the mount, no download; Open above still fetches a
// local copy for anyone who wants one.
if is_streamable_media_entry(entry) {
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&pgettext("verb", "Play"), move || {
stream_entry(&ui_c, &entry_c)
});
}
menu.section();
// Kept through a pinned folder above it, the entry has no pin of its own
@@ -1060,38 +1095,8 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
Some(entry)
}
/// Whether an entry can be opened through FUSE instead of fully materialized.
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
}
/// Stream media straight from the mount, no download. Drive folders *are* part
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
/// seeks and buffers — instead of waiting for the whole file to land like
/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC
/// `.mkv` starts playing in seconds.
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let abs = mounted_path(&mountpoint, &rel);
let Some(path) = abs.to_str() else {
toast_error(
ui,
&gettext("Couldn't play media"),
&gettext("The file path isn't valid UTF-8."),
);
return;
};
toast(
ui,
&gettext_f("Streaming “{name}”…", &[("name", &entry.name)]),
);
play_external(path);
}
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
/// other files download-and-open.
/// Open an entry the Nautilus way: folders descend, media plays in the app,
/// streamed from the mount, other files download-and-open.
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
if entry.is_dir {
@@ -1104,16 +1109,16 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
// Media streams rather than downloads: that is exactly the "play it,
// don't fetch the whole thing" behaviour this is for.
stream_entry(ui, entry);
open_media_player(ui, entry);
} else {
download_and_open(ui, entry);
}
}
/// Hand a file to the user's default application: through the mount when it is
/// there, otherwise downloaded into the cache first. The open path behind both
/// a plain double-click and the context menu's "Open" (including for a video,
/// when the user wants their default application rather than the player).
/// there, otherwise downloaded into the cache first. The open path behind a
/// double-click and the context menu's "Open" for anything but a folder or
/// media, and for media the mount doesn't show.
///
/// The mount comes first because the cache blob is a *copy* keyed by content
/// hash — an application saving into it writes where Drive will never look, and
@@ -1306,6 +1311,11 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
let view = ui.dirs.load_config().files_view;
ui.browser.view.set(view);
if let Some(size) = view.icon_size {
ui.browser
.zoom
.set_value(f64::from(size.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)));
}
let layout = gio::SimpleAction::new_stateful(
"view",
Some(glib::VariantTy::STRING),
@@ -1407,6 +1417,41 @@ pub(crate) fn wire_browser_actions(ui: &Rc<Ui>, build_thumbnails: &gtk4::Button)
ui.browser.content.add_controller(drop);
}
/// Remember the grid's icon size, once the slider has rested for a moment.
fn save_icon_size(ui: &Rc<Ui>, size: i32) {
let mut view = ui.browser.view.get();
if view.icon_size == Some(size) {
return;
}
view.icon_size = Some(size);
ui.browser.view.set(view);
if let Some(source) = ui.browser.zoom_save.borrow_mut().take() {
source.remove();
}
let ui_save = ui.clone();
let source = glib::timeout_add_local_once(Duration::from_millis(500), move || {
ui_save.browser.zoom_save.borrow_mut().take();
let mut config = ui_save.dirs.load_config();
config.files_view.icon_size = ui_save.browser.view.get().icon_size;
if let Err(e) = ui_save.dirs.save_config(&config) {
tracing::warn!("cannot save the icon size: {e}");
}
});
*ui.browser.zoom_save.borrow_mut() = Some(source);
}
/// Step the grid's icon size by `steps` slider notches; `0` resets it.
pub(crate) fn zoom_files(ui: &Rc<Ui>, steps: i32) {
let zoom = &ui.browser.zoom;
let size = if steps == 0 {
GRID_THUMB_DEFAULT
} else {
(zoom.value().round() as i32 + steps * GRID_THUMB_STEP)
.clamp(GRID_THUMB_MIN, GRID_THUMB_MAX)
};
zoom.set_value(f64::from(size));
}
fn layout_name(view: FilesView) -> &'static str {
if view.list { "list" } else { "grid" }
}
@@ -1484,8 +1529,29 @@ pub(crate) fn sort_entries(entries: &mut [DirEntry], view: FilesView) {
});
}
/// Remember how the folder on screen is scrolled and what is selected in it,
/// for Back, Forward or Up to bring it back that way. Only a listing of the
/// folder itself counts; search hits are not its rows.
fn remember_place(ui: &Rc<Ui>) {
let path = ui.browser.path.borrow().clone();
if ui.browser.listed.borrow().as_deref() != Some(path.as_str()) {
return;
}
let files = &ui.browser.files;
let place = FolderPlace {
offset: files.scroll_offset(),
selected: files
.selected()
.into_iter()
.map(|entry| entry.name)
.collect(),
};
ui.browser.places.borrow_mut().insert(path, place);
}
/// Open the folder on screen in a new place, remembering where we were.
pub(crate) fn browse_to(ui: &Rc<Ui>, target: String) {
remember_place(ui);
let left = ui.browser.path.replace(target.clone());
if left != target {
ui.browser.history.borrow_mut().push(left);
@@ -1510,6 +1576,8 @@ fn browse_back(ui: &Rc<Ui>) {
let Some(previous) = ui.browser.history.borrow_mut().pop() else {
return;
};
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(previous.clone());
let left = ui.browser.path.replace(previous);
ui.browser.future.borrow_mut().push(left);
load_browser(ui);
@@ -1520,6 +1588,8 @@ fn browse_forward(ui: &Rc<Ui>) {
return;
};
ui.browser.search.set_text("");
remember_place(ui);
*ui.browser.restore.borrow_mut() = Some(next.clone());
let left = ui.browser.path.replace(next);
ui.browser.history.borrow_mut().push(left);
load_browser(ui);
@@ -1532,6 +1602,7 @@ fn browse_up(ui: &Rc<Ui>) {
}
ui.browser.search.set_text("");
let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or_default();
*ui.browser.restore.borrow_mut() = Some(parent.to_string());
browse_to(ui, parent.to_string());
}
@@ -1728,8 +1799,9 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
let text = if status.scanning {
ui.browser.thumbnail_progress.pulse();
let folders = ngettext_f("{n} folder", "{n} folders", status.folders_scanned, &[]);
let images = ngettext_f("{n} image", "{n} images", status.images_found, &[]);
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 images".
// Translators: a count of images and videos that can have a thumbnail.
let images = ngettext_f("{n} file", "{n} files", status.images_found, &[]);
// Translators: {root} is a folder path or "Proton Drive"; {folders} and {images} are counts such as "3 folders" and "12 files".
gettext_f(
"Scanning {root}… {folders}, {images}",
&[("root", root), ("folders", &folders), ("images", &images)],
@@ -1743,7 +1815,7 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
ui.browser
.thumbnail_progress
.set_fraction(fraction.clamp(0.0, 1.0));
// Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are image counts.
// Translators: {root} is a folder path or "Proton Drive"; {done} and {total} are counts of images and videos.
gettext_f(
"Building thumbnails in {root}… {done} of {total}",
&[
@@ -1756,17 +1828,17 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
ui.browser.thumbnail_progress.set_fraction(1.0);
let available = status.completed.saturating_sub(status.failed);
match status.failed {
// Translators: {root} is a folder path or "Proton Drive".
// Translators: {root} is a folder path or "Proton Drive"; {n} counts images and videos.
0 => ngettext_f(
"Thumbnails ready for {n} image in {root}",
"Thumbnails ready for {n} images in {root}",
"Thumbnails ready for {n} file in {root}",
"Thumbnails ready for {n} files in {root}",
available,
&[("root", root)],
),
// Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images could not get a thumbnail.
// Translators: {root} is a folder path or "Proton Drive"; {failed} is how many images and videos could not get a thumbnail.
failed => ngettext_f(
"Thumbnails ready for {n} image in {root}; {failed} unavailable",
"Thumbnails ready for {n} images in {root}; {failed} unavailable",
"Thumbnails ready for {n} file in {root}; {failed} unavailable",
"Thumbnails ready for {n} files in {root}; {failed} unavailable",
available,
&[("root", root), ("failed", &failed.to_string())],
),
@@ -2765,6 +2837,11 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
if entry.is_dir {
return "folder";
}
// Every video type, including a `.ts` recording, which by its name alone
// is TypeScript.
if pdfs_core::control::is_video_file_name(&entry.name, Some(entry.size)) {
return "video-x-generic";
}
let ext = entry
.name
.rsplit('.')
@@ -2775,7 +2852,6 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => {
"image-x-generic"
}
"mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic",
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic",
"pdf" | "doc" | "docx" | "odt" => "x-office-document",
"xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet",
@@ -2844,6 +2920,18 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
});
}
/// Read again what My files shows: the search hits while there is a query,
/// the folder otherwise. [`load_browser`] alone would swap a search's hits
/// for the folder while the query stays in the box.
pub(crate) fn reload_browser_view(ui: &Rc<Ui>) {
let query = ui.browser.search.text().trim().to_string();
if query.is_empty() {
load_browser(ui);
} else {
run_search(ui, &query);
}
}
/// Clear the model and show the daemon's error on the status page. Used for
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
/// (which restarts the service) wouldn't help and isn't offered.
@@ -2882,7 +2970,10 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
show_location_column(ui, false);
*ui.browser.listing.borrow_mut() = entries.to_vec();
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
// `listed` still names the folder the rows belong to only on a refresh;
// any other load forgot it (see `load_browser`).
let path = ui.browser.path.borrow().clone();
let another_folder = ui.browser.listed.replace(Some(path.clone())).as_ref() != Some(&path);
ui.browser.summary.set_label(&listing_summary(
entries.iter().filter(|entry| !entry.is_dir).count(),
entries.iter().filter(|entry| entry.is_dir).count(),
@@ -2911,6 +3002,16 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
// Only the rows that changed are swapped, so a refresh keeps the
// selection, the scroll position and the thumbnails already drawn.
replace_items(&ui.browser.files.model, &sorted);
// Back, Forward and Up return to a folder as it was left; any other way
// in starts at the top.
let restore = ui.browser.restore.take().filter(|target| *target == path);
if another_folder {
let place = restore.and_then(|path| ui.browser.places.borrow().get(&path).cloned());
match place {
Some(place) => ui.browser.files.restore(place.offset, &place.selected),
None => ui.browser.files.scroll_to_top(),
}
}
}
/// Forget which folder the rows belong to and drop the selection, before the
@@ -3089,6 +3190,9 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
.collect();
sort_entries(&mut entries, ui.browser.view.get());
replace_items(&ui.browser.files.model, &entries);
// Every search starts over (`run_search` forgets the listing), so its hits
// read from the first one.
ui.browser.files.scroll_to_top();
}
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
@@ -3122,7 +3226,7 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)]
mod tests {
use super::{
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
MoveTarget, badge_for, copy_target_allowed, hit_location, icon_base_for, listing_summary,
move_target_allowed, show_thumbnail_build_progress, sort_entries,
};
use pdfs_core::config::{FileSort, FilesView};
@@ -3149,6 +3253,32 @@ mod tests {
}
}
#[test]
fn every_video_type_gets_the_video_icon() {
const MIB: u64 = 1024 * 1024;
for name in [
"clip.mp4",
"clip.MKV",
"broadcast.m2ts",
"camera.mts",
"old.flv",
] {
assert_eq!(
icon_base_for(&entry(name, false, 10, 0)),
"video-x-generic",
"{name}"
);
}
assert_eq!(
icon_base_for(&entry("recording.ts", false, 900 * MIB, 0)),
"video-x-generic"
);
assert_eq!(
icon_base_for(&entry("app.ts", false, 4096, 0)),
"text-x-generic"
);
}
fn names(entries: &[DirEntry]) -> Vec<&str> {
entries.iter().map(|e| e.name.as_str()).collect()
}
+97 -11
View File
@@ -190,10 +190,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.devices.nav.borrow_mut().pop();
load_devices(&ui_back);
});
widgets
.back
.connect_clicked(move |_| devices_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -211,6 +210,14 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
ui.devices.content.set_visible_child_name("status");
}
/// Back out of a computer's folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn devices_back(ui: &Rc<Ui>) {
if ui.devices.nav.borrow_mut().pop().is_some() {
load_devices(ui);
}
}
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
@@ -297,6 +304,7 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
};
ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav);
let another_folder = ui.devices.listed.borrow().as_ref() != Some(&nav);
*ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.devices.files.model.remove_all();
@@ -304,6 +312,10 @@ fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
} else {
ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries);
// A refresh keeps its place; another folder starts at the top.
if another_folder {
ui.devices.files.scroll_to_top();
}
}
});
}
@@ -588,6 +600,7 @@ fn platform_label(device_type: &str) -> &str {
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
let folders: Vec<_> = folders.iter().filter(|f| f.kind == "device").collect();
if folders.is_empty() {
return gettext("No folders backed up yet");
}
@@ -597,6 +610,19 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
folders.len() as u64,
&[],
);
// A folder whose first pass has not run is not backed up yet, whatever its
// state column says (`docs/BUGS.md` B261).
let waiting = folders.iter().filter(|f| never_synced(f)).count();
let count = match waiting {
0 => count,
// Translators: {folders} is "N folders backed up"; {n} counts the folders whose first sync has not run yet.
n => ngettext_f(
"{folders} · {n} waiting for its first sync",
"{folders} · {n} waiting for their first sync",
n as u64,
&[("folders", &count)],
),
};
let attention = folders
.iter()
.filter(|f| f.state == "error" || f.state == "conflict")
@@ -613,12 +639,21 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
}
}
/// Human label for a synced folder's `state` column.
pub(crate) fn sync_state_label(state: &str) -> String {
/// Whether a synced folder is a mirror whose first pass has not finished.
/// Its `state` starts out `idle`, which would read as "up to date".
fn never_synced(folder: &SyncFolderInfo) -> bool {
folder.mode == "mirror" && folder.last_sync == 0 && folder.state == "idle"
}
/// Human label for a synced folder's `state` column. `never_synced` is a
/// mirror folder whose first pass has not finished: its `state` starts out
/// `idle`, and "up to date" would say the opposite of the truth (B261).
pub(crate) fn sync_state_label(state: &str, never_synced: bool) -> String {
match state {
"syncing" => gettext("syncing…"),
"error" => gettext("sync error"),
"conflict" => gettext("needs attention"),
_ if never_synced => gettext("waiting for first sync"),
_ => gettext("up to date"),
}
}
@@ -917,7 +952,13 @@ pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
pub(crate) fn prompt_remove_sync_folder(
ui: &Rc<Ui>,
id: i64,
path: &str,
ondemand: bool,
attached: bool,
) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
@@ -925,7 +966,12 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
let body = if attached {
gettext_f(
"Stop syncing “{path}”? Both the local files and the folder in My files are kept.",
&[("path", path)],
)
} else if ondemand {
// Translators: {path} is a local folder path.
gettext_f(
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
@@ -953,7 +999,9 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
if !attached {
dialog.set_extra_child(Some(&group));
}
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", &gettext("Stop Syncing"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
@@ -966,7 +1014,7 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
delete_remote: !attached && delete_remote.is_active(),
},
&gettext("Stopped syncing folder"),
&gettext("Couldn't stop syncing the folder"),
@@ -1269,23 +1317,61 @@ mod tests {
fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo {
kind: "device".into(),
id: 1,
local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(),
mode: "mirror".into(),
state: state.into(),
last_sync: 0,
last_sync: 1_700_000_000,
pending_mode: None,
progress: None,
paused: false,
}
}
#[test]
fn attached_my_files_folders_do_not_count_as_device_backups() {
let mut attached = folder("error");
attached.kind = "attached".into();
assert_eq!(
this_computer_subtitle(&[attached]),
"No folders backed up yet"
);
}
#[test]
fn a_machine_with_no_folders_says_so() {
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
}
/// A folder added a moment ago is not backed up yet, and the row must not
/// say it is (`docs/BUGS.md` B261). An on-demand folder has no first sync.
#[test]
fn a_folder_before_its_first_sync_is_not_counted_as_backed_up() {
let new = SyncFolderInfo {
last_sync: 0,
..folder("idle")
};
assert_eq!(
this_computer_subtitle(&[folder("idle"), new.clone()]),
"2 folders backed up · 1 waiting for its first sync"
);
let failed = SyncFolderInfo {
state: "error".into(),
..new.clone()
};
assert_eq!(
this_computer_subtitle(&[new.clone(), failed]),
"2 folders backed up · 1 waiting for its first sync · 1 needs attention"
);
let on_demand = SyncFolderInfo {
mode: "ondemand".into(),
..new
};
assert_eq!(this_computer_subtitle(&[on_demand]), "1 folder backed up");
}
#[test]
fn folders_needing_attention_are_counted_not_hidden() {
// The per-folder rows live on the Sync page, so this row is the only
+49 -6
View File
@@ -1361,6 +1361,9 @@ fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
prompt_mountpoint(&ui)
});
}
MountKind::Attached { sync_folder_id } => {
add_device_items(ui, &mut menu, spec, *sync_folder_id);
}
MountKind::Device { sync_folder_id } => {
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
add_device_items(ui, &mut menu, spec, *sync_folder_id);
@@ -1520,8 +1523,9 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
// The folder's *current* mode, not a queued one: a switch that hasn't landed
// yet has not moved the files anywhere.
let is_ondemand = spec.mode == MountMode::OnDemand;
let attached = matches!(spec.kind, MountKind::Attached { .. });
menu.item(&gettext("Stop Syncing…"), move || {
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
prompt_remove_sync_folder(&ui, id, &path, is_ondemand, attached)
});
}
@@ -1699,7 +1703,7 @@ fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
match kind {
MountKind::MyFiles => "folder-remote-symbolic",
MountKind::Device { .. } => "folder-symbolic",
MountKind::Device { .. } | MountKind::Attached { .. } => "folder-symbolic",
MountKind::Shared { .. } => "pdfs-people-symbolic",
}
}
@@ -1716,6 +1720,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
match &spec.kind {
MountKind::MyFiles => parts.push(gettext("My files")),
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
MountKind::Attached { .. } => parts.push(gettext("My files mirror")),
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
@@ -1725,17 +1730,25 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
},
}
if matches!(spec.kind, MountKind::Device { .. }) {
if matches!(
spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) {
parts.push(match &spec.progress {
Some(p) => sync_progress_label(p),
None if spec.paused => pgettext("state", "paused"),
None => sync_state_label(&spec.state).to_string(),
None => sync_state_label(
&spec.state,
spec.mode == MountMode::Mirror && spec.last_sync == 0,
),
});
}
// Only the locations that are supposed to have a session report its absence:
// a mirror folder never has one.
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|| spec.mode == MountMode::OnDemand
let expects_session = !matches!(
spec.kind,
MountKind::Device { .. } | MountKind::Attached { .. }
) || spec.mode == MountMode::OnDemand
|| spec.pending_mode == Some(MountMode::OnDemand);
if expects_session && !spec.mounted {
// Translators: a folder's files can't be opened right now.
@@ -1766,6 +1779,18 @@ mod tests {
}
}
#[test]
fn an_attached_mirror_is_available_without_a_fuse_session() {
let spec = spec(
MountKind::Attached { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
let subtitle = location_subtitle(&spec);
assert!(subtitle.contains("My files mirror"), "{subtitle}");
assert!(!subtitle.contains("not available"), "{subtitle}");
}
#[test]
fn a_mirror_folder_is_never_reported_as_unmounted() {
// It is a plain local directory: no FUSE session is expected, so saying
@@ -1775,7 +1800,25 @@ mod tests {
MountMode::Mirror,
false,
);
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
}
/// A folder whose first pass has not run is not up to date, though its
/// state column starts out `idle` (`docs/BUGS.md` B261).
#[test]
fn a_folder_reads_up_to_date_only_after_its_first_sync() {
let mut spec = spec(
MountKind::Device { sync_folder_id: 7 },
MountMode::Mirror,
false,
);
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
spec.last_sync = 1_700_000_000;
assert_eq!(location_subtitle(&spec), "Synced · up to date");
// A folder that failed before it ever synced says so.
spec.last_sync = 0;
spec.state = "error".into();
assert_eq!(location_subtitle(&spec), "Synced · sync error");
}
#[test]
+486 -159
View File
@@ -40,7 +40,9 @@ pub(crate) struct Viewer {
/// Details panel: the toggle that reveals it, the rows it fills in, and the
/// "Show on map" button (hidden when the photo carries no GPS tags).
pub(crate) info_toggle: gtk4::ToggleButton,
pub(crate) info_revealer: gtk4::Revealer,
/// Lays the details panel beside the photo, or over it in a narrow
/// window.
pub(crate) info_split: adw::OverlaySplitView,
pub(crate) info_rows: gtk4::Box,
pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
@@ -50,9 +52,9 @@ pub(crate) struct Viewer {
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
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
/// The pill that switches between the files of one shot, and the files it
/// switches between (the JPEG and the RAW of a photo, a live photo and its
/// clip). Empty, and the button hidden, for a photo stored as one file.
/// clip). Empty, and the pill hidden, for a photo stored as one file.
pub(crate) group_btn: gtk4::Button,
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen.
@@ -79,14 +81,25 @@ pub(crate) struct Viewer {
/// rests; whether the pointer is over one of them (which keeps them up);
/// and the pending fade-out.
pub(crate) chrome: Vec<gtk4::Revealer>,
/// The strip of thumbnails along the bottom, its selection (the photo on
/// screen), and the revealer it fades with. Hidden in a slideshow.
pub(crate) strip: gtk4::ListView,
pub(crate) strip_selection: gtk4::SingleSelection,
pub(crate) strip_reveal: gtk4::Revealer,
pub(crate) chrome_hover: Cell<bool>,
pub(crate) chrome_source: RefCell<Option<glib::SourceId>>,
/// The slideshow toggle; the timer that moves it on; and whether it put
/// the window in fullscreen, so stopping it only leaves a fullscreen it
/// The ⋮ button, and whether its menu is open, which keeps the controls
/// up the way resting the pointer on them does.
pub(crate) more: gtk4::Button,
pub(crate) menu_open: Cell<bool>,
/// Whether a slideshow runs; the timer that moves it on; and whether it
/// put the window in fullscreen, so stopping it only leaves a fullscreen it
/// entered itself.
pub(crate) slideshow: gtk4::ToggleButton,
pub(crate) slideshow: Cell<bool>,
pub(crate) slide_source: RefCell<Option<glib::SourceId>>,
pub(crate) slide_fullscreened: Cell<bool>,
/// Toasts over the lightbox. The main window's would land behind it.
pub(crate) toasts: adw::ToastOverlay,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
@@ -161,6 +174,14 @@ fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
.group_btn
.set_tooltip_text(Some(&member_tooltip(next)));
}
if let Some((label, lit)) = group_pill(&items, &uid) {
viewer.group_btn.set_label(&label);
if lit {
viewer.group_btn.add_css_class("lit");
} else {
viewer.group_btn.remove_css_class("lit");
}
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
});
@@ -173,6 +194,34 @@ pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Opti
members.get((at + 1) % members.len())
}
/// What the group pill reads for the shot whose files are `members`, with
/// `uid` on screen, and whether it is lit. A raw pair reads "RAW", lit while
/// the raw file is shown; a live photo reads "Live", lit while its clip plays;
/// a burst counts its frames.
pub(crate) fn group_pill(members: &[PhotoItem], uid: &str) -> Option<(String, bool)> {
let at = members.iter().position(|item| item.uid == uid)?;
let shown = members[at].kind;
if members.iter().any(|item| item.kind == PhotoKind::Raw) {
// Translators: badge on a photo tile whose shot includes a raw camera file.
return Some((gettext("RAW"), shown == PhotoKind::Raw));
}
if members.len() == 2 && members.iter().any(|item| item.kind == PhotoKind::Video) {
// Translators: the switch between a live photo and its short clip in the photo viewer.
return Some((pgettext("live photo", "Live"), shown == PhotoKind::Video));
}
Some((
// Translators: the lightbox counter, such as "12 of 340"; {position} is the photo's place, {total} how many there are.
gettext_f(
"{position} of {total}",
&[
("position", &thousands(at + 1)),
("total", &thousands(members.len())),
],
),
false,
))
}
/// What to call one file of a shot: its name, or its kind when the daemon has
/// not resolved a name for it.
pub(crate) fn member_label(item: &PhotoItem) -> String {
@@ -230,6 +279,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
clear_info(viewer);
stop_video(viewer);
viewer.media.set_visible_child_name("photo");
sync_strip(viewer);
reset_zoom(viewer);
// A slideshow waits for this photo to land before it starts counting.
disarm_slide(viewer);
@@ -307,6 +357,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
None => {
viewer.video.set_filename(Some(&path));
viewer.media.set_visible_child_name("video");
sync_strip(&viewer);
*viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, size, ExifInfo::default());
}
@@ -339,14 +390,15 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
/// Start the slideshow: fullscreen, controls out of the way, and the photo on
/// screen counted down once it has loaded.
fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.replace(true) {
return;
}
if !viewer.window.is_fullscreen() {
viewer.window.fullscreen();
viewer.slide_fullscreened.set(true);
}
viewer
.slideshow
.set_icon_name("media-playback-pause-symbolic");
hide_chrome(viewer);
sync_strip(viewer);
if !viewer.loading.get() {
arm_slide(ui, viewer);
}
@@ -354,15 +406,20 @@ fn start_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
/// Stop the slideshow, leaving fullscreen only if the slideshow entered it.
fn stop_slideshow(viewer: &Rc<Viewer>) {
viewer.slideshow.set(false);
sync_strip(viewer);
disarm_slide(viewer);
if viewer.slide_fullscreened.replace(false) {
viewer.window.unfullscreen();
}
viewer
.slideshow
.set_icon_name("media-playback-start-symbolic");
if viewer.slideshow.is_active() {
viewer.slideshow.set_active(false);
}
/// Start the slideshow, or stop the one running.
fn toggle_slideshow(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if viewer.slideshow.get() {
stop_slideshow(viewer);
} else {
start_slideshow(ui, viewer);
}
}
@@ -370,7 +427,7 @@ fn stop_slideshow(viewer: &Rc<Viewer>) {
/// [`SLIDESHOW_INTERVAL`] for a still, once it has played through for a video.
fn arm_slide(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
disarm_slide(viewer);
if !viewer.slideshow.is_active() {
if !viewer.slideshow.get() {
return;
}
let playing_video = viewer.media.visible_child_name().as_deref() == Some("video");
@@ -790,7 +847,7 @@ fn show_chrome(viewer: &Rc<Viewer>) {
};
// This timer has fired, so its id must not be removed again.
viewer.chrome_source.borrow_mut().take();
if !viewer.chrome_hover.get() {
if !viewer.chrome_hover.get() && !viewer.menu_open.get() {
hide_chrome(&viewer);
}
});
@@ -808,14 +865,14 @@ fn hide_chrome(viewer: &Rc<Viewer>) {
}
/// Copy the photo on screen to where the user picks, under its Drive name.
pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &str, name: &str) {
pub(crate) fn save_photo_to_disk(viewer: &Rc<Viewer>, source: &str, name: &str) {
let dialog = gtk4::FileDialog::builder()
.title(gettext("Save a Copy"))
.initial_name(name)
.build();
let source = source.to_string();
let ui = ui.clone();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
let toasts = viewer.toasts.clone();
dialog.save(Some(&viewer.window), gio::Cancellable::NONE, move |res| {
// An error here is the dialog being dismissed.
let Some(dest) = res.ok().and_then(|file| file.path()) else {
return;
@@ -824,26 +881,67 @@ pub(crate) fn save_photo_to_disk(ui: &Rc<Ui>, window: &gtk4::Window, source: &st
glib::spawn_future_local(async move {
let copied =
gio::spawn_blocking(move || std::fs::copy(&source, &dest).map(|_| dest)).await;
match copied {
let message = match copied {
Ok(Ok(dest)) => {
let saved = dest.file_name().map_or_else(
|| dest.display().to_string(),
|name| name.to_string_lossy().into_owned(),
);
// Translators: {name} is the saved file's name.
toast(&ui, &gettext_f("Saved “{name}”", &[("name", &saved)]));
gettext_f("Saved “{name}”", &[("name", &saved)])
}
Ok(Err(e)) => toast_error(&ui, &gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => toast_error(
&ui,
Ok(Err(e)) => failure(&gettext("Couldn't save the copy"), &e.to_string()),
Err(_) => failure(
&gettext("Couldn't save the copy"),
&gettext("The copy was interrupted."),
),
}
};
toasts.add_toast(
adw::Toast::builder()
.title(&message)
.use_markup(false)
.build(),
);
});
});
}
/// A failed action's toast text, worded like [`toast_error`]'s.
fn failure(what: &str, detail: &str) -> String {
tracing::warn!("{what}: {detail}");
// Translators: a failed action's toast. {what} says what failed, such as
// "Couldn't rename"; {detail} is the reason, usually untranslated text
// from the mount service.
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
}
/// Put the photo on screen on the clipboard as an image, for pasting into a
/// chat or a document.
fn copy_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let Some(texture) = viewer
.picture
.paintable()
.and_downcast::<gtk4::gdk::Texture>()
else {
return;
};
viewer.window.clipboard().set_texture(&texture);
viewer.toasts.add_toast(
adw::Toast::builder()
// Translators: toast after the photo viewer put the photo on the clipboard.
.title(gettext("Image copied"))
.use_markup(false)
.build(),
);
}
/// Open the Share dialog for the photo on screen, over the lightbox.
fn share_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let name = viewer.name.borrow().clone().unwrap_or_default();
share_photo(ui, uid, name, Some(viewer.window.clone()));
}
/// A symlink to the cached file `path` under its Drive `name`, in a directory
/// of its own below `base`. The cache names files by content hash, with no
/// extension; the app chooser and the app it launches both need the real name
@@ -983,16 +1081,124 @@ pub(crate) fn position_label(index: u32, loaded: u32, more: bool, total: Option<
}
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
/// The lightbox's title: when the photo was taken, as "Wednesday, October 7,
/// 2026, 13:42".
fn photo_title(capture_time: i64) -> String {
let title = dates::full(capture_time);
if title.is_empty() {
gettext("Unknown Date")
} else {
title
}
}
/// Set the lightbox's title (the photo's date) and the line under it: the town
/// it was taken in, when known, and its "12 of 340" counter.
fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_time: i64) {
let loaded = ui.gallery.model.n_items();
let more = ui.gallery.has_more.get();
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < loaded || more);
viewer.title.set_label(&format_capture_time(capture_time));
viewer.title.set_label(&photo_title(capture_time));
let position = position_label(index, loaded, more, gallery_total(ui));
let place = viewer_item(ui, viewer, &viewer.uid.borrow())
.and_then(|item| item.place_id)
.and_then(pdfs_core::places::city)
.map(|city| city.name);
let line = match place {
Some(place) => {
let args = [("place", place), ("position", position.as_str())];
// Translators: the line under the photo viewer's title, such as "Berlin · 12 of 340"; {place} is a town, {position} the counter.
gettext_f("{place} · {position}", &args)
}
None => position,
};
viewer.counter.set_label(&line);
sync_strip(viewer);
mark_in_strip(viewer, index);
}
/// Show the strip of thumbnails when there is more than one photo to pick
/// from. Not in a slideshow, and not over a video, where it would cover the
/// player's seek bar.
fn sync_strip(viewer: &Rc<Viewer>) {
let video = viewer.media.visible_child_name().as_deref() == Some("video");
viewer
.counter
.set_label(&position_label(index, loaded, more, gallery_total(ui)));
.strip_reveal
.set_visible(viewer.strip_selection.n_items() > 1 && !viewer.slideshow.get() && !video);
}
/// Mark the photo at `index` in the strip and bring it into view.
fn mark_in_strip(viewer: &Rc<Viewer>, index: u32) {
if index >= viewer.strip_selection.n_items() {
return;
}
viewer.strip_selection.set_selected(index);
viewer
.strip
.scroll_to(index, gtk4::ListScrollFlags::NONE, None);
}
/// The strip of thumbnails along the lightbox's bottom edge: the gallery's
/// photos in its order, the one on screen marked. A click steps straight to a
/// photo; the thumbnails come through the gallery's own pipeline and cache.
fn build_strip(ui: &Rc<Ui>) -> (gtk4::ListView, gtk4::SingleSelection) {
let selection = gtk4::SingleSelection::new(Some(ui.gallery.model.clone()));
selection.set_autoselect(false);
selection.set_can_unselect(true);
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Cover)
.width_request(56)
.height_request(56)
.can_shrink(true)
.build();
picture.add_css_class("viewer-strip-thumb");
// The arrow keys belong to the lightbox, not to the strip.
item.set_focusable(false);
item.set_child(Some(&picture));
});
let ui_bind = ui.clone();
factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(picture) = item.child().and_downcast::<gtk4::Picture>() else {
return;
};
let Some(boxed) = item.item().and_downcast::<BoxedAnyObject>() else {
return;
};
let photo = boxed.borrow::<PhotoItem>().clone();
picture.set_paintable(gtk4::gdk::Paintable::NONE);
picture.set_tooltip_text(Some(&dates::full(photo.capture_time)));
want_thumb(&ui_bind, &photo, &picture);
schedule_thumbs(&ui_bind);
});
let ui_unbind = ui.clone();
factory.connect_unbind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let (Some(picture), Some(boxed)) = (
item.child().and_downcast::<gtk4::Picture>(),
item.item().and_downcast::<BoxedAnyObject>(),
) else {
return;
};
let uid = boxed.borrow::<PhotoItem>().uid.clone();
ui_unbind
.gallery
.release_thumb(&uid, |waiting| waiting == &picture);
});
let list = gtk4::ListView::builder()
.model(&selection)
.factory(&factory)
.orientation(gtk4::Orientation::Horizontal)
.focusable(false)
.build();
list.add_css_class("viewer-strip");
(list, selection)
}
/// The in-app lightbox: the photo or video, edge-to-edge on a dark backdrop,
@@ -1001,16 +1207,22 @@ fn show_photo_position(ui: &Rc<Ui>, viewer: &Rc<Viewer>, index: u32, capture_tim
///
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
/// button, or a click on the backdrop beside the photo all dismiss it.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
///
/// Returns the lightbox's window, for a caller that has to put the page
/// behind it back once it closes.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) -> gtk4::Window {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
// An `adw::Window` for its breakpoints: the details panel lies over the
// photo once the window is too narrow to show both side by side.
let adw_window = adw::Window::builder()
.title(gettext("Photo"))
.modal(true)
.transient_for(&parent)
.default_width(1100)
.default_height(760)
.build();
let window: gtk4::Window = adw_window.clone().upcast();
window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new();
@@ -1035,6 +1247,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &window);
let media = gtk4::Stack::new();
media.add_named(&scroller, Some("photo"));
media.add_named(&video, Some("video"));
@@ -1119,22 +1332,21 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn");
let slideshow_btn = gtk4::ToggleButton::builder()
.icon_name("media-playback-start-symbolic")
.tooltip_text(gettext("Slideshow (F5)"))
.valign(gtk4::Align::Center)
.build();
slideshow_btn.add_css_class("flat");
slideshow_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", &gettext("Other files of this photo"));
group_btn.set_visible(false);
// decides that per photo and labels it ("RAW", "Live", "2 of 3").
let group_btn = gtk4::Button::builder()
.valign(gtk4::Align::Center)
.visible(false)
.build();
group_btn.add_css_class("flat");
group_btn.add_css_class("viewer-group-pill");
let download_btn = action("document-save-symbolic", &gettext("Save a Copy…"));
let delete_btn = action("user-trash-symbolic", &gettext("Move to Trash (Delete)"));
let open_ext_btn = action("document-open-symbolic", &gettext("Open With…"));
// Everything used less often than the favorite and the details sits in
// one menu, named in words rather than in icons.
let more_btn = action("view-more-symbolic", &gettext("More"));
let fullscreen_btn = fullscreen_button(&window);
fullscreen_btn.add_css_class("flat");
fullscreen_btn.add_css_class("viewer-action-btn");
let close_btn = action("window-close-symbolic", &gettext("Close (Esc)"));
close_btn.add_css_class("viewer-close-btn");
@@ -1144,14 +1356,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&group_btn);
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&slideshow_btn);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&fullscreen_btn);
top_bar.append(&more_btn);
top_bar.append(&close_btn);
let top_reveal = fade(top_bar.upcast_ref(), gtk4::Align::Fill, gtk4::Align::Start);
overlay.add_overlay(&top_reveal);
let (strip, strip_selection) = build_strip(ui);
let strip_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::External)
.vscrollbar_policy(gtk4::PolicyType::Never)
.child(&strip)
.build();
strip_scroll.add_css_class("viewer-strip-bar");
let strip_reveal = fade(
strip_scroll.upcast_ref(),
gtk4::Align::Fill,
gtk4::Align::End,
);
overlay.add_overlay(&strip_reveal);
// Details panel: docked beside the image as a real surface, so opening it
// narrows the photo instead of covering part of it.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
@@ -1205,17 +1429,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
info_panel.add_css_class("viewer-info-panel");
info_panel.set_width_request(320);
info_panel.append(&info_scroll);
// Set explicitly: the title's hexpand would otherwise propagate up and have
// the revealer take half the window even while it is closed.
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.hexpand(false)
.child(&info_panel)
.build();
let spinner = spinner();
spinner.set_halign(gtk4::Align::Center);
spinner.set_valign(gtk4::Align::Center);
@@ -1232,10 +1447,25 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
body.append(&overlay);
body.append(&info_revealer);
window.set_child(Some(&body));
// The panel docks beside the photo, or lies over it in a narrow window,
// where a click on the photo closes it again.
let info_split = adw::OverlaySplitView::builder()
.content(&overlay)
.sidebar(&info_panel)
.sidebar_position(gtk4::PackType::End)
.min_sidebar_width(300.0)
.max_sidebar_width(360.0)
.show_sidebar(false)
.build();
let narrow = adw::Breakpoint::new(
adw::BreakpointCondition::parse("max-width: 800sp").expect("static breakpoint"),
);
narrow.add_setter(&info_split, "collapsed", Some(&true.to_value()));
adw_window.add_breakpoint(narrow);
let body = info_split.clone();
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&body));
adw_window.set_content(Some(&toasts));
let viewer = Rc::new(Viewer {
window: window.clone(),
@@ -1250,7 +1480,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
prev: prev_btn.clone(),
next: next_btn.clone(),
info_toggle: info_toggle.clone(),
info_revealer: info_revealer.clone(),
info_split: info_split.clone(),
info_rows,
info_map: info_map.clone(),
favorite: favorite_btn.clone(),
@@ -1266,12 +1496,23 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
pinch_start: Cell::new(1.0),
drag_start: Cell::new((0.0, 0.0)),
pointer: Cell::new((0.0, 0.0)),
chrome: vec![top_reveal.clone(), prev_reveal.clone(), next_reveal.clone()],
chrome: vec![
top_reveal.clone(),
prev_reveal.clone(),
next_reveal.clone(),
strip_reveal.clone(),
],
strip: strip.clone(),
strip_selection: strip_selection.clone(),
strip_reveal: strip_reveal.clone(),
chrome_hover: Cell::new(false),
chrome_source: RefCell::new(None),
slideshow: slideshow_btn.clone(),
more: more_btn.clone(),
menu_open: Cell::new(false),
slideshow: Cell::new(false),
slide_source: RefCell::new(None),
slide_fullscreened: Cell::new(false),
toasts,
});
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
@@ -1297,7 +1538,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
body.add_controller(motion);
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
for revealer in [&top_reveal, &prev_reveal, &next_reveal, &strip_reveal] {
let hover = gtk4::EventControllerMotion::new();
let viewer_enter = Rc::downgrade(&viewer);
hover.connect_enter(move |_, _, _| {
@@ -1413,13 +1654,52 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
w_close.close();
});
// A click on a thumbnail selects it; step there. Every selection the
// lightbox sets itself is the photo on screen, and the model's own changes
// (a page landing, a favorite rewritten in place) keep the selected photo
// or drop it, so a selection elsewhere can only be the user's.
let ui_strip = ui.clone();
let viewer_strip = Rc::downgrade(&viewer);
strip_selection.connect_selection_changed(move |selection, _, _| {
let Some(viewer) = viewer_strip.upgrade() else {
return;
};
let picked = selection.selected();
let Some(current) = find_photo_index(&ui_strip.gallery.model, &viewer.uid.borrow()) else {
return;
};
if picked != gtk4::INVALID_LIST_POSITION && picked != current {
navigate_photo(&ui_strip, &viewer, picked as i32 - current as i32);
}
});
let viewer_items = Rc::downgrade(&viewer);
let items_handler = ui
.gallery
.model
.connect_items_changed(move |model, _, _, _| {
let Some(viewer) = viewer_items.upgrade() else {
return;
};
if let Some(index) = find_photo_index(model, &viewer.uid.borrow()) {
viewer.strip_selection.set_selected(index);
}
sync_strip(&viewer);
});
// The one strong handle on the viewer. Every handler holds a weak one: a
// strong one in a handler of a widget the viewer holds is a cycle, which
// kept every closed lightbox, its window and its full-size photo in memory
// for the life of the app.
let keep = RefCell::new(Some(viewer.clone()));
let ui_close = ui.clone();
let items_handler = RefCell::new(Some(items_handler));
window.connect_close_request(move |_| {
// The gallery's model outlives the lightbox.
if let Some(handler) = items_handler.borrow_mut().take() {
ui_close.gallery.model.disconnect(handler);
}
if let Some(viewer) = keep.borrow_mut().take() {
reveal_photo(&ui_close, &viewer.uid.borrow());
stop_video(&viewer);
disarm_slide(&viewer);
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
@@ -1435,21 +1715,13 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let Some(viewer_info) = viewer_info.upgrade() else {
return;
};
viewer_info
.info_revealer
.set_reveal_child(toggle.is_active());
viewer_info.info_split.set_show_sidebar(toggle.is_active());
});
let ui_slides = ui.clone();
let viewer_slides = Rc::downgrade(&viewer);
slideshow_btn.connect_toggled(move |toggle| {
let Some(viewer_slides) = viewer_slides.upgrade() else {
return;
};
if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides);
} else {
stop_slideshow(&viewer_slides);
// A collapsed panel also closes on its own, by a click on the photo.
let toggle_info = info_toggle.clone();
info_split.connect_show_sidebar_notify(move |split| {
if toggle_info.is_active() != split.shows_sidebar() {
toggle_info.set_active(split.shows_sidebar());
}
});
@@ -1458,18 +1730,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let ui_map = ui.clone();
let viewer_map = Rc::downgrade(&viewer);
let w_map = window.downgrade();
info_map.connect_clicked(move |_| {
let Some(w_map) = w_map.upgrade() else {
return;
};
let Some(viewer_map) = viewer_map.upgrade() else {
return;
};
let coords = *viewer_map.coords.borrow();
if let Some((lat, lon)) = coords {
w_map.close();
show_on_map(&ui_map, lat, lon);
if let Some(viewer_map) = viewer_map.upgrade() {
show_photo_on_map(&ui_map, &viewer_map);
}
});
@@ -1498,6 +1761,9 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let failed = match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
set_gallery_favorite(&ui_result, &uid, favorite);
// The tiles hold their own copy of the photo, so the star
// shows on the grid only once it is laid out again.
repaint_gallery(&ui_result);
None
}
Ok(Ok(Response::Error { message, .. })) => Some(message),
@@ -1518,24 +1784,6 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
});
});
// Deleting the photo on screen closes the lightbox: what it was showing is
// gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see.
let ui_delete = ui.clone();
let viewer_delete = Rc::downgrade(&viewer);
let w_delete = window.downgrade();
delete_btn.connect_clicked(move |_| {
let Some(w_delete) = w_delete.upgrade() else {
return;
};
let Some(viewer_delete) = viewer_delete.upgrade() else {
return;
};
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid], false);
});
let ui_group = ui.clone();
let viewer_group = Rc::downgrade(&viewer);
group_btn.connect_clicked(move |_| {
@@ -1551,38 +1799,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}
});
let ui_download = ui.clone();
let w_download = window.downgrade();
let viewer_download = Rc::downgrade(&viewer);
download_btn.connect_clicked(move |_| {
let Some(w_download) = w_download.upgrade() else {
return;
};
let Some(viewer_download) = viewer_download.upgrade() else {
return;
};
if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = viewer_download.name.borrow();
save_photo_to_disk(
&ui_download,
&w_download,
path,
name.as_deref().unwrap_or("photo"),
);
}
});
let w_ext = window.downgrade();
let viewer_ext = Rc::downgrade(&viewer);
open_ext_btn.connect_clicked(move |_| {
let Some(w_ext) = w_ext.upgrade() else {
return;
};
let Some(viewer_ext) = viewer_ext.upgrade() else {
return;
};
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
let ui_more = ui.clone();
let viewer_more = Rc::downgrade(&viewer);
more_btn.connect_clicked(move |_| {
if let Some(viewer_more) = viewer_more.upgrade() {
open_viewer_menu(&ui_more, &viewer_more);
}
});
@@ -1660,13 +1881,22 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let alt = state.contains(gtk4::gdk::ModifierType::ALT_MASK);
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
if playing_video
&& let Some(name) = key.name()
&& player_key(&viewer_key.video, &name, state)
{
return glib::Propagation::Stop;
}
match key.name().as_deref() {
Some("space") if playing_video => {
if let Some(stream) = viewer_key.video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
// Alt+Left is Back everywhere else in the app.
Some("Left") if alt => w_key.close(),
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
@@ -1675,30 +1905,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
Some("minus" | "KP_Subtract") => zoom_by(&viewer_key, 1.0 / ZOOM_STEP),
Some("0" | "KP_0") => reset_zoom(&viewer_key),
Some("1" | "KP_1") => zoom_to(&viewer_key, Some(1.0), None),
Some("F5") => viewer_key
.slideshow
.set_active(!viewer_key.slideshow.is_active()),
Some("F5") => toggle_slideshow(&ui_key, &viewer_key),
// Escape ends a slideshow before it closes anything.
Some("Escape") if viewer_key.slideshow.is_active() => {
viewer_key.slideshow.set_active(false)
}
Some("Escape") if viewer_key.slideshow.get() => stop_slideshow(&viewer_key),
Some("F10" | "Menu") => open_viewer_menu(&ui_key, &viewer_key),
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
Some("f" | "F11") => {
if w_key.is_fullscreen() {
w_key.unfullscreen();
} else {
w_key.fullscreen();
}
}
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid], false);
}
Some("f" | "F11") => toggle_fullscreen(&w_key),
Some("Delete" | "KP_Delete") => trash_shown_photo(&ui_key, &viewer_key),
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
Some("c") if ctrl && viewer_key.path.borrow().is_some() && !playing_video => {
copy_shown_photo(&ui_key, &viewer_key)
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
@@ -1706,6 +1926,113 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
window.add_controller(key_controller);
window.present();
window
}
/// The ⋮ menu: what the lightbox can do with the photo on screen besides
/// starring it and showing its details. Built when it opens, so it offers
/// only what applies to this photo.
fn open_viewer_menu(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let weak = Rc::downgrade(viewer);
let with_viewer = move |run: fn(&Rc<Ui>, &Rc<Viewer>), ui: &Rc<Ui>| {
let (ui, weak) = (ui.clone(), weak.clone());
move || {
if let Some(viewer) = weak.upgrade() {
run(&ui, &viewer);
}
}
};
let mut menu = ActionMenu::new();
menu.item(&gettext("Slideshow"), with_viewer(toggle_slideshow, ui));
if viewer.media.visible_child_name().as_deref() == Some("video") {
let video = viewer.video.clone();
menu.toggle(&gettext("Repeat"), video.is_loop(), move |on| {
video.set_loop(on)
});
}
menu.section();
// These need the full-size file, which may still be downloading.
if viewer.path.borrow().is_some() {
let still = viewer.media.visible_child_name().as_deref() == Some("photo");
if still {
// Translators: menu item that puts the photo on the clipboard as an image.
menu.item(&gettext("Copy Image"), with_viewer(copy_shown_photo, ui));
}
menu.item(&gettext("Save a Copy…"), with_viewer(save_shown_photo, ui));
menu.item(&gettext("Open With…"), with_viewer(open_shown_photo, ui));
}
if photo_shareable(ui) {
menu.item(&gettext("Share…"), with_viewer(share_shown_photo, ui));
}
// A RAW is not on the timeline, and an album holds the shot it belongs to.
if find_photo_index(&ui.gallery.model, &viewer.uid.borrow()).is_some() {
menu.item(
&gettext("Add to Album…"),
with_viewer(add_shown_photo_to_album, ui),
);
}
if viewer.coords.borrow().is_some() {
menu.item(&gettext("Show on Map"), with_viewer(show_photo_on_map, ui));
}
menu.section();
menu.item(
&gettext("Move to Trash…"),
with_viewer(trash_shown_photo, ui),
);
let button = &viewer.more;
let popover = menu.popup_at(
button,
f64::from(button.width()) / 2.0,
f64::from(button.height()),
);
viewer.menu_open.set(true);
let weak = Rc::downgrade(viewer);
popover.connect_closed(move |_| {
if let Some(viewer) = weak.upgrade() {
viewer.menu_open.set(false);
// A slideshow started from the menu keeps the controls hidden.
if !viewer.slideshow.get() {
show_chrome(&viewer);
}
}
});
}
fn save_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
let name = viewer.name.borrow();
save_photo_to_disk(viewer, path, name.as_deref().unwrap_or("photo"));
}
}
fn open_shown_photo(_ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
if let Some(path) = viewer.path.borrow().as_deref() {
open_with(&viewer.window, path, viewer.name.borrow().as_deref());
}
}
fn add_shown_photo_to_album(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
prompt_add_to_album(ui, vec![uid], Some(viewer.window.clone()));
}
/// Close the lightbox and show where the photo was taken.
fn show_photo_on_map(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let coords = *viewer.coords.borrow();
if let Some((lat, lon)) = coords {
viewer.window.close();
show_on_map(ui, lat, lon);
}
}
/// Ask, then move the photo on screen to the trash. Confirmed, the lightbox
/// closes first: what it was showing is gone, and leaving it open on a
/// trashed photo invites a second Delete on something the user cannot see.
fn trash_shown_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>) {
let uid = viewer.uid.borrow().clone();
let window = viewer.window.clone();
ask_trash_photos(ui, vec![uid], Some(window.clone()), move || window.close());
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
File diff suppressed because it is too large. Load diff
+45 -15
View File
@@ -267,7 +267,12 @@ fn want_map_thumb(ui: &Rc<Ui>, photo: &PhotoItem) {
}
// A town's card may be waiting for the same photo: share its picture
// rather than take its place in the queue.
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
let picture = waiting.unwrap_or_else(|| {
let picture = gtk4::Picture::new();
want_thumb(ui, photo, &picture);
@@ -362,7 +367,12 @@ fn show_spot_photos(ui: &Rc<Ui>, photos: Vec<(PlaceInfo, PhotoItem)>, (x, y): (f
/// Show photo's thumbnail in `picture`, sharing one another widget is
/// already waiting for.
fn spot_thumb(ui: &Rc<Ui>, photo: &PhotoItem, picture: &gtk4::Picture) {
let waiting = ui.gallery.thumb_wanted.borrow().get(&photo.uid).cloned();
let waiting = ui
.gallery
.thumb_wanted
.borrow()
.get(&photo.uid)
.and_then(|waiting| waiting.first().cloned());
match waiting {
Some(waiting) => {
waiting
@@ -392,21 +402,45 @@ fn load_place_locations(ui: &Rc<Ui>, id: u32) {
});
}
/// Open `place` and then photo `uid` in it, paging through the town until
/// the photo has loaded.
/// Open photo `uid` of `place` from the map or a spot's list, paging through
/// the town until the photo has loaded.
///
/// The lightbox steps through the gallery's model, so the town is opened
/// behind it. Closing the lightbox goes back to the map rather than leaving
/// the user in a town timeline they never asked for.
fn open_place_photo(ui: &Rc<Ui>, place: PlaceInfo, uid: String) {
let id = place.id;
open_place(ui, place);
view_when_loaded(ui, uid);
view_when_loaded(ui, id, uid);
}
fn view_when_loaded(ui: &Rc<Ui>, uid: String) {
/// The open place's id, if a place is open.
fn open_place_id(ui: &Ui) -> Option<u32> {
ui.gallery.place.borrow().as_ref().map(|place| place.id)
}
fn view_when_loaded(ui: &Rc<Ui>, id: u32, uid: String) {
let ui_page = ui.clone();
ui.gallery.page_waiters.borrow_mut().push(Box::new(move || {
let ui = ui_page;
// The user went elsewhere while the town loaded: whatever page lands
// now belongs to that, and must not pop the lightbox up over it.
if open_place_id(&ui) != Some(id) {
return;
}
if find_photo_index(&ui.gallery.model, &uid).is_some() {
open_photo_viewer(&ui, uid);
} else if ui.gallery.has_more.get() && ui.gallery.place.borrow().is_some() {
view_when_loaded(&ui, uid);
let window = open_photo_viewer(&ui, uid);
let ui_close = ui.clone();
window.connect_close_request(move |_| {
// Only if the town is still what the lightbox was showing: "Show
// on map" in the lightbox has already moved on by itself.
if open_place_id(&ui_close) == Some(id) {
show_place_grid(&ui_close);
}
glib::Propagation::Proceed
});
} else if ui.gallery.has_more.get() {
view_when_loaded(&ui, id, uid);
ui.gallery.burst.set(true);
load_gallery(&ui, true);
}
@@ -422,9 +456,7 @@ fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
// As in an album: no filters, no Upload, no view switch — back leads out.
ui.gallery.filters.set_visible(false);
ui.gallery.view_switch.widget().set_visible(false);
ui.gallery.upload.set_visible(false);
ui.gallery.back.set_visible(true);
sync_header(ui);
ui.gallery.kind.set(None);
ui.gallery.range.set(None);
@@ -439,9 +471,7 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
return;
}
ui.gallery.title.set_title(&gettext("Photos"));
ui.gallery.view_switch.widget().set_visible(true);
ui.gallery.upload.set_visible(true);
ui.gallery.back.set_visible(false);
sync_header(ui);
}
/// Show the place grid's own status page (loading, empty, or an error).
+14 -5
View File
@@ -279,10 +279,7 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.shared.nav.borrow_mut().pop();
load_shared(&ui_back);
});
widgets.back.connect_clicked(move |_| shared_back(&ui_back));
let ui_retry = ui.clone();
widgets
.retry
@@ -318,6 +315,14 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
ui.shared.content.set_visible_child_name("status");
}
/// Back out of a shared folder to the level above: the page's Back button, and
/// Alt+Left or Backspace. A no-op at the top.
pub(crate) fn shared_back(ui: &Rc<Ui>) {
if ui.shared.nav.borrow_mut().pop().is_some() {
load_shared(ui);
}
}
/// Fetch the top level (shared-with-me, invitations, saved links) in parallel
/// and repaint the page once all three land.
///
@@ -443,6 +448,7 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
let nav = ui.shared.nav.borrow().clone();
repaint_shared_crumb(ui, &nav);
let top = nav.is_empty();
let another_folder = ui.shared.listed.borrow().as_ref() != Some(&nav);
*ui.shared.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.shared.files.model.remove_all();
@@ -467,8 +473,11 @@ fn repaint_shared_files(ui: &Rc<Ui>, entries: &[DirEntry]) {
}
ui.shared.files_content.set_visible_child_name("list");
// Only the rows that changed are swapped, so a refresh keeps the selection
// and the scroll position.
// and the scroll position. Another folder starts from its first entry.
replace_items(&ui.shared.files.model, entries);
if another_folder {
ui.shared.files.scroll_to_top();
}
}
/// Rebuild the breadcrumb trail for `nav`: "Shared with me", then each folder
+125 -36
View File
@@ -806,7 +806,8 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
}
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
/// the mount service so the daemon picks it up.
/// the mount service so the daemon picks it up. A folder with files in it is
/// refused: the mount would hide them (B277).
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
@@ -816,46 +817,89 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(path) = folder.path() else { return };
let path_str = path.display().to_string();
// Persist the choice to config so the next mount uses it.
let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
// The folder My files is mounted at lists Drive's files, not its own,
// so picking it again is not checked.
if path == ui.dirs.resolved_mountpoint(&ui.dirs.load_config()) {
use_mountpoint(&ui, &path);
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The Locations page shows the same path as a row title. Any other page
// reads it again on the next visit.
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(&ui);
}
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
.heading(gettext("Restart Proton Drive?"))
// Translators: {path} is the folder the drive is mounted at.
.body(gettext_f(
"My files now appears in “{path}” after Proton Drive restarts.",
&[("path", &path_str)],
))
.build();
confirm.add_response("later", &gettext("Later"));
confirm.add_response("restart", &gettext("Restart Now"));
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart"));
confirm.set_close_response("later");
let ui_restart = ui.clone();
confirm.connect_response(None, move |_, resp| {
if resp == "restart" {
restart_service_then(&ui_restart, |_| {});
let ui = ui.clone();
glib::spawn_future_local(async move {
let checked = path.clone();
let found = gio::spawn_blocking(move || pdfs_core::mountpoint::not_empty(&checked))
.await
.unwrap_or_else(|_| Err(std::io::Error::other("the check did not finish")));
match found {
Ok(None) => use_mountpoint(&ui, &path),
Ok(Some(found)) => refuse_mountpoint(&ui, &found),
Err(e) => toast_error(&ui, &gettext("Couldn't read the folder"), &e.to_string()),
}
});
confirm.present(ui_window(&ui).as_ref());
});
}
/// Explain why the folder `found` cannot hold My files.
fn refuse_mountpoint(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
let path = found.path.display().to_string();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Choose an Empty Folder"))
// Translators: {path} is the folder the user picked; {n} is how many
// files and folders are already in it.
.body(ngettext_f(
"“{path}” already has {n} item in it. My files would hide it while Proton Drive runs, and it would not be uploaded.",
"“{path}” already has {n} items in it. My files would hide them while Proton Drive runs, and they would not be uploaded.",
found.count as u64,
&[("path", &path)],
))
.build();
dialog.add_response("close", &gettext("Close"));
dialog.set_default_response(Some("close"));
dialog.set_close_response("close");
dialog.present(ui_window(ui).as_ref());
}
/// Make `path` the folder My files is mounted at, and offer the restart that
/// moves it there.
fn use_mountpoint(ui: &Rc<Ui>, path: &std::path::Path) {
let path_str = path.display().to_string();
// Persist the choice to config so the next mount uses it.
let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) {
toast_error(ui, &gettext("Couldn't save the folder"), &e.to_string());
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The Locations page shows the same path as a row title. Any other page
// reads it again on the next visit.
if ui.stack.visible_child_name().as_deref() == Some("locations") {
load_locations(ui);
}
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
.heading(gettext("Restart Proton Drive?"))
// Translators: {path} is the folder the drive is mounted at.
.body(gettext_f(
"My files now appears in “{path}” after Proton Drive restarts.",
&[("path", &path_str)],
))
.build();
confirm.add_response("later", &gettext("Later"));
confirm.add_response("restart", &gettext("Restart Now"));
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart"));
confirm.set_close_response("later");
let ui_restart = ui.clone();
confirm.connect_response(None, move |_, resp| {
if resp == "restart" {
restart_service_then(&ui_restart, |_| {});
}
});
confirm.present(ui_window(ui).as_ref());
}
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
/// reload the page.
@@ -1343,12 +1387,39 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
/// read-out so the page doesn't flicker on a blip.
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
set_mounted(ui, false);
paint_sync_status(ui, SyncState::Disconnected);
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(false);
}
}
// A running daemon whose mount fails on every try never answers either,
// and "isn't running" would be wrong for it (B263, B277). The pages read
// the same failure record.
let ui = ui.clone();
glib::spawn_future_local(async move {
let failure = if service_down().await {
None
} else {
let state_dir = ui.dirs.state_dir();
gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
.await
.ok()
.flatten()
};
// The daemon may have answered while this looked.
if *ui.mounted.borrow() {
return;
}
paint_sync_status(
&ui,
match failure {
Some(failure) => SyncState::CannotOpen {
not_empty: failure.not_empty.is_some(),
},
None => SyncState::Disconnected,
},
);
});
}
/// What the sidebar's status strip reports, most urgent first.
@@ -1392,6 +1463,11 @@ pub(crate) enum SyncState {
},
/// No mount daemon answered: still starting, or the service is down.
Disconnected,
/// The service runs, but its mount fails on every try. `not_empty` when
/// it refuses a folder that already has files in it.
CannotOpen {
not_empty: bool,
},
/// The daemon is running but didn't answer in time.
NotResponding,
}
@@ -1400,7 +1476,10 @@ pub(crate) enum SyncState {
/// one-line state, and a detail line.
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
let paused = matches!(state, SyncState::Paused { .. });
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
let connected = !matches!(
state,
SyncState::Disconnected | SyncState::CannotOpen { .. } | SyncState::NotResponding
);
let expired = matches!(state, SyncState::SessionExpired { .. });
let outdated = matches!(state, SyncState::Outdated { .. });
let (icon, class, title, detail) = match state {
@@ -1492,6 +1571,16 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
gettext("Not connected"),
Some(gettext("Proton Drive isn't running")),
),
SyncState::CannotOpen { not_empty } => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Can't open your Drive"),
Some(if not_empty {
gettext("Your Proton Drive folder isn't empty")
} else {
gettext("Proton Drive keeps trying to start")
}),
),
SyncState::NotResponding => (
"dialog-warning-symbolic",
Some("warning"),
+81 -2
View File
@@ -10,6 +10,12 @@ use crate::*;
pub(crate) struct ToggleGroup {
widget: gtk4::Widget,
options: Options,
/// Each option's label, kept while [`Self::set_compact`] shows the icons
/// alone.
labels: Rc<RefCell<Vec<String>>>,
/// Each option's own tooltip, as given.
tooltips: Rc<[Option<String>]>,
compact: Rc<Cell<bool>>,
}
#[derive(Clone)]
@@ -47,6 +53,9 @@ impl ToggleGroup {
Self {
widget,
options: Options::Toggles(toggles),
labels: Rc::new(RefCell::new(labels_of(options))),
tooltips: tooltips_of(options),
compact: Rc::default(),
}
} else {
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
@@ -71,6 +80,9 @@ impl ToggleGroup {
Self {
widget: bar.upcast(),
options: Options::Buttons(buttons),
labels: Rc::new(RefCell::new(labels_of(options))),
tooltips: tooltips_of(options),
compact: Rc::default(),
}
}
}
@@ -115,15 +127,69 @@ impl ToggleGroup {
}
pub(crate) fn set_label(&self, index: u32, label: &str) {
if let Some(stored) = self.labels.borrow_mut().get_mut(index as usize) {
*stored = label.to_string();
}
self.show_label(index, label);
}
/// Put an icon before each option's label, in option order.
pub(crate) fn set_icons(&self, icons: &[&str]) {
match &self.options {
Options::Toggles(toggles) => {
for (toggle, icon) in toggles.iter().zip(icons) {
toggle.set_property("icon-name", *icon);
}
}
Options::Buttons(buttons) => {
let labels = self.labels.borrow();
for ((button, icon), label) in buttons.iter().zip(icons).zip(labels.iter()) {
let face = adw::ButtonContent::builder()
.icon_name(*icon)
.label(label.as_str())
.build();
button.set_child(Some(&face));
}
}
}
}
/// Show the icons alone, for a narrow window, with each label moved to
/// the option's tooltip; or bring the labels back.
pub(crate) fn set_compact(&self, compact: bool) {
if self.compact.replace(compact) == compact {
return;
}
let labels = self.labels.borrow().clone();
for (index, label) in labels.iter().enumerate() {
self.show_label(index as u32, label);
}
}
/// Show `label` on option `index`, or only in its tooltip while compact.
fn show_label(&self, index: u32, label: &str) {
let compact = self.compact.get();
let shown = if compact { "" } else { label };
let given = self.tooltips.get(index as usize).cloned().flatten();
let tooltip = if compact {
Some(label.to_string())
} else {
given
};
match &self.options {
Options::Toggles(toggles) => {
if let Some(toggle) = toggles.get(index as usize) {
toggle.set_property("label", label);
toggle.set_property("label", shown);
toggle.set_property("tooltip", tooltip.unwrap_or_default());
}
}
Options::Buttons(buttons) => {
if let Some(button) = buttons.get(index as usize) {
button.set_label(label);
match button.child().and_downcast::<adw::ButtonContent>() {
Some(face) => face.set_label(shown),
None => button.set_label(shown),
}
button.set_tooltip_text(tooltip.as_deref());
}
}
}
@@ -271,6 +337,19 @@ fn shortcuts_dialog(groups: &[(String, Vec<(&str, String)>)]) -> adw::Dialog {
.build()
}
/// The labels of `(label, tooltip)` options.
fn labels_of(options: &[(&str, Option<&str>)]) -> Vec<String> {
options.iter().map(|(label, _)| label.to_string()).collect()
}
/// The tooltips of `(label, tooltip)` options.
fn tooltips_of(options: &[(&str, Option<&str>)]) -> Rc<[Option<String>]> {
options
.iter()
.map(|(_, tooltip)| tooltip.map(str::to_string))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+44 -1
View File
@@ -224,6 +224,49 @@ impl FileList {
.set_visible_child_name(if list { "list" } else { "grid" });
}
/// Bring both views back to the first entry. For a listing of something
/// else: [`replace_items`] keeps the scroll position, which is right for a
/// refresh, but another folder would open where the last one was left —
/// often at its bottom.
pub(crate) fn scroll_to_top(&self) {
if self.model.n_items() == 0 {
return;
}
self.grid.scroll_to(0, gtk4::ListScrollFlags::NONE, None);
self.column_view
.scroll_to(0, None, gtk4::ListScrollFlags::NONE, None);
}
/// How far the view on screen is scrolled, in px from the top.
pub(crate) fn scroll_offset(&self) -> f64 {
self.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
.map_or(0.0, |scroll| scroll.vadjustment().value())
}
/// Select the entries named `selected` and scroll the view on screen to
/// `offset`, as they were when the folder was left. The scroll waits for
/// an idle, when the views have measured the rows just put in.
pub(crate) fn restore(&self, offset: f64, selected: &[String]) {
self.selection.unselect_all();
for i in 0..self.selection.n_items() {
if entry_at(Some(&self.selection), i)
.is_some_and(|entry| selected.contains(&entry.name))
{
self.selection.select_item(i, false);
}
}
let Some(scroll) = self
.views
.visible_child()
.and_downcast::<gtk4::ScrolledWindow>()
else {
return;
};
glib::idle_add_local_once(move || scroll.vadjustment().set_value(offset));
}
/// Whether the list, rather than the grid, is on screen.
pub(crate) fn shows_list(&self) -> bool {
self.views.visible_child_name().as_deref() == Some("list")
@@ -478,7 +521,7 @@ pub(crate) fn text_column(
fn resize_grid_tile(thumbnail: &gtk4::Overlay, label: &gtk4::Label, size: i32) {
resize_file_thumbnail(thumbnail, size, grid_fallback_size(size));
let name_width = (size / 6 + 1).clamp(8, 24);
let name_width = (size / 6 + 1).clamp(8, 40);
label.set_width_chars(name_width);
label.set_max_width_chars(name_width);
}
+9 -2
View File
@@ -101,8 +101,14 @@ impl ActionMenu {
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
/// popover removes itself once dismissed; it waits for an idle turn, so the
/// item that closed it still finds its action.
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
/// item that closed it still finds its action. Returned for a caller that
/// has to know when it closes.
pub(crate) fn popup_at(
self,
anchor: &impl IsA<gtk4::Widget>,
x: f64,
y: f64,
) -> gtk4::PopoverMenu {
let (menu, group) = self.finish();
let anchor = anchor.as_ref();
let host = popover_host(anchor);
@@ -122,6 +128,7 @@ impl ActionMenu {
glib::idle_add_local_once(move || p.unparent());
});
popover.popup();
popover
}
/// A flat ⋮ button that opens this menu.
+2
View File
@@ -3,6 +3,8 @@ pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
pub(crate) mod photo_drag;
pub(crate) mod player;
pub(crate) mod scrubber;
pub(crate) mod share_dialog;
pub(crate) mod thumbnails;
@@ -0,0 +1,237 @@
//! Dragging photos out of the timeline into a file manager or another app.
//!
//! A photo is not a file anywhere the receiver can read until it has been
//! downloaded, and a drag has to start the moment the pointer moves. So the
//! drag offers a list of files without having them: [`PhotoDrag`] downloads
//! the photos only when the receiver asks for the list, which it does once the
//! photos are dropped on it, and then hands over copies under their own names.
use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, SystemTime};
use gtk4::gdk;
use gtk4::gdk::subclass::prelude::*;
use crate::*;
/// The one format a drag of photos offers. GTK turns it into a file list for
/// a receiver in this app, and every file manager reads it.
const URI_LIST: &str = "text/uri-list";
/// How long the copies handed to a receiver are kept. The receiver copies
/// them on drop, so a day is far more than it needs.
const KEEP_DRAGGED: Duration = Duration::from_secs(24 * 60 * 60);
mod imp {
use super::*;
#[derive(Default)]
pub struct PhotoDrag {
/// The photos dragged, as uid and Drive name.
pub(super) photos: RefCell<Vec<(String, Option<String>)>>,
pub(super) socket: RefCell<PathBuf>,
}
#[glib::object_subclass]
impl ObjectSubclass for PhotoDrag {
const NAME: &'static str = "PdfsPhotoDrag";
type Type = super::PhotoDrag;
type ParentType = gdk::ContentProvider;
}
impl ObjectImpl for PhotoDrag {}
impl ContentProviderImpl for PhotoDrag {
fn formats(&self) -> gdk::ContentFormats {
gdk::ContentFormats::new(&[URI_LIST])
}
fn write_mime_type_future(
&self,
mime_type: &str,
stream: &gio::OutputStream,
io_priority: glib::Priority,
) -> Pin<Box<dyn Future<Output = Result<(), glib::Error>> + 'static>> {
let photos = self.photos.borrow().clone();
let socket = self.socket.borrow().clone();
let stream = stream.clone();
let wanted = mime_type == URI_LIST;
Box::pin(async move {
if !wanted {
return Err(glib::Error::new(
gio::IOErrorEnum::NotSupported,
"photos are offered as a list of files only",
));
}
let files = fetch_dragged(socket, photos).await?;
let list: String = files
.iter()
.map(|path| format!("{}\r\n", gio::File::for_path(path).uri()))
.collect();
stream
.write_all_future(list.into_bytes(), io_priority)
.await
.map_err(|(_, e)| e)?;
Ok(())
})
}
}
}
glib::wrapper! {
/// The content of a drag of photos: their files, fetched when dropped.
pub struct PhotoDrag(ObjectSubclass<imp::PhotoDrag>)
@extends gdk::ContentProvider;
}
impl PhotoDrag {
pub(crate) fn new(socket: PathBuf, photos: Vec<(String, Option<String>)>) -> Self {
let drag: Self = glib::Object::new();
*drag.imp().photos.borrow_mut() = photos;
*drag.imp().socket.borrow_mut() = socket;
drag
}
}
/// Download each dragged photo and put a copy under its Drive name where the
/// receiver can read it. A photo that fails is left out; only when every one
/// fails does the drop fail.
async fn fetch_dragged(
socket: PathBuf,
photos: Vec<(String, Option<String>)>,
) -> Result<Vec<PathBuf>, glib::Error> {
let base = glib::user_cache_dir().join("pdfs-drag");
let mut files = Vec::new();
let mut last_error = None;
for (uid, name) in photos {
let rx = spawn_request(socket.clone(), Request::OpenPhoto { uid: uid.clone() });
let source = match rx.recv().await {
Ok(Ok(Response::FilePath { path })) => path,
Ok(Ok(Response::Error { message, .. })) => {
last_error = Some(message);
continue;
}
_ => {
last_error = Some("the Proton Drive service didn't respond".to_string());
continue;
}
};
let base = base.clone();
let copied = gio::spawn_blocking(move || {
prune_dragged(&base, SystemTime::now());
let name = name.unwrap_or_else(|| "photo".to_string());
hand_over(&base, Path::new(&source), &uid, &name)
})
.await;
match copied {
Ok(Ok(path)) => files.push(path),
Ok(Err(e)) => last_error = Some(e.to_string()),
Err(_) => last_error = Some("the copy was interrupted".to_string()),
}
}
if files.is_empty() {
let message = last_error.unwrap_or_else(|| "nothing to drop".to_string());
tracing::warn!("dragging photos out failed: {message}");
return Err(glib::Error::new(gio::IOErrorEnum::Failed, &message));
}
Ok(files)
}
/// Put the cached photo `source` at `base/<uid>/<name>`, where a receiver
/// copies it from.
///
/// A hard link when the cache is on the same file system, so a large video is
/// not copied twice; a copy otherwise. Not a symlink: a file manager copies a
/// symlink as a link, which would leave the user a link into the cache.
pub(crate) fn hand_over(
base: &Path,
source: &Path,
uid: &str,
name: &str,
) -> std::io::Result<PathBuf> {
let file_name = Path::new(name)
.file_name()
.ok_or_else(|| std::io::Error::other("the photo has no usable name"))?;
let dir = base.join(uid.replace('/', "_"));
std::fs::create_dir_all(&dir)?;
let dest = dir.join(file_name);
let _ = std::fs::remove_file(&dest);
if std::fs::hard_link(source, &dest).is_err() {
std::fs::copy(source, &dest)?;
}
Ok(dest)
}
/// Remove the copies handed over more than [`KEEP_DRAGGED`] before `now`.
pub(crate) fn prune_dragged(base: &Path, now: SystemTime) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|meta| meta.modified())
.is_ok_and(|modified| {
now.duration_since(modified)
.is_ok_and(|age| age > KEEP_DRAGGED)
});
if old {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("pdfs-drag-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_dragged_photo_is_handed_over_under_its_own_name() {
let dir = scratch("name");
let source = dir.join("0f3a9c");
std::fs::write(&source, b"jpeg bytes").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(path, base.join("vol~link").join("IMG_0001.JPG"));
assert_eq!(std::fs::read(&path).unwrap(), b"jpeg bytes");
assert!(!path.is_symlink());
// Dragged again, it replaces the copy rather than failing on it.
std::fs::write(&source, b"new bytes").unwrap();
let again = hand_over(&base, &source, "vol~link", "IMG_0001.JPG").unwrap();
assert_eq!(std::fs::read(again).unwrap(), b"new bytes");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_name_cannot_climb_out_of_its_folder() {
let dir = scratch("climb");
let source = dir.join("blob");
std::fs::write(&source, b"x").unwrap();
let base = dir.join("out");
let path = hand_over(&base, &source, "u", "../../escape.jpg").unwrap();
assert_eq!(path, base.join("u").join("escape.jpg"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_old_handovers_are_pruned() {
let dir = scratch("prune");
std::fs::create_dir_all(dir.join("fresh")).unwrap();
prune_dragged(&dir, SystemTime::now());
assert!(dir.join("fresh").exists());
prune_dragged(&dir, SystemTime::now() + KEEP_DRAGGED * 2);
assert!(!dir.join("fresh").exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+348
View File
@@ -0,0 +1,348 @@
//! Playing videos and music inside the app.
//!
//! The pieces both players share live here: the one My files opens a video or
//! a song in ([`open_media_player`]) and the Photos lightbox. A GTK
//! [`gtk4::Video`] brings play, pause, a seek bar and the volume; this adds
//! fullscreen, the keys every video player answers to, a repeat switch and a
//! volume that carries over from one video to the next.
use crate::activation::{mounted_path, present_within};
use crate::*;
/// How far `J` and `L` (or Shift+←/→) jump.
const SEEK_LONG: i64 = 10;
/// How far ← and → jump in the My files player, where they don't move to
/// another photo.
const SEEK_SHORT: i64 = 5;
/// How much ↑ and ↓ change the volume in the My files player.
const VOLUME_STEP: f64 = 0.1;
thread_local! {
/// The volume and mute the user last set, applied to the next stream so
/// muting one video doesn't leave the next one blaring. Kept for the run
/// of the app only.
static LEVEL: Cell<Option<(f64, bool)>> = const { Cell::new(None) };
}
/// Where a jump of `delta_secs` from `now` lands, in microseconds, as a
/// [`gtk4::MediaStream`] counts. Kept inside the stream; a `duration` of 0
/// means the stream doesn't know its length yet, which bounds only the start.
pub(crate) fn seek_target(now: i64, duration: i64, delta_secs: i64) -> i64 {
let target = now
.saturating_add(delta_secs.saturating_mul(1_000_000))
.max(0);
if duration > 0 {
target.min(duration)
} else {
target
}
}
/// Jump the stream `delta_secs` forward, or back when negative.
pub(crate) fn seek_by(video: &gtk4::Video, delta_secs: i64) {
if let Some(stream) = video.media_stream()
&& stream.is_seekable()
{
stream.seek(seek_target(
stream.timestamp(),
stream.duration(),
delta_secs,
));
}
}
/// Play a paused stream, pause a playing one.
pub(crate) fn toggle_playing(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_playing(!stream.is_playing());
}
}
fn toggle_muted(video: &gtk4::Video) {
if let Some(stream) = video.media_stream() {
stream.set_muted(!stream.is_muted());
}
}
fn change_volume(video: &gtk4::Video, delta: f64) {
if let Some(stream) = video.media_stream() {
stream.set_muted(false);
stream.set_volume((stream.volume() + delta).clamp(0.0, 1.0));
}
}
/// Enter fullscreen, or leave it.
pub(crate) fn toggle_fullscreen(window: &gtk4::Window) {
if window.is_fullscreen() {
window.unfullscreen();
} else {
window.fullscreen();
}
}
/// The keys both players answer to the same way: `K` plays and pauses, `J` and
/// `L` (or Shift+← and Shift+→) jump ten seconds, `M` mutes. True when `key`
/// was one of them.
pub(crate) fn player_key(video: &gtk4::Video, key: &str, state: gtk4::gdk::ModifierType) -> bool {
let shift = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
let plain = !state
.intersects(gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::ALT_MASK);
if !plain {
return false;
}
match key {
"k" | "K" => toggle_playing(video),
"j" | "J" => seek_by(video, -SEEK_LONG),
"l" | "L" => seek_by(video, SEEK_LONG),
"Left" if shift => seek_by(video, -SEEK_LONG),
"Right" if shift => seek_by(video, SEEK_LONG),
"m" | "M" => toggle_muted(video),
_ => return false,
}
true
}
/// Make `video` carry the volume over between streams and toggle `window`'s
/// fullscreen on a double-click.
pub(crate) fn wire_player(video: &gtk4::Video, window: &gtk4::Window) {
video.connect_media_stream_notify(|video| {
let Some(stream) = video.media_stream() else {
return;
};
if let Some((volume, muted)) = LEVEL.get() {
stream.set_volume(volume);
stream.set_muted(muted);
}
let remember = |stream: &gtk4::MediaStream| {
LEVEL.set(Some((stream.volume(), stream.is_muted())));
};
stream.connect_volume_notify(remember);
stream.connect_muted_notify(remember);
});
// The controls' own buttons and seek bar claim their clicks, so this only
// sees a double-click on the picture.
let click = gtk4::GestureClick::new();
let window = window.downgrade();
click.connect_pressed(move |_, n_press, _, _| {
if n_press == 2
&& let Some(window) = window.upgrade()
{
toggle_fullscreen(&window);
}
});
video.add_controller(click);
}
/// A button that enters and leaves `window`'s fullscreen, showing which one
/// it will do.
pub(crate) fn fullscreen_button(window: &gtk4::Window) -> gtk4::Button {
let button = gtk4::Button::builder().valign(gtk4::Align::Center).build();
let paint = |button: &gtk4::Button, fullscreen: bool| {
if fullscreen {
button.set_icon_name("view-restore-symbolic");
button.set_tooltip_text(Some(&gettext("Leave Fullscreen (F)")));
} else {
button.set_icon_name("view-fullscreen-symbolic");
button.set_tooltip_text(Some(&gettext("Fullscreen (F)")));
}
};
paint(&button, window.is_fullscreen());
let weak = button.downgrade();
window.connect_fullscreened_notify(move |window| {
if let Some(button) = weak.upgrade() {
paint(&button, window.is_fullscreen());
}
});
let target = window.downgrade();
button.connect_clicked(move |_| {
if let Some(window) = target.upgrade() {
toggle_fullscreen(&window);
}
});
button
}
/// Play a video or a song from My files in a window of its own, streamed from
/// the mount: the player reads the blocks it needs as it plays and seeks, so a
/// long video starts in seconds instead of after a full download. A file the
/// mount doesn't show, or the service is down, opens in the default
/// application instead, after a download.
pub(crate) fn open_media_player(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let (ui, entry) = (ui.clone(), entry.clone());
glib::spawn_future_local(async move {
match present_within(mounted_path(&mountpoint, &rel)).await {
Some(path) => build_media_player(&ui, &entry, &path),
None => download_and_open(&ui, &entry),
}
});
}
fn build_media_player(ui: &Rc<Ui>, entry: &DirEntry, path: &Path) {
let window = adw::Window::builder()
.title(&entry.name)
.default_width(960)
.default_height(600)
.build();
if let Some(parent) = ui_window(ui) {
window.set_transient_for(Some(&parent));
}
window.add_css_class("media-player-window");
let gtk_window: gtk4::Window = window.clone().upcast();
let video = gtk4::Video::builder()
.autoplay(true)
.hexpand(true)
.vexpand(true)
.build();
wire_player(&video, &gtk_window);
// A song has no picture, which would leave a black window above the
// controls: a note stands in for it.
let note = gtk4::Image::builder()
.icon_name("audio-x-generic-symbolic")
.pixel_size(128)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
note.add_css_class("media-player-note");
let overlay = gtk4::Overlay::new();
overlay.set_child(Some(&video));
overlay.add_overlay(&note);
let failed = adw::StatusPage::builder()
.icon_name("dialog-warning-symbolic")
.title(gettext("Couldn't play this file"))
.build();
let open_with = gtk4::Button::builder()
.label(gettext("Open With…"))
.halign(gtk4::Align::Center)
.build();
open_with.add_css_class("pill");
open_with.add_css_class("suggested-action");
failed.set_child(Some(&open_with));
let stack = gtk4::Stack::new();
stack.add_named(&overlay, Some("player"));
stack.add_named(&failed, Some("failed"));
let header = adw::HeaderBar::new();
header.pack_end(&fullscreen_button(&gtk_window));
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Open With…"), move || {
open_entry_with(&ui_c, &entry_c)
});
let video_c = video.clone();
// Translators: menu item of the video player that plays the file again
// and again.
menu.toggle(&gettext("Repeat"), false, move |on| video_c.set_loop(on));
header.pack_end(&menu.button());
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
// Fullscreen is the video alone, its own controls still at hand.
window
.bind_property("fullscreened", &toolbar, "reveal-top-bars")
.invert_boolean()
.sync_create()
.build();
window.set_content(Some(&toolbar));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
open_with.connect_clicked(move |_| open_entry_with(&ui_c, &entry_c));
let (stack_c, failed_c, note_c) = (stack.clone(), failed.clone(), note.clone());
video.connect_media_stream_notify(move |video| {
let Some(stream) = video.media_stream() else {
return;
};
let (stack, failed) = (stack_c.clone(), failed_c.clone());
stream.connect_error_notify(move |stream| {
if let Some(error) = stream.error() {
tracing::warn!("in-app player failed: {error}");
failed.set_description(Some(&error.to_string()));
stack.set_visible_child_name("failed");
}
});
let note = note_c.clone();
stream.connect_prepared_notify(move |stream| {
note.set_visible(stream.is_prepared() && stream.has_audio() && !stream.has_video());
});
});
video.set_filename(Some(path));
let keys = gtk4::EventControllerKey::new();
let (video_k, window_k) = (video.clone(), gtk_window.downgrade());
keys.connect_key_pressed(move |_, key, _code, state| {
let Some(window) = window_k.upgrade() else {
return glib::Propagation::Proceed;
};
let Some(name) = key.name() else {
return glib::Propagation::Proceed;
};
// The open menu keeps its arrows and Enter.
let in_menu = gtk4::prelude::GtkWindowExt::focus(&window)
.is_some_and(|focus| focus.ancestor(gtk4::Popover::static_type()).is_some());
if in_menu {
return glib::Propagation::Proceed;
}
if player_key(&video_k, &name, state) {
return glib::Propagation::Stop;
}
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
match name.as_str() {
"space" => toggle_playing(&video_k),
"Left" => seek_by(&video_k, -SEEK_SHORT),
"Right" => seek_by(&video_k, SEEK_SHORT),
"Up" => change_volume(&video_k, VOLUME_STEP),
"Down" => change_volume(&video_k, -VOLUME_STEP),
"f" | "F11" => toggle_fullscreen(&window),
// Escape leaves fullscreen before it closes anything.
"Escape" if window.is_fullscreen() => window.unfullscreen(),
"Escape" | "q" => window.close(),
"w" if ctrl => window.close(),
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
// Capture, so the arrows and Space reach the player rather than moving the
// focus between its controls.
keys.set_propagation_phase(gtk4::PropagationPhase::Capture);
window.add_controller(keys);
// Stop the sound with the window, not when the window is finally freed.
let video_c = video.clone();
window.connect_close_request(move |_| {
video_c.set_media_stream(gtk4::MediaStream::NONE);
glib::Propagation::Proceed
});
window.present();
}
#[cfg(test)]
mod tests {
use super::*;
const SEC: i64 = 1_000_000;
#[test]
fn a_jump_stays_inside_the_video() {
assert_eq!(seek_target(30 * SEC, 60 * SEC, 10), 40 * SEC);
assert_eq!(seek_target(30 * SEC, 60 * SEC, -10), 20 * SEC);
assert_eq!(seek_target(5 * SEC, 60 * SEC, -10), 0);
assert_eq!(seek_target(55 * SEC, 60 * SEC, 10), 60 * SEC);
}
#[test]
fn a_jump_forward_in_a_stream_of_unknown_length_is_not_held_back() {
assert_eq!(seek_target(30 * SEC, 0, 10), 40 * SEC);
}
}
@@ -334,6 +334,13 @@ pub(crate) struct ShareDialog {
/// Open the per-node Share dialog: invite Proton/external users, manage who has
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
open_share_dialog_over(ui, entry, None);
}
/// [`open_share_dialog`] over `parent` rather than the main window. The photo
/// viewer is a window of its own, and a dialog on the main window would open
/// behind it.
pub(crate) fn open_share_dialog_over(ui: &Rc<Ui>, entry: &DirEntry, parent: Option<gtk4::Window>) {
if !*ui.mounted.borrow() {
toast_error(
ui,
@@ -491,7 +498,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
}
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
dialog.present(parent.or_else(|| ui_window(ui)).as_ref());
}
/// Re-fetch the node's share and rebuild the people + public-link sections.
+48 -10
View File
@@ -1,4 +1,4 @@
//! Shared thumbnails for ordinary Drive image files.
//! Shared thumbnails for ordinary Drive image and video files.
//!
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
//! File listings need a smaller, reusable surface: one loader backs My files,
@@ -6,7 +6,7 @@
//! so a replaced image never flashes its previous contents.
use crate::*;
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
use pdfs_core::control::{FileThumbRequest, is_thumbnail_media_name, is_video_file_name};
const THUMB_BATCH: usize = 32;
const THUMB_CACHE_MAX: usize = 256;
@@ -125,6 +125,18 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
.build();
picture.add_css_class("file-thumbnail");
// Over a video's frame, so it does not pass for a photo.
let play = gtk4::Image::builder()
.icon_name("media-playback-start-symbolic")
.pixel_size(16)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.can_target(false)
.visible(false)
.build();
play.add_css_class("photo-video-badge");
play.add_css_class("photo-video-badge-small");
let overlay = gtk4::Overlay::new();
overlay.set_width_request(size);
overlay.set_height_request(size);
@@ -133,9 +145,21 @@ pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Over
overlay.set_overflow(gtk4::Overflow::Hidden);
overlay.set_child(Some(&fallback));
overlay.add_overlay(&picture);
overlay.add_overlay(&play);
overlay
}
/// The play badge [`file_thumbnail_widget`] puts after `picture`.
fn play_badge(picture: &gtk4::Picture) -> Option<gtk4::Image> {
picture
.next_sibling()
.and_downcast::<gtk4::Image>()
.filter(|image| image.has_css_class("photo-video-badge"))
}
/// Marks a play badge to show once its video's frame is painted.
const VIDEO_BADGE: &str = "video";
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
/// these requests in place and rebinding the model is enough to implement the
/// status-bar zoom without downloading or decoding an image again.
@@ -179,6 +203,16 @@ pub(crate) fn bind_file_thumbnail(
fallback.set_visible(true);
picture.set_visible(false);
picture.set_paintable(gtk4::gdk::Paintable::NONE);
// List rows are too small for a badge; their icon says video already.
let video = !entry.is_dir && is_video_file_name(&entry.name, Some(entry.size));
if let Some(play) = play_badge(&picture) {
play.set_visible(false);
play.set_widget_name(if video && !symbolic_fallback {
VIDEO_BADGE
} else {
""
});
}
// The widget name is a cheap binding token. List factories recycle their
// children; a reply for the previous entry must not paint into the new one.
@@ -186,7 +220,10 @@ pub(crate) fn bind_file_thumbnail(
picture.set_widget_name(&key);
remove_waiter(ui, &picture);
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
if entry.is_dir
|| !is_thumbnail_media_name(&entry.name, Some(entry.size))
|| entry.uid.is_empty()
{
return;
}
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
@@ -214,12 +251,6 @@ pub(crate) fn bind_file_thumbnail(
schedule_file_thumbs(ui);
}
/// Whether the file name denotes an image format for which Proton or the local
/// decoder can reasonably provide a thumbnail.
pub(crate) fn is_image_name(name: &str) -> bool {
is_thumbnail_image_name(name)
}
fn thumbnail_key(uid: &str, modified: i64) -> String {
format!("{uid}:{modified}")
}
@@ -238,6 +269,9 @@ fn paint(picture: &gtk4::Picture, key: &str, texture: &gtk4::gdk::Texture) {
}
picture.set_paintable(Some(texture));
picture.set_visible(true);
if let Some(play) = play_badge(picture) {
play.set_visible(play.widget_name() == VIDEO_BADGE);
}
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
{
@@ -413,7 +447,11 @@ fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
#[cfg(test)]
mod tests {
use super::{ReplyDisposition, is_image_name, reply_disposition};
use super::{ReplyDisposition, reply_disposition};
fn is_image_name(name: &str) -> bool {
pdfs_core::control::is_thumbnail_image_name(name)
}
#[test]
fn image_extensions_are_case_insensitive_and_specific() {
+2 -2
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use adw::prelude::*;
use gtk4::{gio, glib};
use super::row::{fill_slot, thumb_slot};
use super::row::{content_type_for, fill_slot, thumb_slot};
use super::thumbs::Thumbs;
use crate::{Hit, dates, gettext, human_bytes, pgettext};
@@ -235,7 +235,7 @@ impl Preview {
let content_type = if hit.is_dir() {
glib::GString::from("inode/directory")
} else {
gio::functions::content_type_guess(Some(hit.name()), None).0
content_type_for(hit.name(), hit.size())
};
self.kind
.set_label(&gio::functions::content_type_get_description(&content_type));
+18 -5
View File
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::rc::Rc;
use adw::prelude::*;
use gtk4::{gdk, gio};
use gtk4::{gdk, gio, glib};
use super::highlight;
use super::thumbs::Thumbs;
@@ -53,7 +53,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
return;
};
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir()));
fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir(), hit.size()));
fallback.set_visible(true);
picture.set_visible(false);
picture.set_paintable(gdk::Paintable::NONE);
@@ -63,7 +63,7 @@ pub(crate) fn fill_slot(slot: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mo
/// The desktop's own full-colour icon for a file name, so results look like
/// the user's file manager. Folders are Proton's purple ones while the Proton
/// theme is on, as in the app's browser.
pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
pub(crate) fn icon_for(name: &str, is_dir: bool, size: u64) -> gio::Icon {
if is_dir {
let folder = if proton_theme_active() {
"pdfs-proton-folder"
@@ -72,8 +72,21 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
};
return gio::ThemedIcon::new(folder).upcast();
}
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
gio::functions::content_type_get_icon(&content_type)
gio::functions::content_type_get_icon(&content_type_for(name, size))
}
/// The content type of a file named `name` of `size` bytes, guessed from the
/// name. GIO takes every `.ts` for TypeScript (or Qt translations); a
/// recording's size says otherwise (see
/// [`is_video_file_name`](pdfs_core::control::is_video_file_name)).
pub(crate) fn content_type_for(name: &str, size: u64) -> glib::GString {
let transport_stream = std::path::Path::new(name)
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("ts"));
if transport_stream && pdfs_core::control::is_video_file_name(name, Some(size)) {
return glib::GString::from("video/mp2t");
}
gio::functions::content_type_guess(Some(name), None).0
}
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
+12 -4
View File
@@ -9,8 +9,8 @@
//! matches the file's;
//! 2. for local images, decoding the file itself, and writing the result back
//! to that cache for the next time and for every other application;
//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet
//! period.
//! 3. for Drive images and videos, the daemon's `FileThumbs`, batched after a
//! short quiet period.
//!
//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross
//! threads. Each job carries the render epoch it was queued in, and workers
@@ -30,7 +30,9 @@ use gtk4::gdk_pixbuf::Pixbuf;
use gtk4::prelude::*;
use gtk4::{gdk, glib};
use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name};
use pdfs_core::control::{
FileThumbRequest, Request, Response, is_thumbnail_image_name, is_thumbnail_media_name,
};
use crate::{Hit, spawn_request};
@@ -165,7 +167,13 @@ impl Thumbs {
// The widget name binds the picture to one key: a late reply for
// another hit must not paint into it.
picture.set_widget_name(&key);
if hit.is_dir() || !is_thumbnail_image_name(hit.name()) {
// A video's frame comes from the daemon, which has ffmpeg's help; a
// local video has only what another application cached.
let thumbnailed = match hit {
Hit::Drive(drive) => is_thumbnail_media_name(&drive.name, Some(drive.size)),
Hit::Local(_) => is_thumbnail_image_name(hit.name()),
};
if hit.is_dir() || !thumbnailed {
return;
}
if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) {
+7 -3
View File
@@ -48,7 +48,8 @@ pub(crate) fn init() {
.ok()
.and_then(|dirs| dirs.load_config().language)
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
gettextrs::setlocale(LocaleCategory::LcAll, "");
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
unsafe { gettextrs::setlocale(LocaleCategory::LcAll, "") };
if let Some(code) = language {
apply_language(&code);
}
@@ -97,14 +98,17 @@ pub(crate) fn country_name(code: &str) -> String {
fn apply_language(code: &str) {
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
unsafe { std::env::set_var("LANGUAGE", code) };
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
// SAFETY: as above, still single-threaded.
let current = unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, "") }
.map(|name| String::from_utf8_lossy(&name).into_owned())
.unwrap_or_default();
if !is_c_locale(&current) {
return;
}
for candidate in message_locales(code) {
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
// SAFETY: as above, still single-threaded.
if unsafe { gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()) }.is_some()
{
// SAFETY: as above, still single-threaded.
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
return;
+18
View File
@@ -194,6 +194,24 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
),
Phase::Attention,
),
// A daemon whose mount fails on every try never opens the
// socket; the app says why (B263).
Ok(s)
if socket
.parent()
.and_then(pdfs_core::mountfail::read)
.is_some() =>
{
(
// Translators: {username} is the Proton account name. The
// service keeps failing to start; the app shows the error.
gettext_f(
"Signed in as {username} — can't open your Drive",
&[("username", &s.username)],
),
Phase::Attention,
)
}
Ok(s) => (
// Translators: {username} is the Proton account name.
gettext_f(
+1 -1
View File
@@ -94,7 +94,7 @@ pub(crate) fn icon_name(hit: &Hit) -> String {
let name = hit.name();
if is_image(name) {
"image-x-generic"
} else if is_media(name) {
} else if is_media(name) || pdfs_core::control::is_video_file_name(name, Some(hit.size())) {
"video-x-generic"
} else if is_document(name) {
"text-x-generic"
+6
View File
@@ -24,6 +24,7 @@ const STOCK: &[&str] = &[
// GTK's built-in icons
"info-outline-symbolic",
// Adwaita
"audio-x-generic-symbolic",
"checkbox-checked-symbolic",
"checkbox-symbolic",
"computer-symbolic",
@@ -52,6 +53,7 @@ const STOCK: &[&str] = &[
"image-missing-symbolic",
"image-x-generic-symbolic",
"list-add-symbolic",
"list-remove-symbolic",
"mark-location-symbolic",
"media-playback-pause-symbolic",
"media-playback-start-symbolic",
@@ -61,16 +63,20 @@ const STOCK: &[&str] = &[
"object-select-symbolic",
"preferences-system-symbolic",
"process-stop-symbolic",
"selection-mode-symbolic",
"starred-symbolic",
"system-search-symbolic",
"text-x-generic-symbolic",
"user-trash-symbolic",
"video-x-generic-symbolic",
"view-fullscreen-symbolic",
"view-grid-symbolic",
"view-list-symbolic",
"view-more-symbolic",
"view-paged-symbolic",
"view-pin-symbolic",
"view-refresh-symbolic",
"view-restore-symbolic",
"window-close-symbolic",
"zoom-fit-best-symbolic",
"zoom-in-symbolic",
+1
View File
@@ -467,6 +467,7 @@ both with `pdfs_core::search::relevance_score`, so the prompt can merge them int
| `pdfs-sync`, `pdfs-sync-poll` | Mirror passes and the remote poll (§7) |
| Tokio runtime | Remote calls, and the Drive and photos event streams (§6) |
| `pdfs-online-probe` | Probes Drive while offline |
| `pdfs-pin-keeper` | Checks pins once a second for work: every pin at startup, when the link returns and after the event feed loses continuity, and the pins a batch of remote events changed. Downloads files kept offline that are missing or stale; failures retry with a backoff of 30 s to 30 min (`docs/BUGS.md` B282) |
| `pdfs-supervisor` | Logs workers that hold one job for minutes, a queue that stops draining, and control requests that never return; samples memory; pings the systemd watchdog only after a round trip over the control socket succeeds (`WatchdogSec=120`) |
| `pdfs-conflict-sweep` | Optional. After 30 s, then every 5 minutes, finds `(sync-conflict …)` copies identical (size and SHA-1) to their original. `conflict_sweep` decides: `off`, `report` (default) or `enforce`, which trashes them after re-checking each one |
| Smaller helpers | `pdfs-localindex` (home index for search), `pdfs-pause` (ends a timed pause), `pdfs-similar` (similar photos, on request), `pdfs-diagnostics` |
+569 -62
View File
@@ -68,21 +68,9 @@ original wording.
| Entry | Title | What is left |
|---|---|---|
| [B275](#b275--on-a-slow-link-a-files-last-write-can-land-only-in-a-conflict-copy) | On a slow link, a file's last write can land only in a conflict copy | Cause not found; seen once in CI |
| [B273](#b273--a-restore-proposes-another-computers-paths-when-the-two-share-a-hostname) | A restore proposes another computer's paths when the two share a hostname | Not fixed |
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id | Not fixed |
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing | Not fixed |
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | Not fixed |
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | Not fixed |
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | Not fixed |
| [B267](#b267--a-key-failure-at-a-start-from-a-warm-cache-shows-as-offline-for-good) | A key failure at a start from a warm cache shows as offline for good | Not fixed |
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" | Not fixed |
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open | Needs an SDK change; not reproduced |
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running | Not fixed |
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Not fixed |
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | Not fixed |
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" | Not fixed |
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder | Not fixed |
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | Not fixed; the trigger in #53 is not known |
| [B134](#b134--a-file-deleted-online-while-the-drain-checks-its-write-is-kept-as-a-conflict-copy) | A file deleted online while the drain checks its write is kept as a conflict copy | Only with `local_first` off |
| [B91](#b91--the-daemon-hung-overnight-and-nothing-could-say-what-it-was-waiting-on) | The daemon hung overnight, and nothing could say what it was waiting on | The cause is not diagnosed; the daemon now reports where it is stuck and is restarted |
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
@@ -95,6 +83,12 @@ original wording.
| Entry | Title | What is left |
|---|---|---|
| [B270](#b270--one-file-left-in-a-mirror-folder-clears-the-wipe-guard-for-all-the-others) | One file left in a mirror folder clears the wipe guard for all the others | A loss under nine in ten still goes through; no choice offered in the app |
| [B269](#b269--a-restore-that-skipped-every-folder-reports-success) | A restore that skipped every folder reports success | The app shows the result only in the activity feed; no offer to replace or reset the folder in the way |
| [B268](#b268--pdfs-diagnose-says-no-problems-found-while-sync-is-stuck) | `pdfs diagnose` says "No problems found" while sync is stuck | The sync engine's state is not checked |
| [B263](#b263--a-mount-that-can-never-succeed-retries-every-5-s-and-tells-only-the-journal) | A mount that can never succeed retries every 5 s and tells only the journal | Errors no retry fixes back off like network errors |
| [B262](#b262--watching-a-large-synced-folder-holds-up-the-sync-engine-and-a-failed-watch-is-never-retried) | Watching a large synced folder holds up the sync engine, and a failed watch is never retried | The first pass still waits for the watch; a failed watch is not shown in the app |
| [B259](#b259--the-sync-engine-can-die-or-hang-and-nothing-shows-it-until-a-restart) | The sync engine can die or hang, and nothing shows it until a restart | No pass deadline; an ended engine is reported, not restarted; the trigger in #53 is not known |
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
@@ -115,9 +109,22 @@ a live account since.
| Entry | Title |
|---|---|
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
| [B283](#b283--videos-have-no-thumbnail-outside-photos-and-a-ts-recording-looks-like-source-code) | Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code |
| [B282](#b282--a-file-kept-offline-is-downloaded-again-after-a-change-made-elsewhere) | A file kept offline is downloaded again after a change made elsewhere |
| [B281](#b281--a-file-in-a-synced-folder-cannot-be-kept-offline) | A file in a synced folder cannot be kept offline |
| [B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed) | Uploading a batch of large photos swaps the machine until the daemon is killed |
| [B279](#b279--after-a-watchdog-kill-the-mount-never-comes-back) | After a watchdog kill the mount never comes back |
| [B278](#b278--sync-list-and-devices-list-show-the-last-sync-as-epoch-seconds) | `sync list` and `devices list` show the last sync as epoch seconds |
| [B277](#b277--my-files-mounted-over-a-folder-with-files-in-it-and-hid-them) | My files mounted over a folder with files in it, and hid them |
| [B274](#b274--opening-a-file-for-writing-fails-with-eio-while-its-queued-revision-lands) | Opening a file for writing fails with EIO while its queued revision lands |
| [B272](#b272--after-a-folder-is-removed-and-added-again-its-file-events-go-to-the-old-id) | After a folder is removed and added again, its file events go to the old id |
| [B271](#b271--one-failed-listing-makes-a-large-first-sync-start-over-from-nothing) | One failed listing makes a large first sync start over from nothing |
| [B266](#b266--pin-unpin-search-and-node-loads-report-a-database-error-as-offline) | Pin, unpin, search and node loads report a database error as "offline" |
| [B265](#b265--address-keys-whose-token-is-for-an-inactive-account-key-never-open) | Address keys whose token is for an inactive account key never open |
| [B264](#b264--a-mount-that-fails-after-starting-its-workers-leaves-them-running) | A mount that fails after starting its workers leaves them running |
| [B261](#b261--a-folder-that-has-never-synced-reads-up-to-date) | A folder that has never synced reads "up to date" |
| [B260](#b260--the-poll-pushes-a-new-folders-first-sync-behind-every-other-folder) | The poll pushes a new folder's first sync behind every other folder |
| [B257](#b257--deleting-a-new-file-while-it-is-open-can-drop-the-bytes-its-handle-reads) | Deleting a new file while it is open can drop the bytes its handle reads |
| [B254](#b254--an-edit-in-the-same-second-as-a-new-upload-never-arrives) | An edit in the same second as a new upload never arrives |
| [B253](#b253--the-app-shows-the-sign-in-page-when-the-keyring-is-locked) | The app shows the sign-in page when the keyring is locked |
| [B252](#b252--computers-sharing-a-device-overwrite-each-others-profile) | Computers sharing a device overwrite each other's profile |
@@ -292,6 +299,10 @@ a live account since.
| Entry | Title |
|---|---|
| [B276](#b276--a-stop-can-end-with-a-panic-in-a-thread-the-stop-never-waited-for) | A stop can end with a panic in a thread the stop never waited for |
| [B258](#b258--journalctl--p-warning-shows-nothing-whatever-the-daemon-logged) | `journalctl -p warning` shows nothing, whatever the daemon logged |
| [B256](#b256--pdfs-copy-right-after-a-save-copies-the-old-version) | `pdfs copy` right after a save copies the old version |
| [B255](#b255--a-synced-folder-whose-files-are-all-gone-here-is-stuck-for-good) | A synced folder whose files are all gone here is stuck for good |
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
@@ -375,6 +386,362 @@ a live account since.
Newest first.
### B283 — Videos have no thumbnail outside Photos, and a `.ts` recording looks like source code
**Status:** Fixed, not verified live
**Found:** 2026-11-14, from a user report: large `.ts` video recordings in My files showed a text
icon, and no video in My files had a thumbnail although the same videos had one in Photos.
**Where:** `is_video_file_name` in `crates/pdfs-core/src/control.rs`; `generate_thumbs` and
`sample_video` in `crates/pdfs-fuse/src/photos.rs`; `bind_file_thumbnail` in
`crates/pdfs-gui/src/app/widgets/thumbnails.rs`
**Repro:** Put an `.mp4` and a recorded `.ts` of a few GB in My files and open the folder in the
app's grid view. Both show a generic icon, and the `.ts` shows a text one.
**Cause.** Four things:
- File views asked the daemon for thumbnails of images only (`is_thumbnail_image_name`), so a
video was never asked for.
- `.ts` is TypeScript as often as it is an MPEG transport stream. Drive stores both as
`application/octet-stream`, and every place that names a file's type went by the extension:
the browser's icon list had no `.ts`, GIO guesses text, and the opener's `@text` class listed
`.ts`.
- The daemon downloaded a video whole into memory for one frame, so it refused anything over
256 MiB, and the size check behind that refusal asked the Photos client, which knows nothing
of a My files node.
- `file_thumbs` never asked for the thumbnail an uploader attached, on the belief that only
Photos nodes have one. This client attaches one to every upload it can make one for.
**Fix.** `is_video_file_name(name, size)` is the one rule: a video extension, and for `.ts` a
size of at least 1 MiB. The browser icon, the thumbnail gate, the launcher's icon and type, and
the opener's classes use it. For a file view, the daemon first asks Drive for the file's own
thumbnail. Without one, a video of up to 24 MiB is downloaded whole. A larger one brings its first
16 MiB, and for an MP4 or MOV whose `moov` box lies further in, the box headers on the way and
that box. The parts are laid out at their offsets in a sparse staging file, which ffmpeg reads.
A `.ts` whose head lacks the 0x47 sync byte is recorded as having no thumbnail.
**Test:** `a_ts_file_is_a_video_only_at_the_size_of_one` and
`listings_thumbnail_images_and_videos` in `crates/pdfs-core/src/control.rs`;
`a_ts_file_opens_as_media_or_text_by_its_size` in `crates/pdfs-core/src/opener.rs`;
`a_large_mp4_downloads_its_head_and_its_index` and the other `sample_video` tests in
`crates/pdfs-fuse/src/photos.rs`; the simulation
`a_video_thumbnail_downloads_a_small_part_of_the_video`, which reads under 34 MiB for two
videos of over 70 MiB and takes nothing from a video with an uploader's thumbnail.
`real_video_fixture_yields_a_frame` passed on 2026-11-14 with ffmpeg 9.0 for a 46 MB MP4 with its
index at the end, an MPEG-TS and an MKV.
### B282 — A file kept offline is downloaded again after a change made elsewhere
**Status:** Fixed, not verified live
**Found:** 2026-11-13, while looking into B281. Nothing downloaded a pinned file again after the
pin had downloaded it once.
**Where:** `apply_event` in `crates/pdfs-fuse/src/background.rs`; the new pin keeper in
`crates/pdfs-fuse/src/keeper.rs`
**Repro:** Pin a file, then change it from another device. The event deletes the cached copy.
The file stays listed as kept offline, `cached` is false, and the next read downloads it, or
fails without a network. Rename or move it elsewhere and the same happens, though its content
did not change. Add a file to a pinned folder from another device and it is never downloaded.
A folder pin whose downloads failed while the link was down never retries them.
**Cause:** A remote change to a known node called `ContentCache::evict`, which does not check
pins, and only the pin request itself downloaded anything. The copy was also stored under the
size the listing held at pin time. That is the ciphertext size until the size upgrade lands
(B12), and a copy stored under it never matches the plaintext it holds.
**Fix:** A remote change no longer evicts a pinned file's copy. The copy is checked against the
revision's `(mtime, size)`, so a read of a newer revision does not take it. A rename keeps the
revision's mtime (B123), so the copy stays valid. A pin keeper thread checks pins again and
downloads files that are missing or stale:
- the pins a batch of remote events changed, after the batch's listings are dropped;
- a pinned folder that gets a new file;
- every pin at startup, when the link comes back, and after the event feed loses continuity;
- a file whose version is restored.
Failed downloads are tried again with a backoff from 30 s to 30 min. Pinning settles a
provisional size before it stores the copy.
**Test:** `a_file_kept_offline_stays_on_disk_through_changes_made_elsewhere` in
`crates/pdfs-fuse/src/sim/daemon.rs`. Another device edits a pinned file and a file in a pinned
folder, and adds a file to that folder. The test waits until all three are cached, then reads
them with the link down. With the keeper disabled, it fails with all three uncached.
---
### B281 — A file in a synced folder cannot be kept offline
**Status:** Fixed, not verified live
**Found:** 2026-11-13. An image was made available offline in the app, and it opened from the app
with no download. `feh` opened it from a terminal and downloaded it each time. The two were not
the same file. The app had kept `~/ProtonDrive/Pictures/photo.png`, in My files. The
terminal opened `~/Pictures/photo.png`, in the on-demand synced folder `~/Pictures`. That
copy is a separate node under Computers, uploaded on its own, with the same name and size but a
different mtime. The daemon treats every program the same way: it does not look at the process
that reads.
**Where:** the `Pin` and `Unpin` handlers in `crates/pdfs-fuse/src/control.rs`
**Repro:** Add a synced folder in on-demand mode and put a file in it. `pdfs pin` on its absolute
path fails with "is not under the mountpoint". A relative path is resolved against My files, so
it keeps a file of the same name there, if one exists, or fails. Reading the file in the synced
folder then downloads it.
**Cause:** Both handlers turned the path into a mount-relative one with `rel_to_mount`, which only
knows the primary mount. B86 moved `ls`, `refresh`, `rm` and `mv` to `route_to_mount`, which finds
the mount that serves an absolute path. Pin and unpin were left out.
**Fix:** Pin and unpin route through `route_to_mount` and run on the mount that owns the path. A
path in a mirror folder gets an error that says its files are already on this computer. The pin
registry records the absolute local path for a pin made through an on-demand folder, so
`pdfs pins`, the prompt and the HUD open the file through the folder it was kept from. Pins
under the primary mount still record the path relative to it.
**Test:** `pinning_a_file_in_an_on_demand_folder_keeps_that_folders_copy` in
`crates/pdfs-fuse/src/sim/daemon.rs`. It pins a file through an on-demand folder's absolute path,
takes the link down, and reads the file through that folder.
---
### B280 — Uploading a batch of large photos swaps the machine until the daemon is killed
**Status:** Fixed, not verified live
**Found:** 2026-11-12, after B279. 868 new pictures (15.6 GiB) were copied into
`~/ProtonDrive/Pictures`. Each restart reached 1.7 GiB resident and 9.8 GiB of swap within 40 s.
The 16 `pdfs-drain-*` threads were all busy, cgroup memory pressure (`full avg10`) was 77 %, and
the system's 15 GiB of swap was full. The watchdog killed the daemon, and the next start did the
same. The largest picture was a 97 MB JPEG of 11656×8742 pixels.
**Where:** `upload_thumbnails_with` and `scale_thumbnail` in `crates/pdfs-fuse/src/photos.rs`
**Repro:** Queue more large photos than there are drain workers, for example by copying a folder
of 100-megapixel JPEGs into the mount, and start the daemon.
**Cause.** Every upload of an image builds its thumbnail by decoding the whole file. Nothing
limited how many of these decodes ran at once, so all 16 drain workers did it together. One
102-megapixel JPEG holds about 300 MB of pixels, and twice that when it has to be turned upright.
Sixteen of them do not fit in memory, and `MemoryHigh=2G` throttled the daemon into swap until it
stopped answering.
**Fix.** Each thumbnail decode first takes a share of a process-wide 512 MiB budget. The share is
the encoded size plus 8 bytes per pixel, read from the image header. A 12-megapixel phone photo
still decodes beside four others. A 100-megapixel photo waits until no other decode runs, and
then runs alone. The upload path takes its share before it reads the file.
**Test:** `decode_budget_tests` in `crates/pdfs-fuse/src/photos.rs`. On 2026-11-12 the same queue
on the fixed build peaked at 1.3 GiB resident and 90 MB of swap, with memory pressure near 0 %.
---
### B279 — After a watchdog kill the mount never comes back
**Status:** Fixed, not verified live
**Found:** 2026-11-12, copying a folder of pictures into `~/ProtonDrive/Pictures`. The daemon
stopped answering, the watchdog aborted it after 4 h 39 min (2.5 GiB memory peak, 11.8 GiB swap
peak), and every restart then logged
`create mountpoint /home/narl/ProtonDrive: File exists (os error 17)`. After the dead mounts were
cleared by hand, the next start mounted all six folders and was aborted again, 120 s after it
began, leaving six dead mounts behind once more.
**Where:** `mount_once` in `crates/pdfs-cli/src/main.rs`; `prepare_mountpoint` in
`crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-fuse/src/supervisor.rs`;
`crates/pdfs-fuse/src/systemd.rs`
**Repro:** Kill the daemon with `SIGABRT` while it is mounted. Each restart fails with `EEXIST`
until `fusermount3 -uz` clears every mount. Then restart on an account where hydrating the
metadata cache takes about a minute: the watchdog aborts the new daemon before it pings.
**Cause.** Two faults, one after the other.
1. A dead FUSE mount answers `ENOTCONN`. `create_dir_all` in `mount_once` read that as "absent",
tried `mkdir`, and failed with `EEXIST`. The daemon retried that error forever and never
reached `check_mountpoint`, which clears a dead mount.
2. systemd arms the watchdog when the process starts, but the supervisor sent its first ping one
full period (60 s) after it started. The supervisor starts only once the mount is live: 74 s
in on this account (70 s of that is hydrating 47 958 nodes). 74 s + 60 s is past the 120 s
`WatchdogSec`, so the run was aborted just before its first ping.
**Fix.** `mount_once` calls `prepare_mountpoint`, which clears a dead mount before it creates the
directory. Before startup, the daemon sends `WATCHDOG_USEC=` to allow 10 minutes. When the
supervisor first gets an answer from the control socket, it sends the unit's own timeout back.
The supervisor also probes and pings on its first tick, not one period later. A startup that
hangs for good is still aborted, only later.
The daemon stopped answering during the copy because of
[B280](#b280--uploading-a-batch-of-large-photos-swaps-the-machine-until-the-daemon-is-killed).
**Test:** `first_watchdog_ping_is_due_at_once` in `crates/pdfs-fuse/src/supervisor.rs`. The
stale-mount path and the watchdog handshake need a live systemd unit to check.
---
### B278 — `sync list` and `devices list` show the last sync as epoch seconds
**Status:** Fixed, not verified live
**Found:** 2026-11-02, issue #46. The reporter's `pdfs sync list` on 3.6.0 read
`synced: 1793570850`, a pass that finished at 2026-11-01 22:07 UTC.
**Where:** `cmd_sync` and `cmd_devices` in `crates/pdfs-cli/src/main.rs`
**Repro:** Let a synced folder finish one pass and run `pdfs sync list`, or `pdfs devices list`
on an account with a device that synced.
**Cause.** Both commands printed `last_sync` with `to_string()`. The activity feed already
formats the same epoch seconds with `format_epoch`.
**Fix.** Both columns go through `last_synced`, which prints `MM-DD HH:MM` in UTC like
`pdfs activity`, and `never` for 0 or no value. `--json` output is unchanged.
**Test:** `last_synced_shows_a_time_not_epoch_seconds` in `crates/pdfs-cli/src/main.rs`.
---
### B277 — My files mounted over a folder with files in it, and hid them
**Status:** Fixed, not verified live
**Found:** 2026-10-31, issue #67. The reporter chose an existing working folder as the My files
location. Its folders disappeared and looked deleted; they came back when the service stopped.
**Where:** `mount_with` and `check_mountpoint` in `crates/pdfs-fuse/src/mount.rs`;
`crates/pdfs-core/src/mountpoint.rs`; `prompt_mountpoint` in
`crates/pdfs-gui/src/app/pages/status.rs`
**Repro:** Stop the service. Put a file and a folder in `~/ProtonDrive`, or choose a folder with
files in it under Preferences → General → Location. Start the service. The files are hidden
under the mount: not uploaded, not in the queue, and back only once the service stops.
**Cause.** FUSE mounts over a folder that is not empty without a word, and nothing checked the
folder first. `mount_once` only created it. On-demand synced folders were already checked when
they were mounted again after a restart (`restore_ondemand_mounts`); My files was not. The
folder picker took any folder.
**Fix.**
- The daemon refuses to mount My files over a folder with anything in it, hidden files
included, before it fetches the root or starts a worker. A dead mount is cleared first, so it
can be listed. One of this client's own live mounts is left to the mount, which clears it as
before.
- The refusal is a mount failure like any other: it backs off and tries again, so emptying the
folder is enough. The failure record now carries the folder and its first entries
(`MountFailure::not_empty`), which an older reader ignores.
- The app explains it in the user's language, and offers to open the folder or choose another
one. The folder picker refuses a folder with files in it.
Merging an existing folder into My files is not done here. #67 proposes `pdfs sync attach` for
that, as a separate change.
**Test:** `a_folder_with_files_is_refused_and_named`, `a_hidden_file_alone_is_enough_to_refuse`,
`an_empty_or_missing_folder_takes_the_mount` in `crates/pdfs-core/src/mountpoint.rs`;
`a_failure_an_older_daemon_wrote_still_reads` in `mountfail.rs`;
`a_mount_refused_over_files_is_recorded_as_such` in `crates/pdfs-cli/src/main.rs`; the
simulation `a_mount_over_local_files_is_refused_and_leaves_them_alone`.
---
### B276 — A stop can end with a panic in a thread the stop never waited for
**Status:** Fixed, checked on 2026-11-01 by an account run of `scripts/fuse-acceptance.sh
--journal-check --durability`: eight daemon restarts, and no panic in the journal
**Found:** 2026-10-31, by an account run of `scripts/fuse-acceptance.sh --journal-check
--durability` against the audit branches. Every case passed, and the run still failed. During
the restart in the durability case on My files, the journal had three
`ERROR fuser::reply: Failed to send FUSE reply: No such file or directory (os error 2)` lines,
and after them a panic that the check did not report:
```
thread '<unnamed>' (1536340) panicked at .../tokio-1.53.1/src/runtime/time/entry.rs:539:9:
A Tokio 1.x context was found, but it is being shutdown.
```
The same panic is in the journal of a 3.6.0 stop on 2026-10-28, twice. B91 first saw it in a
meta worker.
**Where:** `mount_once` and `release_runtime` in `crates/pdfs-cli/src/main.rs`;
`JournalWatch` and `journal_problems` in `scripts/fuse-acceptance.py`.
**Cause.** A stop joins the drain workers, the sync engine and the FUSE worker pool, and then
`mount_once` shut the tokio runtime down. Other threads also use the runtime and are never
joined: a size lookup for a listing, a profile backup, a control request, a database vacuum.
A request still in progress when the stop began can start one of them. The thread id here is
nine below the next daemon's pid, so this thread started during the stop. Once the runtime is
shut down, that thread's next timer panics. Every call to Drive has a timer, because each one is
bounded.
The three `fuser::reply` lines are part of a correct stop. The stop aborts each mount's FUSE
connection so the unmount cannot hang. A request that was still running replies after that,
and the kernel answers a reply on an aborted connection with `ENOENT`.
The journal check matched only the `ERROR` token. A panic goes to stderr with no tracing level,
so the check did not see it.
**Fix.**
- On a stop, `release_runtime` leaves the runtime running for the process to end with, instead
of shutting it down. The process exits right after, so a thread the stop did not wait for
can finish its call or be ended by the exit, but it cannot panic. A mount that is about to
be tried again still shuts its runtime down with the 2 s limit from B165, because a new
runtime replaces it.
- The journal check counts `panicked at` lines.
- The journal check ignores the `ENOENT` reply error between the connection abort and the end
of that stop ("daemon stopping", or "control socket listening" for a mount that comes back).
At any other time the error still counts.
Making the bounded call helpers refuse new calls once the stop flag is set was rejected. The
stop sets that flag before it waits up to 10 s for the drain workers, so the refusal would cut
off the second half of an upload the stop is waiting for.
**Test:** `a_thread_left_running_at_a_stop_can_still_use_the_runtime` and
`a_mount_tried_again_shuts_its_runtime_down` in `crates/pdfs-cli/src/main.rs`. With the old
shutdown, the first one fails with the same panic at `entry.rs:539`. The new journal
filter was run on the journal of the run that found this: the three reply errors are no longer
counted, and the panic is.
---
### B275 — On a slow link, a file's last write can land only in a conflict copy
**Status:** Open
**Found:** 2026-10-31, CI on #60: `one_client_on_a_slow_link`, profile `one-client-wifi` seed 2,
failed with `Drive does not hold what the clients wrote, 120s after the links came up`. The
same seed passed 4 times locally, and #60 changes no daemon logic, so the failure depends on
timing.
**Where:** not located; the drain's conflict check in `crates/pdfs-fuse/src/drain.rs` is the
first suspect
**Repro:** Only the CI run above. The last steps created `x/z/y/a.txt` (37 bytes), wrote 42
bytes into it at offset 20, and brought every link up. Drive then held the file at its first
37 bytes, and the full content was in `x/z/y/a (sync-conflict …).txt`. No other client wrote
to it.
**Cause.** Not known. Nothing was lost, since the conflict copy holds the bytes, but the file
conflicted with its own create. B183 and B184 found the same symptom after a lost create
reply.
**Fix.** Not done. Needs a reproduction: run the seed in a loop under load and keep the state
directory of a failing run.
---
### B274 — Opening a file for writing fails with EIO while its queued revision lands
**Status:** Fixed, not verified live
**Found:** 2026-10-31, CI: the simulation
`a_file_whose_upload_was_not_read_back_takes_two_writes_at_once` failed once with
`Input/output error` on a write, after `copy queued revision into write scratch failed … No
such file or directory`. It did not fail in 54 local runs.
**Where:** the write open in `crates/pdfs-fuse/src/filesystem.rs`
**Repro:** Write a file, and open it for writing again at the moment its queued revision
finishes uploading. The open fails with EIO, and the program sees its save fail.
**Cause.** The open samples the queued revision, then copies its blob into the new handle's
scratch file. The drain lets go of the pending entry and then deletes the blob when the upload
lands. A blob deleted between the sample and the copy failed the open, although the re-check
just after the copy already retries a revision that changed during it.
**Fix.** A failed copy checks whether the pending entry still names that blob. When it does
not, the revision landed or was replaced, and the open goes around again with the base the
file has now, as the re-check does. Since the drain drops the entry before the blob, a blob
that is gone and still named is a real failure and still returns EIO.
**Test:** none deterministic; the simulation above covers the window.
---
### B273 — A restore proposes another computer's paths when the two share a hostname
**Status:** Open
@@ -395,7 +762,7 @@ written before it existed.
### B272 — After a folder is removed and added again, its file events go to the old id
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #53.
**Where:** `rewatch` in `crates/pdfs-fuse/src/sync/engine.rs`
@@ -407,17 +774,21 @@ two-minute poll, and the same goes for every later edit until the daemon restart
path is still wanted, so the old entry stays with the removed folder's id. Its events become
`Reconcile(old id)`, `sync_folder_get` finds no row, and the request is dropped.
**Fix.** Not done. Compare `(path, id)` pairs, and update the id of a path that is still
watched.
**Fix.** `keep_wanted` keeps a watch whose path is still wanted and gives it the id the
folder list has now.
**Test:** `a_re_added_folder_keeps_its_watch_under_the_new_id` in `pdfs-fuse`.
---
### B271 — One failed listing makes a large first sync start over from nothing
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #46: a folder of about 19,900 items restored on a
second computer sat at `scanning…` with nothing downloaded, and the journal showed
`RequestTimeout (http 408)`.
`RequestTimeout (http 408)`. On 2026-11-01 the reporter's 3.6.0 journal showed the same pass
failing four times in four minutes with `reconcile failed id=1 error=enumerate …: HTTP transport
error`, after the laptop woke from sleep; a later pass finished.
**Where:** `walk_remote` in `crates/pdfs-fuse/src/sync.rs`
**Repro:** Restore or add a mirror folder with tens of thousands of items on Drive, on a link
@@ -431,14 +802,19 @@ the walk is done. Files that did download keep their baseline rows, so only the
but on a first sync that is all of it. Progress is also counted only after a folder's full
listing, so a large folder shows `scanning…` with 0 checked for a long time.
**Fix.** Not done. Record a failed subtree as a gap, as undecryptable nodes already are: no
deletions under it, and the rest of the pass still applies.
**Fix.** A listing that fails below the sync root records that folder as a gap in
`RemoteGaps`, next to the folders whose listing came back short. The planner deletes nothing
under a gap, the rest of the pass still applies, and the pass report names the folder as
tried again next pass. A failed listing of the root itself still fails the pass, since there is
nothing left to apply. The progress count inside one large folder is unchanged.
**Test:** `a_folder_that_failed_to_list_hides_everything_under_it` in `sync::planner`.
---
### B270 — One file left in a mirror folder clears the wipe guard for all the others
**Status:** Open
**Status:** Partly fixed
**Found:** 2026-10-31, code audit after issue #46, whose reporter emptied a synced folder
several times.
**Where:** `guard_local_wipe` and `guard_remote_wipe` in `crates/pdfs-fuse/src/sync/planner.rs`
@@ -452,14 +828,26 @@ that two computers sync, the other computer then deletes its copies too.
path is enough to clear it, so it guards against an empty folder and nothing short of that.
The remote guard has the same shape.
**Fix.** Not done. Also refuse when most of a large baseline is missing at once, and leave the
choice to the user: delete on the other side, or sync from scratch.
**Fix.** Both guards also refuse a pass when nine in ten or more of the tracked paths of a
folder with at least 20 are missing at once (`looks_like_a_wipe`). The folder goes into error with
the count in its reason, `pdfs diagnose` reports it, and **Sync From Scratch…** brings the files
back. A smaller folder still stops only on a total loss, since an ordinary cleanup can delete most
of it.
**What is left.** A loss under nine in ten, such as a disk that is half there, still goes
through. The app does not offer the choice yet: confirm the deletions on the other side, or sync
from scratch.
**Test:** `one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards`,
`a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone` in
`crates/pdfs-fuse/src/sync/planner.rs`.
---
### B269 — A restore that skipped every folder reports success
**Status:** Open
**Status:** Partly fixed
**Found:** 2026-10-31, code audit after issue #46: the journal showed `restore complete
restored=0 skipped=1` several times, and the user saw nothing.
**Where:** the `RestoreSyncFolders` and `RestoreDeviceFolders` handler in
@@ -474,15 +862,20 @@ result, `restored 0 folder(s); skipped: …`, is then logged as a successful act
reason is only in the activity text and the journal. When the database insert fails partway,
the `?` also skips the `Rewatch` for the folders already restored.
**Fix.** Not done. Log a restore that skipped folders as a failure, name the reason in the app
and the CLI, and offer to replace or reset the folder in the way. Send the `Rewatch` on every
exit path.
**Fix.** `restore_sync_folders` returns what it restored and what it skipped, with the reason
for each skip. The daemon logs a restore that skipped any folder as a failed activity, with the
reasons as its detail, and warns in the journal. A database error partway now sends the
`Rewatch` for the folders already restored before it returns. `pdfs devices join` points to
`pdfs activity` for the result.
Left: the app shows the result only in the activity feed, and nothing offers to replace or reset
the folder in the way.
---
### B268 — `pdfs diagnose` says "No problems found" while sync is stuck
**Status:** Open
**Status:** Partly fixed. The sync engine's own state is not checked yet.
**Found:** 2026-10-31, issue #53: a folder that never synced, and `diagnose` found nothing.
**Where:** `diagnose` in `crates/pdfs-cli/src/main.rs`
@@ -495,8 +888,16 @@ folders or for `Diagnostics`, so a folder in error and a stalled worker are not
control socket file with no daemon behind it is also reported `[ok]`; only the next line says
the daemon does not answer.
**Fix.** Not done. Warn on failing or parked writes, a pause, old staged data and folders in
error, and report the sync engine's state once B259 exposes it.
**Fix.** `diagnose` warns about writes that keep failing (with the last error), a write that
has waited a day or more to upload, a pause, and each synced folder in `error` or `conflict`
(`sync_health` and `folder_health` in `crates/pdfs-cli/src/main.rs`). A control socket file
that nothing listens on is a warning, not `[ok]`. Parked uploads are left out: a file still
wearing a download's temporary name is normal. Not done: the sync engine's state, which
`Diagnostics` reports once B259's change is in.
**Test:** `a_stuck_queue_or_a_pause_is_reported` and `a_synced_folder_in_error_is_reported` in
`pdfs-cli`. On 2026-10-31 the new `diagnose` against a healthy 3.6.0 daemon still reported
"No problems found".
---
@@ -523,7 +924,7 @@ report the others as their own state through `Status`.
### B266 — Pin, unpin, search and node loads report a database error as "offline"
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #38. B205 fixed the photo upload path only.
**Where:** `CoreError::from_api` in `crates/pdfs-core/src/error.rs`; its callers in
`crates/pdfs-fuse/src/lib.rs` (`pin`, `unpin`, the searches, `load node`)
@@ -536,14 +937,17 @@ it was written for SDK calls, where that means a transport error. These callers
`pdfs_core::Error` values from the cache and the database. A `ProtonError` wrapped in
`pdfs_core::Error::Proton` also misses the downcast, since the source chain is not walked.
**Fix.** Not done. Use `CoreError::internal` at the database callers, and walk the source
chain in `from_api` before falling back.
**Fix.** `from_api` walks the source chain. The first `ProtonError` or `pdfs_core::Error` in it
decides the kind, the same way `From<Error>` does, and only an error with neither, a transport
error, reads as offline. That covers every caller at once, the database ones included.
**Test:** `a_database_error_handed_to_from_api_is_not_offline` in `pdfs-core`.
---
### B265 — Address keys whose token is for an inactive account key never open
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #40: a Duo account whose mount failed on every
start with `fetch My Files root: cryptography error: failed to decrypt: missing key`, after three
`failed to decrypt address key token` warnings. The web app worked. B210's fix does not apply,
@@ -560,14 +964,23 @@ already reports "token is for inactive account key", so the case is known. Separ
`addresses()` fails as a whole through `?` when one address has no usable primary key, which
takes the working addresses down with it.
**Fix.** Not done, and it belongs in the SDK. Use every account key that opens for decrypting
tokens, and only the active ones for signing. Skip and log an address that cannot be opened.
**Fix.** In `proton-sdk` 0.10.1 (narrrl/proton-sdk-rs#3). Tokens are tried with every account
key that opens, active ones first, and signing uses active keys only. An address that cannot be
opened is left out with a warning, and the others work; `addresses()` fails only when none
opens. When the left-out address is the default, `default_address` fails with the reason
rather than signing with another address. `key_report` walks the same chain, so `pdfs diagnose`
reports such a key as opening. Not verified against an account with an inactive key, since
none is at hand; issue #40's reporter can confirm.
**Test:** `an_address_key_whose_token_is_for_an_inactive_account_key_opens`,
`an_address_whose_keys_do_not_open_leaves_the_others_working` and
`an_account_whose_addresses_all_fail_to_open_is_an_error` in `proton-sdk`.
---
### B264 — A mount that fails after starting its workers leaves them running
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #40.
**Where:** `run_mount` in `crates/pdfs-fuse/src/mount.rs`
@@ -581,14 +994,20 @@ permission check, a `spawn_session` error, and a `?` on a later thread spawn) sk
the `flock` held, every later attempt fails with "another daemon is using cache.db" instead
of the real error. That part is inferred from the code and not observed.
**Fix.** Not done. Bind the socket and check the mountpoint before any worker starts, and stop
the workers on every error path.
**Fix.** Every early return in `mount` after the first worker starts stops the workers
(`abandon_workers`, `keep_or_abandon` in `crates/pdfs-fuse/src/mount.rs`). Unlike a normal stop
it does not send `STOPPING=1`, because the daemon is about to retry and systemd would
otherwise wait for it to exit and then kill it. The control-thread failure path, which already
stopped its workers, no longer sends it either.
**Test:** none of its own; the simulations (`sim::daemon`, 55 passed) cover the normal start
and stop.
---
### B263 — A mount that can never succeed retries every 5 s and tells only the journal
**Status:** Open
**Status:** Partly fixed. Errors that no retry fixes are not told apart from network errors yet.
**Found:** 2026-10-31, issue #40: the app showed "Connecting… waiting for the Proton Drive
service" for days, `pdfs diagnose` only reported the control socket missing, and the reporter
uninstalled.
@@ -605,15 +1024,30 @@ four requests, so a broken account sends tens of thousands a day, which may also
Proton's sign-in blocks (#39). The control socket is bound only after the mount succeeds, and
the error is written nowhere a front end can read it.
**Fix.** Not done. Back off exponentially, treat key, `KeysLocked` and 4xx errors as permanent
with a long back-off, and write the last error where the app, the tray and `diagnose` can
show it (a state file, and `STATUS=` to systemd).
**Fix.**
- The retry doubles from 5 s to a 2-minute ceiling (`mount_retry_delay` in
`crates/pdfs-cli/src/main.rs`), about 720 attempts a day instead of 17,000. The ceiling stays
at minutes because a cold start without a network fails the same way and has to mount soon
after the network returns.
- Each failure is written to `mount-failure.json` in the state directory
(`crates/pdfs-core/src/mountfail.rs`), with the error, since when it fails and the attempt
count. A mount that comes up removes it, and so does a new daemon.
- The app shows "Can't open your Drive" with the error and a Retry button instead of
"Connecting…". The tray says "Signed in as … — can't open your Drive". `pdfs diagnose` reports
`[FAIL] mount: failing for …`. `systemctl --user status` shows the error as the unit's status.
- Not done: key, `KeysLocked` and 4xx errors still retry like network errors, only less often.
**Test:** `a_failing_mount_backs_off_to_a_ceiling` in `pdfs-cli`;
`a_recorded_failure_reads_back_until_cleared` in `pdfs-core`. On 2026-10-31 a second
`pdfs daemon` beside the running one failed on the database lock three times in 18 s (at 0, 5
and 15 s) and recorded `attempts: 3` with the lock error.
---
### B262 — Watching a large synced folder holds up the sync engine, and a failed watch is never retried
**Status:** Open
**Status:** Partly fixed. A watch is still set up before the first pass, and a failed watch
is not shown in the folder's state.
**Found:** 2026-10-31, code audit after issue #53.
**Where:** `rewatch` and `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
@@ -631,14 +1065,22 @@ line, the folder is never watched, and later changes are found only by the two-m
and they use up watches the next folder needs.
- Errors the watcher reports later are dropped in its callback.
**Fix.** Not done. Reconcile first, unwatch after a partial failure and retry with a back-off,
turn off symlink following, and show a watch failure in the folder's state.
**Fix.**
- A failed watch is unwatched, which removes the watches it had added under the folder, and
is tried again every 10 minutes. The warning names `fs.inotify.max_user_watches`.
- The watcher no longer follows symlinks.
- Errors the watcher reports are logged.
- Not done: the watch still runs before the folder's first pass. Watching first is what keeps
a change made during the pass from being missed, so the order stays; the cost is one walk
of a new folder's tree. A failed watch is not shown in the folder's state yet.
**Test:** none for the watch failure itself; it needs the inotify limit reached.
---
### B261 — A folder that has never synced reads "up to date"
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, issue #53: "In Computers I add the folder. It appears as synced."
**Where:** `sync_folder` in `crates/pdfs-core/src/db/migrations.rs`; `sync_state_label` in
`crates/pdfs-gui/src/app/pages/devices.rs`; the location rows in
@@ -653,14 +1095,21 @@ every folder while sync is paused for the whole account.
it, and a test pins the label for that case. The rows only show a folder's own pause, not the
account-wide one.
**Fix.** Not done. Show "waiting for first sync" when `last_sync` is 0, and show the
account-wide pause on the folder rows.
**Fix.** A mirror folder whose `last_sync` is 0 and whose state is `idle` reads "waiting for
first sync" on its row. The This computer row on the Computers page counts such folders, as in
"2 folders backed up · 1 waiting for its first sync". An on-demand folder has no first sync,
so it is left out. The account-wide pause stays where it already is, the pause card at the top
of the page; repeating it on every row would only add noise.
**Test:** `a_folder_reads_up_to_date_only_after_its_first_sync` and
`a_folder_before_its_first_sync_is_not_counted_as_backed_up` in `pdfs-gui`; the pinned test now
expects the new label.
---
### B260 — The poll pushes a new folder's first sync behind every other folder
**Status:** Open
**Status:** Fixed, not verified live
**Found:** 2026-10-31, code audit after issue #53.
**Where:** `engine_loop` in `crates/pdfs-fuse/src/sync/engine.rs`
@@ -673,14 +1122,18 @@ running. When a burst holds both a `ReconcileAll` and a folder's `Reconcile(id)`
only `reconcile_all`, which goes in id order. A new folder has the highest id, so it always
goes last.
**Fix.** Not done. Run the folders asked for by id before the rest, and don't queue a poll
while a pass runs.
**Fix.** `run_burst` runs the folders a burst names by id first, lowest id first, and then
every other folder when the burst also holds a `ReconcileAll`. A named folder is read again
before its pass. A pause that lands between passes now stops the rest of the burst.
**Test:** `named_folders_run_before_the_rest_of_a_burst` in `pdfs-fuse`.
---
### B259 — The sync engine can die or hang, and nothing shows it until a restart
**Status:** Open
**Status:** Partly fixed. A hung pass is reported but not ended, and a panic outside a pass
still stops the engine; it is reported, not restarted.
**Found:** 2026-10-31, issue #53: a newly added folder did not sync until the reporter restarted
the app. All FUSE workers were idle, nothing was queued, and the warning-level journal was
empty (B258).
@@ -704,15 +1157,31 @@ is why it helps.
nothing about sync.
- A database error when the engine reads a folder drops that folder's request without a log.
**Fix.** Not done. Catch a panic per pass, end its progress and set the folder to `error`. Have
the supervisor restart a finished engine thread. Log passes at info, and report the folder,
the pass age and the queue in `Diagnostics`.
**Fix.**
- Each pass runs under `catch_unwind` (`EngineHealth::contain` in
`crates/pdfs-fuse/src/sync/engine.rs`). A panic ends that pass only: its progress is cleared,
the folder is set to `error`, the activity feed says so, and the next poll tries again.
- `Diagnostics` carries the engine's state: running or not, the folder of the running pass, how
long it has run and how long since it last reported progress, and how many passes panicked.
`pdfs diagnostics` prints it as `sync engine: …`.
- The supervisor warns when the engine thread has ended, and when a pass has reported no
progress for 15 minutes.
- A first pass logs its start and end at info, as does any pass that changed something or
took 30 s or more. A request for a folder the database cannot read is logged.
- Not done: a deadline for a pass, and restarting an engine thread that has ended. The
channel's receiver ends with the thread, so a restart needs a new channel in every `Core`
clone.
**Test:** `a_panicking_pass_is_contained_and_counted`, `the_running_pass_is_reported` and
`the_engine_reads_as_stopped_once_its_thread_ends` in `pdfs-fuse`;
`a_stopped_sync_engine_is_a_stall` and `only_a_quiet_sync_pass_is_a_stall` in the supervisor.
---
### B258 — `journalctl -p warning` shows nothing, whatever the daemon logged
**Status:** Fixed, not verified live
**Status:** Fixed, checked on 2026-11-01 on a live account: after an acceptance run,
`journalctl --user -u proton-drive.service -p warning` listed all 76 `WARN` lines the run logged
**Found:** 2026-10-31, issue #53: the reporter ran `journalctl --user -u proton-drive.service -b
-p warning`, as `docs/TROUBLESHOOTING.md` tells them to, and got `-- No entries --`.
**Where:** `main` in `crates/pdfs-cli/src/main.rs`; `packaging/proton-drive.service`
@@ -737,9 +1206,46 @@ are off. A terminal that merely inherited `JOURNAL_STREAM` keeps the plain forma
---
### B256 — `pdfs copy` right after a save copies the old version
### B257 — Deleting a new file while it is open can drop the bytes its handle reads
**Status:** Fixed, not verified live
**Found:** 2026-10-27, the simulation
`a_file_deleted_offline_while_open_still_reads_and_takes_writes` failing once on CI with a cache
tag left behind, then on every run with a 50 ms pause added to `ContentCache::store_file`.
**Where:** `retire_create` in `crates/pdfs-fuse/src/drain.rs`; `queue_trash` and
`remove_replaced` in `crates/pdfs-fuse/src/lib.rs`; `trash_child` in
`crates/pdfs-fuse/src/filesystem.rs`; `ContentCache::reconcile` in `crates/pdfs-core/src/cache.rs`
**Repro:** Create a file in the mount, wait until `pdfs status` shows nothing queued, open it,
and delete it while the link is down. If the create was still landing, a partial write or a read
through the open handle fails with "Input/output error", and the cache can keep a tag with no
bytes until the next start.
**Cause.** A create that carries the file's bytes retires its op, then reads the node back,
caches the bytes and only then gives the tree the real uid. Nothing counted that window, so the
queue read empty while it ran, which B119 promises means the file reads without the network. A
delete in that window found the placeholder uid in the tree, and trashed the node by its real
uid. `queue_trash` asked the tree whether that uid was open, found no entry for it, and dropped
the cached bytes the open handle reads, which B186 had kept. When the cache store was running at
the same moment, its tag was written after the eviction, and nothing removed it.
**Fix.** The create's landing is counted like an upload's, from the op's retirement until the
tree has the real uid. The delete and the replace pass whether they found the file open, so
`queue_trash` no longer depends on the tree having the new uid. Opening the cache removes a tag
whose bytes are gone, which also covers a crash between an eviction's two removals.
**Test:** The simulation `a_new_file_deleted_while_open_as_its_create_lands_still_reads` holds a
create's landing and fails without the count. The simulation above passes again with the 50 ms
pause, since it now waits for `Daemon::drained`, which counts the landing. Unit test
`a_tag_without_its_blob_is_swept_at_open` in `crates/pdfs-core/src/cache.rs`. No test hits the
delete inside the cache store itself: the window is too short to hold from the simulation.
---
### B256 — `pdfs copy` right after a save copies the old version
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B256" on a live
account
**Found:** 2026-10-26, the simulation `a_pdfs_copy_copies_on_drive_and_waits_for_unsynced_changes`
failing on most runs. The feature was not released yet.
**Where:** `release` in `crates/pdfs-fuse/src/filesystem.rs`; `copy_to` in
@@ -763,7 +1269,8 @@ A move checks the same set.
### B255 — A synced folder whose files are all gone here is stuck for good
**Status:** Fixed, not verified live
**Status:** Fixed, checked on 2026-10-27 by the acceptance case "regression B255" on a live
account
**Found:** 2026-10-26, issue #46: a folder restored from another computer never downloaded.
**Where:** `push_pass` in `crates/pdfs-fuse/src/sync.rs`; `reset_sync_folder` in
`crates/pdfs-fuse/src/devices.rs`
+260
View File
@@ -11,6 +11,266 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
## [3.8.0] — 2026-11-14
Schema: **45**. Synced folders now tell device backups apart from folders attached to My files.
Existing folders stay device backups. Older builds cannot open the upgraded database.
### Added
- **Sync a local folder with a folder you already have in My files** (#67, #77).
`pdfs sync attach ~/Work --to Work` keeps `~/Work` an ordinary local folder and syncs it both
ways with My files/Work. The first sync uploads files that are only on this computer, downloads
files that are only on Proton Drive, and keeps both versions of a file that differs: the one on
Proton Drive keeps its name, and the local one becomes a conflict copy next to it and is
uploaded too. Attached folders are always kept on this computer, never online only. They are
listed apart from device backups and are not part of the device profile used to restore another
computer. Stopping one keeps both copies, and `--delete-remote` is refused for it. If the folder
on Proton Drive is trashed or moved out of My files, syncing stops with an error and the local
files stay. Attaching a folder is done from a terminal for now.
- **Tab completion for `pdfs`** in bash, zsh and fish, installed by every package. Besides
commands and options it completes paths in My files (`pdfs pin Doc<Tab>`) and what the running
service knows, each with a description: synced folders, queued changes, computers, trash, albums,
items shared with you, invitations, bookmarks and conflict copies, and once a path is typed, the
file's versions or the share's members and link. Without the service it still completes commands
and options. Elvish and PowerShell load it from `pdfs completions SHELL`, and so does a source
build; the [command-line reference](CLI.md#shell-completion) has the line for each shell.
`pdfs share` and `pdfs share-role` now check the role (`viewer`, `editor` or `admin`) before
sending the request, and `pdfs public-link` does the same for `viewer` and `editor`.
- **Video thumbnails in My files, Shared, Trash and the launcher**, as in Photos, with a play
badge in the grid. A video's thumbnail is the one its uploader attached when it has one.
Otherwise the daemon takes a frame with ffmpeg from the start of the file, plus an MP4's
index, and never downloads more than about 16 MiB of a video for it, however large the
video is. **Build Thumbnails** covers videos too (B283).
- **A new layout for Photos.**
- The grid shows every photo at its own shape, in rows that fill the width (portraits
are no longer cropped to squares). **Square Tiles** in the page menu brings the even
grid back, and the Year zoom stays square.
- Timeline, Albums and Places switch in the header. The filters take one row, which
scrolls sideways in a narrow window.
- The section you are in floats over the top of the grid once its heading has scrolled
away. Headings are larger, and a check beside each one selects the whole day, month or
year.
- Selection mode works as in other GNOME apps: the header shows the count and
**Cancel**, and a bar along the bottom has these actions:
- **Add to Favorites**, or remove them when every selected photo is already one;
- **Add to Album**;
- **Remove from Album**;
- **Save Copies**, into a folder you pick, never overwriting a file;
- **Move to Trash**.
- Favorites carry a star on their tile, and a video waiting for its thumbnail shows a
video glyph.
- New icons for Photos, albums and the slideshow.
- **Upload** takes several photos and videos at once, and you can drop files onto the
timeline to upload them.
- The zoom level and the tile shape are remembered between runs.
- In the photo viewer, the details panel lies over the photo when the window is
narrow instead of squeezing it.
- **Larger icons in My files.** The icon size slider goes up to 256 px, big enough to
tell photos apart by their thumbnail. Ctrl+scroll, Ctrl+plus, Ctrl+minus and Ctrl+0
change it too, and the size is remembered between runs.
- **Back takes you to where you were.** Back, Forward and Up in My files return to a folder
scrolled where it was left, with the same items selected, and closing the photo viewer scrolls
the grid to the photo it was showing. Alt+Left and Backspace go back a level in Shared with me
and Computers and leave an open album or place in Photos, as Escape does too.
- **Share photos, and more in the photo viewer.**
- **Share…** in a photo's menu and in the viewer's ⋮ menu opens the usual Share dialog: invite
people or make a public link. It is not offered for a photo in an album someone else shared.
- **Copy Image** (Ctrl+C) puts the photo on screen on the clipboard.
- A strip of thumbnails along the bottom of the viewer shows the photos around the one on
screen; click one to jump to it. It fades with the other controls and stays hidden during a
slideshow.
- The line under the date names the town the photo was taken in, when it is known.
- **Drag photos out of Photos.** Select photos, then drag one of them into a file manager, a chat
or any app that takes files. The photos download when dropped and arrive under their own
names. A drag that starts on a photo that is not selected still selects, as before.
- **Upload progress in Photos.** Uploading several photos at once shows "Uploading 3 of 12
items…" with a progress bar above the timeline, instead of a single toast at the start.
- **Videos and music play in the app.** Opening a video or a song in My files, by a double-click
or **Open**, plays it in a player window, streamed from the mount like before. **Open With…**
still hands it to another player, and the separate **Play** item is gone. When the file can't
be played (a codec GStreamer lacks), the window says so and offers **Open With…**. Both this
player and the one in Photos gained a fullscreen button, fullscreen on a double-click,
**Repeat** in the ⋮ menu, `K` to play and pause, `J` and `L` (or Shift+←/→) to jump
10 seconds, and `M` to mute. The volume carries over from one video to the next, and the
thumbnail strip in Photos stays off a video, where it covered the seek bar.
### Changed
- **`proton-sdk` and `proton-drive-rs` 0.11.0.** Timestamps are converted where they enter
from the SDK, and a photo keeps its own modification time now that it no longer follows the
capture time. [The SDK review](SDK-0.11.0.md) lists the new features and breaking changes.
### Fixed
- **Photos zoomed all the way out shows the whole timeline.** The first 200 photos fit on one
screen there, and with nothing to scroll no more were loaded. More now load whenever the photos
don't fill the window, also after a zoom or a resize.
- **My files comes back after the service was killed for hanging** (B279). A killed service left
its folders behind as dead mounts, and every restart then failed with `File exists` and retried
forever without opening anything, until the mounts were cleared by hand. The service now clears
a dead mount before it opens the folder. On a large account a restart that did open My files was
killed again, because loading the account took longer than the 2 minutes the service has to
report in. Startup now gets 10 minutes, and the 2 minutes apply again once the service answers.
- **Uploading many large photos no longer runs the computer out of memory** (B280). Every image
upload decodes the whole picture to make its thumbnail, and up to 16 did so at once. A batch of
100-megapixel photos took about 9 GiB, filled the swap, and the service was killed on every
restart. Thumbnails now share 512 MiB between them: phone photos are still made side by side,
and a very large photo waits and is made on its own.
- **Keep a file offline in an on-demand synced folder** (B281). `pdfs pin` and `pdfs unpin` now
accept a path in a synced folder such as `~/Pictures`. Before, they only took paths under the
Proton Drive mount, so the copy in the synced folder was downloaded again by every program that
read it.
- **Files kept offline stay on this computer when they change elsewhere** (B282). A change from
another device, even a rename, deleted the copy of a file kept offline, and nothing downloaded
it again, so the next read downloaded it or failed without a network. Files added to a folder
kept offline from another device were never downloaded. The copy now stays until the new
version is downloaded, which happens on its own. Files kept offline are also checked at
startup and when the connection returns, and failed downloads are tried again.
- **Large `.ts` recordings show and open as videos** (B283). An MPEG transport stream got the
text icon, no thumbnail, and the `@text` open rule. It now gets the video icon, a thumbnail
and the `@media` rule. A `.ts` of 1 MiB or more counts as a video, a smaller one stays
TypeScript source, and the daemon checks the stream's sync byte before it takes a frame.
- **Videos over 256 MiB get a thumbnail** (B283). They never got one, and a smaller video was
downloaded whole into memory for one frame.
- **A folder opens at the top.** A folder opened in My files, Shared with me or Computers could
open scrolled to where its parent was left, often at the bottom. Another folder, a new search
and a new Photos filter, album or place now start at the top, and a refresh keeps its place.
- **Closing a photo opened from the Places map goes back to the map**, not to the town's
timeline.
- **Albums and Places stay on screen.** They could switch to the timeline by themselves when a
timeline page arrived late, after an upload or after a trash Undo. Coming back to Photos no
longer reloads the timeline and loses its scroll position, unless photos changed while it was
out of sight. After an album or a place, the timeline came back unfiltered while the kind tab
and the date still showed the filter.
- **A search in My files stays.** It was replaced by the folder's listing whenever files
changed, on refresh and on coming back to the page, while the query stayed in the box.
- **The photo viewer's buttons are readable on any photo.** They were white on a see-through
white, and vanished over a bright photo. The bar now keeps only the favorite star, the details
and a menu, which names Slideshow, Save a Copy, Open With, Add to Album, Show on Map and Move to
Trash in words where eight icons stood. A shot stored as more than one file shows a "RAW" or
"Live" switch, or "2 of 3" for a burst, where an unlabelled icon was. The title reads
"Wednesday, October 7, 2026, 13:42" rather than a timestamp. Delete asks first, as it does in
the grid, and Alt+Left closes the viewer.
- **A date jump in Photos stops when you look elsewhere.** A jump still loading the timeline
kept going after another filter, album or place was opened.
## [3.7.0] — 2026-11-02
Schema: **44**, unchanged.
### Added
- **The app and the tray say why your Drive does not open** (B263, #40). When the service runs
but its mount keeps failing, the app shows **Can't open your Drive** with the last error and a
**Retry** button, instead of "Connecting…" for as long as it lasts. The tray says "can't open
your Drive", `pdfs diagnose` reports `mount: failing for …`, and
`systemctl --user status proton-drive` shows the error as the unit's status.
- **`pdfs diagnostics` shows what the sync engine is doing**, in a new `sync engine:` line:
idle, the folder it is on and for how long, or stopped. The journal warns when the engine has
stopped, or when a pass makes no progress for 15 minutes (B259, #53).
- **`pdfs diagnose` reports more problems** (B268, #53): writes that keep failing, data that has
waited a day or more to be sent, a paused sync, synced folders in error or conflict, and a
control socket file that nothing listens on.
### Changed
- **A mount that keeps failing retries less often**, doubling from 5 seconds to every 2 minutes,
instead of every 5 seconds forever (B263).
- **`proton-sdk` and `proton-drive-rs` 0.10.1.** Dependencies are updated, among them
`rusqlite` 0.41 with a newer bundled SQLite.
### Fixed
- **My files no longer opens over a folder that already has files in it** (#67, B277). The files
were hidden under it, not uploaded, and came back only when the service stopped, so they looked
deleted. The service now waits until the folder is empty, and the app says why and offers to
open the folder or choose another one. The folder picker in Preferences refuses a folder with
files in it. **After upgrading:** if your `~/ProtonDrive` has files underneath, for example
saved there while the service was stopped, My files does not open until you move them out.
- **The sidebar no longer says "Proton Drive isn't running" while the service is running**
(B263, B277). When the service runs but cannot open My files, for example because its folder
has files in it, the status at the bottom of the sidebar and on the Sync page now reads
"Can't open your Drive" with the reason, like the page itself.
- **A synced folder stops instead of deleting when nearly all of it vanishes at once** (B270).
The guard against a lost folder, such as a disk that is not mounted, stopped a pass only when
every synced file was gone. One file left over was enough to clear it, and the next pass
trashed everything else on Proton Drive, and on every other computer syncing the folder. A
folder with 20 or more files and folders now also stops when nine in ten of them are missing,
on this computer or on Proton Drive. **Sync From Scratch…** brings them back.
- **My Files opens for accounts whose address keys depend on an inactive account key** (B265,
#40). The mount retried `missing key` forever. Address key tokens are now decrypted with every
account key the password opens, and one address whose keys do not open no longer breaks the
others.
- **A new folder starts its first sync at once** (B260, #53), instead of waiting until every
other folder was walked again. A folder watch that failed partway is retried every 10 minutes
instead of never (B262), and a folder removed and added again keeps its watch (B272).
- **A crash in one folder's sync no longer stops all syncing until a restart** (B259, #53). That
folder shows a sync error, and the next pass tries it again.
- **A folder reads "waiting for first sync" until its first sync finishes**, instead of "up to
date", on **Sync → Folders** and on the Computers page (B261, #53).
- **A large first sync goes on when one folder on Proton Drive fails to list** (B271, #46). It
used to start over from nothing. The folder that failed is tried again on the next pass, and
nothing under it is deleted.
- **A restore that skipped folders is reported as failed**, with the reason for each, instead of
"restored 0 folder(s)" as a success (B269).
- **`pdfs sync list` and `pdfs devices list` show when a folder last synced as a time**, such as
`synced: 11-01 22:07` in UTC like `pdfs activity`, instead of seconds since 1970 (B278, #46).
- **"You're offline" no longer appears for a cache or database error** when pinning, unpinning,
searching or loading a folder (B266, #38). The app shows the actual error.
- **A save no longer fails with "Input/output error"** when the file is opened for writing just
as its last change finishes uploading (B274).
- **`journalctl --user -u proton-drive.service -p warning` shows the service's warnings and
errors** (B258, #53). Every line was stored at info level, so the filter showed nothing.
- **Stopping the service no longer ends with a panic in the journal** (B276).
- **A failed mount attempt stops the workers it started** (B264). They kept running beside the
next attempt and held the database.
- **A new file reads without the network as soon as `pdfs status` shows nothing queued**
(B257). A new file's upload counted as done before its bytes were cached, so a script that
waited for an empty queue and then went offline could not read it. A new file deleted while
open in that moment could also lose the bytes its open handle reads, which made a write or
read through that handle fail with "Input/output error". The cache also removes leftover
tags of evicted files when it opens.
## [3.6.0] — 2026-10-27
Schema: **44**, unchanged.
+34 -1
View File
@@ -8,6 +8,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
what the commands do in the app, see the [user guide](USER_GUIDE.md).
- [Conventions](#conventions)
- [Shell completion](#shell-completion)
- [Account and service](#account-and-service)
- [Files](#files)
- [Available offline](#available-offline)
@@ -32,6 +33,36 @@ what the commands do in the app, see the [user guide](USER_GUIDE.md).
service.
- **Output** is English and stable enough for `grep`; use `--json` where it is available.
## Shell completion
Tab completes commands and options, and also the values the service knows. Paths complete
from My files, and these complete with a description: synced-folder and queue ids, computers,
trash items, albums, items shared with you, invitations, bookmarks, conflict copies, and a file's
versions or a share's members and link once the path is typed. Values come from the running
service. Without it, only commands, options and local paths complete.
| Example | Completes |
|---|---|
| `pdfs pin Doc<Tab>` | `Documents/`; Tab again lists its contents |
| `pdfs sync rm <Tab>` | `1 /home/me/Documents (mirror)` … |
| `pdfs devices rm <Tab>` | computer uids, with their names |
| `pdfs versions restore Documents/a.pdf <Tab>` | version ids, with date and size |
The packages install completion for bash, zsh and fish, which these shells load by themselves. For
a source build, or another shell, load the script that `pdfs completions <shell>` prints:
| Shell | Add to |
|---|---|
| bash | `~/.bashrc`: `source <(pdfs completions bash)` |
| zsh | `~/.zshrc`, after `compinit`: `source <(pdfs completions zsh)` |
| fish | `~/.config/fish/config.fish`: `pdfs completions fish \| source` |
| elvish | `~/.config/elvish/rc.elv`: `eval (pdfs completions elvish \| slurp)` |
| PowerShell | `$PROFILE`: `pdfs completions powershell \| Out-String \| Invoke-Expression` |
The script runs `pdfs` from `PATH` on every Tab, so it keeps working after an update. A
`pdfs` that is not on `PATH` prints a script for its own location with
`PDFS_COMPLETE=<shell> ./pdfs`.
## Account and service
| Command | Description |
@@ -71,7 +102,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). PATH can be under the Proton Drive mount or in an on-demand synced folder. A file in a mirror folder is already on this computer. |
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
| `pdfs pins` | List files and folders kept available offline. |
@@ -80,6 +111,7 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description |
|---|---|
| `pdfs sync add PATH` | Back up a local folder under this computer and keep it in sync. |
| `pdfs sync attach PATH --to REMOTE` | Merge a real local folder with an existing My files folder, keeping both versions on initial conflicts. |
| `pdfs sync list` | List synced folders with their IDs and state. |
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. |
| `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
@@ -184,6 +216,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). |
| `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. |
| `pdfs cache clear` | Delete cached file content. Files kept available offline stay. |
| `pdfs completions SHELL` | Print the [shell completion](#shell-completion) script for bash, zsh, fish, elvish or powershell. |
[Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
+1 -1
View File
@@ -76,7 +76,7 @@ fix it.
| Key | Type | Default | In the app | Meaning |
|---|---|---|---|---|
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted |
| `mountpoint` | path | `~/ProtonDrive` | General → Location | Where My files is mounted. Must be empty: the service does not mount over files |
| `cache_budget` | bytes | 5 GiB | Storage → Cache | Soft limit for cached content. `0` means unlimited. Available-offline files do not count against it |
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none |
| `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
+3 -1
View File
@@ -28,7 +28,9 @@ Yes. `~/ProtonDrive` is your Proton Drive, not a copy. A deleted file moves to t
Trash, and you can restore it from **Trash** in the app or the web. See [Trash](FILES.md#trash).
Stopping the service is not the same: once the service stops, `~/ProtonDrive` is just an empty
folder, and your files stay on Proton Drive.
folder, and your files stay on Proton Drive. Keep it empty: the service does not open My files
over a folder with files in it, because it would hide them. See
[My files does not open: the folder is not empty](TROUBLESHOOTING.md#my-files-does-not-open-the-folder-is-not-empty).
## Online only, available offline or synced folder: which do I want?
+1 -1
View File
@@ -102,7 +102,7 @@ That URL follows `main`. To stay on releases, pin the input to a release tag ins
the tag to update:
```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.8.0";
```
Each release tag is built with Nix before the release is published.
+60
View File
@@ -0,0 +1,60 @@
# SDK 0.11.0 review
Reviewed the sibling `../proton-sdk-rs` checkout at `84039a1` against 0.10.1
(`48dd410`), including its changelog and source diff.
## Features
- Folder creation, streaming uploads, draft replacement, and revision uploads
preserve the supplied timestamp's UTC offset and fractional seconds.
- `ModificationTime` is exported as an alias for
`chrono::DateTime<chrono::FixedOffset>`.
- Photo modification time can be set independently of capture time. Downloaded
claimed modification timestamps remain verbatim strings.
- Neither SDK crate defines Cargo feature flags. No feature opt-in is needed.
The Drive crate now depends directly on `chrono` 0.4; the SDK already used it.
The minimum Rust version remains 1.96, matching this workspace.
## Breaking changes and adaptation
The modification-time argument changes from `Option<i64>` to
`Option<ModificationTime>` on `create_folder`, `upload_file_from`,
`upload_file_replacing_draft_from`, `upload_new_revision`, and
`upload_new_revision_from`. `None` remains valid.
The real `DriveApi` adapter converts the client's stored epoch seconds to UTC
through `pdfs_core::timestamp::modification_time`. Internal storage, the trait,
and the simulated Drive continue using epoch seconds. Out-of-range timestamps
are omitted rather than panicking. Existing upload paths still supply whole
seconds; this bump alone does not add subsecond storage or offset tracking.
`PhotoUploadMetadata` gains `last_modification_time`, and the SDK no longer
copies `capture_time` into it. Our literals already use `Default`, so this would
compile while silently changing uploaded metadata. Control uploads, Takeout
imports (including the EXIF fallback), and redating now set both explicitly to
preserve the previous behavior when a capture time is known. Without a known
capture time, modification time stays omitted as before.
No other public API changes were found in the reviewed diff. No database or
recovery migration is required by this SDK bump.
## Dependencies
The workspace requires the published 0.11.0 versions of both crates from
crates.io. No local patches or sibling checkout are needed to build it.
## Verification
The published crates' source files match the reviewed sibling checkout at
`84039a1`. On the final branch based on `main`, these checks passed:
```sh
cargo test --workspace --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
```
The test suite reported 1,135 passed and 67 ignored; ignored tests were not run.
Tests ran outside the sandbox because control-socket tests require socket
permissions. No live account or mounted-filesystem acceptance testing was
performed.
+28 -1
View File
@@ -5,6 +5,7 @@ up to Proton Drive and kept in step with it. This page covers adding one, what t
that should not be backed up, changes that are still waiting to upload, conflicts, and pausing.
- [Add a folder](#add-a-folder)
- [Attach an existing My files folder](#attach-an-existing-my-files-folder)
- [Synced or online only](#synced-or-online-only)
- [Move files between synced folders and My files](#move-files-between-synced-folders-and-my-files)
- [Stop syncing a folder](#stop-syncing-a-folder)
@@ -74,7 +75,8 @@ again when they move into a synced one.
**Stop Syncing** in the app, or `pdfs sync rm ID`, keeps both the local files and the copy on
Proton Drive. To delete the copy on Proton Drive as well, tick **Also delete from Proton Drive**,
or add `--delete-remote`.
or add `--delete-remote`. Attached My files folders always keep the cloud copy;
`--delete-remote` is refused for them.
## Sync a folder from scratch
@@ -169,3 +171,28 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
`pdfs sync resume` continues at once.
- **Limit bandwidth** under **Preferences → General → Network**, or with
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
## Attach an existing My files folder
Run `pdfs sync attach /path/to/local --to Work` to merge a real local directory
with the existing `Work` folder in My files. `--to` also accepts an absolute path
inside the My files mount. Both folders must already exist.
The local directory stays a normal mirrored directory. The first pass copies files
found on only one side, adopts identical files, and keeps a conflict copy when
versions differ; it does not propagate deletions. Later passes use ordinary
bidirectional sync, including deletions and the existing wipe guards.
The My files root, shared folders, overlapping sync roots, and local paths inside
or containing the cloud mount are refused. Use `pdfs sync list` to find the new
folder's ID and monitor its state. `pdfs sync rm ID` stops syncing and keeps both
copies. `--delete-remote` is refused for attached folders.
Attached folders support **mirror** mode only. Use the main My files mount for online-only
access. They are listed separately from device backups and are not included in the device
profile used to restore another computer.
On the first sync, the remote version keeps the original name when both versions differ.
The local version is preserved as a conflict copy next to it and is uploaded too. If the
remote root is trashed or moved outside My files, syncing stops with an error and local
files are kept.
+1 -1
View File
@@ -246,7 +246,7 @@ remaining cases are skipped. Cases that make many round trips by design get twic
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
| `--journal-check` | Fails the run if `proton-drive.service` logged an error or a panic during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time". A `Failed to send FUSE reply: No such file or directory` during a stop does not count: it is a request answering after the stop aborted its connection |
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
| `--fail-fast` | Stops at the first failure |
| `--list` | Prints every case and its targets |
+18 -3
View File
@@ -141,6 +141,20 @@ fusermount3 -uz ~/ProtonDrive
systemctl --user restart proton-drive.service
```
### My files does not open: the folder is not empty
The app says **Your Proton Drive Folder Isn't Empty**, and `pdfs diagnose` reports
`mount: failing … is not empty`. The folder chosen for My files has files in it, and the service
does not open My files over them: they would be hidden, and not uploaded, for as long as the
service runs.
Move the files out of the folder, or choose another folder in **Preferences → General →
Location**. The service tries again on its own and opens once the folder is empty. To put the
files on Proton Drive, copy them into My files once it is open.
Files saved into `~/ProtonDrive` while the service was stopped are in the same place: in the
local folder, not on Proton Drive.
### The folder is empty or missing
Check that the service runs and is signed in, with `pdfs status`. The service waits for a stored
@@ -234,9 +248,10 @@ Check that:
### A synced folder stays in error and nothing downloads
The log says `every one of the N synced paths is missing locally; refusing to trash them on
Drive`. The folder synced before, and now none of its files are on this computer. The sync
stops so that it does not delete them on Proton Drive too. Check that the folder is the right
The log says `M of the N synced paths are missing locally; refusing to trash them on Drive`.
The folder synced before, and now all or nearly all of its files are gone from this computer:
everything, or nine in ten of a folder with 20 or more files and folders. The sync stops so that
it does not delete them on Proton Drive too. Check that the folder is the right
one and that its disk is mounted. To get the files back, choose **Sync From Scratch…** in the
folder's menu on **Sync → Folders**, or run:
+4 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=3.6.0
pkgver=3.8.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
@@ -80,5 +80,8 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh "$rel/pdfs" "$pkgdir/usr/share"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.6.0
pkgver = 3.7.0
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -22,21 +22,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0
provides = proton-drive-linux=3.6.0
provides = proton-drive-for-linux=3.7.0
provides = proton-drive-linux=3.7.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git
options = !strip
options = !debug
source = proton-drive-linux-3.6.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.6.0/proton-drive-linux-3.6.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.6.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.6.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.6.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/packaging/proton-drive.service
source = LICENSE-3.6.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.6.0/LICENSE
sha256sums = 8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7
source = proton-drive-linux-3.7.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.7.0/proton-drive-linux-3.7.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.7.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.7.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.7.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/packaging/proton-drive.service
source = LICENSE-3.7.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.7.0/LICENSE
sha256sums = 3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin
pkgver=3.6.0
pkgver=3.7.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64')
@@ -36,7 +36,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('8e9b76b0df238af1c8978c33b4c27c0c548e1faa81b368d0d271ed564d0a76c7'
sha256sums=('3f0d16c19b10b30e3f181e3c7fb439f0a8c38980e8ab7a4a96ae6596b349787c'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -64,6 +64,14 @@ package() {
install -dm755 "$pkgdir/usr/share"
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish, written by the binary itself.
local share="$pkgdir/usr/share"
install -d "$share/bash-completion/completions" "$share/zsh/site-functions" \
"$share/fish/vendor_completions.d"
"$srcdir/pdfs" completions bash > "$share/bash-completion/completions/pdfs"
"$srcdir/pdfs" completions zsh > "$share/zsh/site-functions/_pdfs"
"$srcdir/pdfs" completions fish > "$share/fish/vendor_completions.d/pdfs.fish"
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
# pacman brings new releases, so the app never checks for them itself.
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.6.0.r0.g2720191
pkgver = 3.7.0.r0.g7ab14bb
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -25,8 +25,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.6.0.r0.g2720191
provides = proton-drive-linux=3.6.0.r0.g2720191
provides = proton-drive-for-linux=3.7.0.r0.g7ab14bb
provides = proton-drive-linux=3.7.0.r0.g7ab14bb
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git
pkgver=3.6.0.r0.g2720191
pkgver=3.7.0.r0.g7ab14bb
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64')
@@ -81,6 +81,9 @@ package() {
# Translations: compiled from po/ into /usr/share/locale.
po/build.sh "$pkgdir/usr/share/locale"
# Shell completion for bash, zsh and fish.
packaging/completions.sh target/release/pdfs "$pkgdir/usr/share"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.6.0
pkgver = 3.7.0
pkgrel = 1
url = https://proton-drive.narl.io
install = proton-drive.install
@@ -24,13 +24,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.6.0
provides = proton-drive-linux=3.7.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git
options = !lto
source = proton-drive-for-linux-3.6.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.6.0.tar.gz
sha256sums = d9b16b1027816a379d6a11c6396d779f751a6f94becaa16b17439420f561d13d
source = proton-drive-for-linux-3.7.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.7.0.tar.gz
sha256sums = 542c534976e335cde6658de127e1eefffade9057d5332fd33457bc371c7a2737
pkgname = proton-drive-for-linux
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more