Compare commits

...
106 Commits
Author SHA1 Message Date
nvrl 27201914ee Merge pull request #51 from narrrl/fix/emptied-folder-direct-switch
fix(fuse): let an emptied mirror folder switch to on-demand at once
2026-10-27 12:05:50 +01:00
nvrl 2775f0681d test(acceptance): count a purge of a listed trash entry that then vanishes
The reaper found an abandoned root in the trash and sent the delete. The
reply did not confirm it, and the next listing no longer had the node,
but the retry loop only counted deletes it sent itself. It waited for a
node that was already gone, reported it as never trashed, and the
leftover check stopped the run before any case ran.

Callers that just found the node in the trash now say so, and the error
of the first delete is kept for the warning.
2026-10-27 01:22:07 +01:00
nvrl 97277d41dd fix(fuse): let an emptied mirror folder switch to on-demand at once
A switch asked for before any pass saw the folder empty found it still
idle, so the check for what only this computer has ran at once and
refused it for good: every synced path read as "deleted here but not on
Proton Drive yet". The account run hit it in the B255 case.

A local copy gone in full is the wipe guard's case, which the push pass
of a queued switch settles without trashing anything on Drive. The
switch now returns NotNow for it, so the request is queued and that pass
runs first. A partial deletion is still refused as before.
2026-10-27 01:22:07 +01:00
nvrl d49738bf0b Merge pull request #50 from narrrl/test/size-batch-clock-before-spawn
test(fuse): start the size batch clock before the worker
2026-10-27 01:03:49 +01:00
nvrl 48945b9c97 Merge pull request #49 from narrrl/test/b256-case-own-folder
test(acceptance): give the B256 case a copy folder of its own
2026-10-27 01:03:40 +01:00
nvrl b860ac3b49 test(fuse): start the size batch clock before the worker
The worker thread's sleeps could begin before the test read the clock, so the elapsed time came out a few milliseconds under the 90 ms bound on a busy runner. Reading the clock first makes the bound hold.
2026-10-27 01:02:29 +01:00
nvrl 42f3a18177 test(acceptance): give the B256 case a copy folder of its own
Every case of a run shares one root, and the pdfs copy case before it
had already made "copies" there, so the B256 case failed on its mkdir
with EEXIST before it checked anything.
2026-10-27 00:56:35 +01:00
nvrl 6f504d2e83 Merge pull request #48 from narrrl/test/acceptance-new-features
3.6.0: acceptance cases for the new features, --json for members/du/public-link
2026-10-27 00:44:30 +01:00
nvrl 8c1c37a928 test(acceptance): cover copy, folder size, link updates and sync recovery
New cases for what 3.6.0 adds:

- copy_file_range over several calls, into the middle of a file, past
  its end, at the source's end, and from a file whose writes are not
  released yet; on a live mount also read back from Drive.
- pdfs copy of a tree and of a file beside itself, read back as listed
  and after a refresh, with nothing queued; refused while a file has
  writes Drive lacks, and never the version before a save (B256).
- pdfs du counts every item below a folder, trash included.
- pdfs public-link update keeps the URL and the options it was not
  asked to change, and refuses a link id that is not the folder's.
- In a sync folder of their own: Sync From Scratch changes nothing that
  already matched, and an emptied mirror folder still switches to
  on-demand with every file intact on Drive (B255).

docs/TESTING.md gains a checklist for the new screens in the app. The
CHANGELOG moves these into 3.6.0, which is not tagged yet.
2026-10-27 00:42:44 +01:00
nvrl e4d2340bbf feat(cli): --json for members, du and public-link create/update
Scripts, the acceptance suite among them, can now read a link's id,
role, password flag and expiry, and a folder's size and item count,
instead of parsing the human-readable lines.
2026-10-27 00:42:44 +01:00
nvrl c1b72e455e test(fuse): time the short-outage reconnect from the mount coming back
The probe that sees the link return runs 2 s after the drop, then 6 s.
A loaded run could turn the link back on after the first probe, and the
next one came after the test's 3 s were up. The test now checks that the
drain takes the mount offline within 2 s, which is the B189 fix itself,
and counts the 3 s from the mount reporting it is back online.
2026-10-27 00:32:49 +01:00
nvrl 2e3b7748e6 Merge pull request #47 from narrrl/feat/server-copy
Release 3.6.0: server copy, link settings, folder size, sync fixes (#46)
2026-10-26 22:47:47 +01:00
nvrl 7f4f11ef18 chore(release): 3.6.0 2026-10-26 22:45:36 +01:00
nvrl cc9f7c6d8a Merge remote-tracking branch 'origin/main' into feat/server-copy
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-10-26 22:41:40 +01:00
nvrl 9add185c1f Merge pull request #43 from narrrl/feat/diagnose-keys
feat(cli,core): trace the keys that open My Files in pdfs diagnose
2026-10-26 22:35:35 +01:00
nvrl 0e7ffe1261 fix(gui,cli): count what a folder's first scan has checked (#46)
The first pass over a synced folder has no baseline to estimate from, so
its scan showed only "checking for changes…" in the app and "scanning…"
in `pdfs sync list`. For a large folder that scan reads every item on
Proton Drive before anything downloads, which takes many minutes and
looked stuck. Both now show how many items the scan has checked so far.
2026-10-26 22:28:46 +01:00
nvrl a5acae41b3 fix(sync,gui,cli): get a synced folder unstuck when its local copy is gone (#46)
A mirror folder whose files were all gone from this computer stayed in
error for good. The wipe guard rightly refuses to trash the whole folder
on Drive, but nothing got the folder out of that state: a queued switch
to on-demand waits for a pass that ends idle, and its push pass hit the
same guard. Only removing the folder from sync cleared the baseline that
made the empty folder read as "delete everything".

- The push pass for a switch to on-demand forgets the baseline rows of a
  local copy that is gone in full instead of trashing them, so the
  switch goes ahead and the mount shows what Drive has.
- New Request::ResetSyncFolder, `pdfs sync reset ID` and "Sync From
  Scratch…" in a synced folder's menu clear a mirror folder's baseline
  (and a queued switch to on-demand, whose push pass would upload a
  duplicate of every file). The next pass compares both sides as on the
  first sync: one-sided files are copied across, files that differ keep
  both versions, nothing is deleted.
- The wipe guards' messages name the reset; restore logs why it skipped
  a folder.

Two simulations cover it. The on-demand one failed in exactly the
reported state (mirror, pending ondemand, error) before the fix.
All 8 catalogs translated.
2026-10-26 17:45:22 +01:00
nvrl ea10a43216 fix(fuse): a copy right after a save waits for the edit (B256)
close(2) returns before the kernel sends release, and release takes the
write handle out of the open writes a few milliseconds before the
revision is in the queue. A `pdfs copy` checked in that gap found the
edit nowhere and copied the version Drive had. A folder copy looked only
at the queue, so it also missed files below it still open with unsaved
writes.

release now keeps the inode in State::releasing until the revision is
queued. A copy refuses a file or folder with unsaved writes, or a
release still queueing, at it or below it, and a move checks the same
set.
2026-10-26 17:45:14 +01:00
nvrl 917e7268f9 build(deps): proton-sdk and proton-drive-rs 0.10.0
0.10.0 adds copy_node, get_folder_size, update_public_link and a link's custom password.
2026-10-26 17:03:33 +01:00
nvrl 9089c8d13c feat(fuse): answer copy_file_range from bytes already on this machine
A copy inside the mount went through the kernel's read/write loop, one
FUSE round trip per page, because copy_file_range answered ENOSYS. The
daemon now copies a range whose source bytes are local (an open write
handle, a queued write, the cache) into the destination's scratch file,
the same way write stages them. A range it would have to download gets
EOPNOTSUPP, which makes the kernel copy that one call itself and keeps
the operation enabled. The copy still uploads: Drive cannot copy into a
file cp has already created.

In the simulation, a warm cp of a 256 MiB file went from about
100 MiB/s to about 450 MiB/s (debug build).
2026-10-26 16:57:06 +01:00
nvrl 071a11ef65 Merge remote-tracking branch 'origin/main' into feat/diagnose-keys
# Conflicts:
#	docs/CHANGELOG.md
#	docs/TROUBLESHOOTING.md
2026-10-26 16:45:36 +01:00
nvrl 4edcff3f87 feat(sharing,gui,cli): change public links and show folder sizes
The share dialog gets Link settings under an existing link: role, password
and expiry apply at once and keep the URL. The daemon fills in the options a
change keeps from the link as it stands, and refuses to keep a password it
cannot read rather than dropping it. pdfs public-link update does the same
from a terminal, and pdfs members shows the expiry.

Details shows a folder's size and item count as Proton Drive counts it,
trash included, and pdfs du prints it. Both are refused offline and for a
folder that is not on Drive yet.

Needs proton-drive-rs with update_public_link, get_folder_size and
PublicLink::custom_password.
2026-10-25 22:18:11 +01:00
nvrl ee59e338b6 feat(gui): make a copy and copy to another folder in My files
The entry menu gains Make a Copy, which copies an item next to itself, and
Copy To…, which reuses the move folder picker limited to My files; Copy
To… is also in the selection menu. Both send Request::Copy, so the copy is
made on Proton Drive.

The move and copy reports share one request loop. Docs describe the new
actions, and the changelog notes that saved public links now show their
names once the SDK decrypts them.
2026-10-25 21:41:21 +01:00
nvrl c1d9538669 feat(cli,fuse,core): copy files and folders on Proton Drive
`pdfs copy PATH... NEW_PARENT` sends a new control request, Copy, which
copies a file with its content or a folder with its subtree on Proton
Drive through proton-drive-rs's copy_node. Nothing is downloaded or
uploaded. A name already taken in the destination gets the next free
" (n)" suffix through get_available_name.

A copy cannot wait in the queue, because Drive copies what it holds.
So it is refused offline, and while the mount still owes Drive part of
the node: a local placeholder, a queued op, unsaved writes, or, for a
folder, any queued op at all. Local-first does not change this.

DriveApi gains copy_node and get_available_name, and the fake drive
copies subtrees, so a FUSE sim test covers the copy, the suffix, the
folder subtree and both refusals. The activity feed logs a new Copy
kind, translated in every catalog.

Needs the proton-drive-rs release that adds copy_node; until then, build
with a [patch.crates-io] pointing at proton-sdk-rs.
2026-10-25 21:10:50 +01:00
nvrl a73ed34421 Merge pull request #45 from narrrl/docs/wiki
docs: publish docs/ to the GitHub wiki and reorganise the user guide
2026-10-17 14:06:46 +02:00
nvrl 9c30ff8276 docs: publish docs/ to the GitHub wiki and reorganise the user guide
- scripts/publish-wiki.sh publishes every page listed in SECTIONS, copies
  images, writes a sectioned sidebar and footer, and removes stale pages.
  --check fails on links to missing files or headings and on docs pages
  that are neither published nor excluded.
- New wiki.yml runs the check on pull requests and pushes to main, and
  publishes when release.yml calls it or when started by hand.
- Split USER_GUIDE.md into topic pages (files, synced folders, photos,
  sharing, computers, launcher and tray); add Getting started and FAQ.
- Make the COPR repository the main Fedora path, with update and
  uninstall steps for every package type.
- Fix the stale CAPTCHA note in CLI.md, document pdfs unlock and lock,
  and drop the broken AGENTS.md link from CONTRIBUTING.md.
2026-10-17 13:57:55 +02:00
nvrl d1d4d23dd5 Merge pull request #44 from narrrl/automate-updates
feat: COPR repository and opt-in update check
2026-10-17 13:13:49 +02:00
nvrl f717a692ff fix(packaging): COPR project belongs to narl-rs
The COPR project lives under the FAS account narl-rs, not narrrl.

Refs #42
2026-10-17 12:38:09 +02:00
nvrl e065586d7e build(deps): proton-sdk and proton-drive-rs 0.9.2
0.9.2 adds the key report pdfs diagnose uses.
2026-10-17 12:23:19 +02:00
nvrl fd80c34152 feat(cli,core): trace the keys that open My Files in pdfs diagnose
When the session is stored and unlocked but the daemon does not answer,
pdfs diagnose asks Proton for a key report and prints a Keys section:
each account key, each address key and the account key its token is
for, and the address key the My Files share is encrypted to. It holds
key IDs only, so it can be posted in an issue.

Refs #40
2026-10-17 12:13:27 +02:00
nvrl e66308498b feat(gui,core): opt-in check for new releases
Preferences gets a switch that asks GitHub once a day for the latest
release and shows a banner over the sidebar when it is newer. Off by
default. Packages whose package manager brings updates install the
marker share/proton-drive-linux/managed-updates, which hides the switch.

Refs #42
2026-10-17 11:45:09 +02:00
nvrl 29b87ece72 feat(packaging): COPR repository for Fedora
The spec builds from the tag archive and a vendored crates tarball when
git_dir is not defined, so COPR can build it without network access.
release.yml publishes the vendor tarball and submits the build to COPR
after the release is published.

Refs #42
2026-10-17 11:44:33 +02:00
Nils Pukropp fac8a144e5 chore(aur): update packages to 3.5.0 2026-10-16 17:29:08 +00:00
nvrl eb25c16fe6 Merge pull request #41 from narrrl/release/3.5.0
Release/3.5.0
2026-10-16 19:18:13 +02:00
nvrl 8f771fdcc4 fix(fuse): read the size again for a baseline row without a revision id
A mirror upload that Drive did not list yet when it was read back left a
baseline row with no revision id. The next walk reused the recorded size
whenever the modification time matched, and a row without an id never
counts as moved. So a revision another computer saved in the same second
looked unchanged and never arrived (B254). The B250 simulation hit this
about one run in five on CI.

A row without an id now reuses its size only while Drive gives no id
either. Otherwise the walk reads the size, and an unchanged file gets
Drive's revision id written into its row, so each row costs one extra
read. Rows from before schema 44 are read once after upgrading.
2026-10-16 18:44:51 +02:00
nvrl c1911ea1de docs: window size, keyring banner and the mailbox password per session
Add the window size and the narrow layout to the 3.5.0 changelog with
B253, document the `window` config field, and explain in the
troubleshooting guide why a two-password account asks for its mailbox
password after every reboot, and how to keep it unlocked.
2026-10-16 17:21:46 +02:00
nvrl 179b6555c4 fix(gui): keep a saved sign-in when the keyring can't be read
The app read the session with `auth::load().ok()`, so a locked keyring, a
Secret Service that was not up yet or any D-Bus error showed the sign-in
form as if nobody were signed in. The session was still saved (B253).

Keyring errors are now kept apart from "not signed in". The login page
shows a banner saying the saved sign-in could not be read, with a Retry
button. The app tries again after 2, 5, 10 and 30 seconds and opens on
Files once the keyring answers. The login page's header also loses the
back button the collapsed split gave it.

Translated into every shipped language.
2026-10-16 17:21:46 +02:00
nvrl e6971dd702 fix(gui): let the main window shrink to fit small screens
While signed in, the sidebar never collapsed. The window could not get
narrower than the sidebar plus the widest page, about 950 pixels, so on a
small or scaled screen the minimize, maximize and close buttons ended up
off-screen.

- The page stack is no longer homogeneous, so only the page on screen sets
  the minimum width.
- The selection bars of My files and Photos scroll sideways instead of
  holding the window at their full width, even while hidden.
- Below 760sp the sidebar and the page take turns, with a back button,
  and the My files search entry narrows. Picking a sidebar row opens the
  page.

Every page, in English and in German, now fits in a window 480 pixels
wide with its window buttons visible.
2026-10-16 17:21:46 +02:00
nvrl 770d4fda38 feat(core,gui): remember the main window's size
The app always opened at 980x680. It now keeps the unmaximized size and
whether the window was maximized in config.json under `window`, written
when the window closes and when the app quits with it still open. A
missing field, or a saved size below 480x400, opens at the old default.
2026-10-16 17:21:46 +02:00
nvrl 11d03091a7 docs: syncing several computers into one device; changelog for 3.5.0
Add B248 to B252 to the bug ledger, the shared-device feature and schema 44
to the 3.5.0 changelog, and describe the join flow, per-machine profiles,
conflict copy names and the mirror's multi-writer rules in the user guide,
recovery guide, CLI and configuration references and the architecture notes.
2026-10-16 16:18:14 +02:00
nvrl 7a0f1ebd6f feat(cli,gui): sync with another computer's device
Two or more computers can now sync the same folders through one device in
Proton Drive. `pdfs devices join UID` and "Sync With This Computer…" on
another computer's row adopt that device as this machine's and then offer its
folders for syncing here, like a restore. Unlike "Continue This Backup Here",
the other computer goes on syncing, and the confirmation says that changes
and deletions on one reach the other.

Translated into every shipped language.
2026-10-16 16:04:26 +02:00
nvrl 950056b48d feat(core,fuse): one profile per machine on a shared device
Every computer that synced into one device wrote the same profile.json, so
each save replaced the other computers' folders with its own (B252). Each
machine now writes profile-<machine id>.json, where the id is random, made on
first use and kept in machine-id next to config.json. No host identifier goes
to Drive.

Restore reads every profile in .proton-drive-linux, including the shared one
older clients wrote. A folder's proposed path and mode come from this
computer's own profile, otherwise from the newest one that has the folder.
The legacy file is left as it is, so a client before 3.5.0 still finds it.
2026-10-16 14:55:13 +02:00
nvrl 05b84bf796 fix(fuse,core): keep a mirrored folder safe when another computer syncs it
A mirrored folder assumed it was the only client writing to its folder on
Drive. With a second computer syncing the same folder, edits could be lost:

- The first sync took a same-size file at the same path for the same file.
  It now records the baseline only if the local file's SHA-1 matches Drive's
  digest, and otherwise keeps a conflict copy (B248).
- An upload replaced a revision another client had committed since the pass
  looked. The upload now checks Drive's active revision first, and an edit
  that read back with someone else's digest is left for the next pass to keep
  as a conflict copy (B249).
- A download recorded a revision committed while it ran as synced, so that
  edit never arrived. The baseline now records the revision the pass planned
  to download (B250).
- Conflict copies now carry the computer's hostname, so two computers do not
  make the same name on Drive (B251).

The baseline gains the revision id it was synced at (schema 44), and a new
revision counts as a remote change even at the same mtime and size.
2026-10-16 14:48:32 +02:00
nvrl 7cd516a164 chore(release): 3.5.0 2026-10-16 12:58:06 +02:00
nvrl c3c536892e docs(changelog): add B245 to B247 2026-10-16 12:31:51 +02:00
nvrl cc9db51b78 fix(gui): translate the tray's transfer status lines (B247) 2026-10-16 12:31:40 +02:00
nvrl 7384d8e97d fix(gui): read the new session off the main thread after sign-in (B246) 2026-10-16 12:31:40 +02:00
nvrl 4bf176a631 fix(packaging): accept watchdog pings from the daemon only (B245) 2026-10-16 12:17:53 +02:00
nvrl aa0c60f5a2 docs(changelog): list the second round of 3.5.0 fixes 2026-10-16 12:14:17 +02:00
nvrl 47934b7ddb fix(core,fuse,gui): date photos by their EXIF when nothing else does
A Takeout photo with no sidecar and no date in its name was filed under
the day of the import, and the app dated uploads by modification time.
A new `exiftime` module reads DateTimeOriginal (then DateTimeDigitized)
with its offset. Takeout uses it for undated photos it copies out for a
thumbnail, and the daemon uses it for app uploads before falling back to
the modification time (B244).
2026-10-16 12:12:41 +02:00
nvrl 48c8910e7b fix(gui,core): ask the mount off the main thread when opening, reap children
Opening a file or a location stat'ed the mount on the GTK main thread, so
a hung mount hung the app. The browser now uses the launcher's bounded
worker check (`present_within`, 2 s), and opening a location runs on a
thread. Every child the GUI starts is also waited for on a thread, so it
no longer stays a zombie (B243).
2026-10-16 12:07:48 +02:00
nvrl 6ec8043934 fix(gui): decode viewer photos and read their EXIF off the main thread
The viewer decoded each full-size photo, read its EXIF and stat'ed it on
the GTK main thread, freezing the app for a large photo. That now runs in
gio::spawn_blocking, and a result for a photo the user has left is
dropped (B242).
2026-10-16 12:04:05 +02:00
nvrl 99707c48fa fix(gui): free the photo viewer and the share dialog when they close
The viewer's handlers held strong references to the viewer and its
window, which own them, so every closed viewer stayed in memory with its
full-size photo. The share dialog's row handlers had the same cycle with
the dialog state. The handlers now hold weak references, and the viewer's
one strong handle is dropped on close with the photo (B241).
2026-10-16 12:03:02 +02:00
nvrl 5557aa66d5 ci: run the simulations on pull requests and lint with --locked 2026-10-16 11:58:56 +02:00
nvrl 6456f503f1 fix(cli,core): keep secret-holding commands out of core dumps
Only `pdfs daemon` marked itself non-dumpable, though `mount`, `login`,
`unlock` and `logout` hold the same passphrases or tokens. All five now
do. The file-manager script also passes each path after `--`, so a file
named like an option stays a path (B240).
2026-10-16 11:58:28 +02:00
nvrl 4836c5a84a fix(cli,fuse,gui): make --wait fail when an import or re-date did
`import-google-photos --wait` and `redate-photos --wait` exited 0 however
the run ended, and a run that failed as a whole left no summary, so the
CLI said no import had run and the app said it finished. Failed runs now
leave a summary with the reason, `--wait` exits 1 on failure, cancel or
failed photos, and the app shows the reason (B239).
2026-10-16 11:56:22 +02:00
nvrl 937a0a6824 perf(core): index queued ops by kind and name (schema 43)
The name checks the drain makes before each create, rename and trash,
and the wake of ops waiting for a name, read the whole queue, which made
a large offline batch quadratic to drain. Schema 43 adds an index on
pending_op(kind, name) (B238).
2026-10-16 11:50:56 +02:00
nvrl fcbb15cb4b fix(core,fuse): back failed uploads off properly and spread their retries
A reconnect made every failed op due at once, quota and access refusals
included, and ops that failed together retried in the same second. The
doubling also stopped at 128 s, short of the 300 s maximum. Reconnects
now move up only unrefused ops, and the backoff reaches its maximum with
a per-op spread of up to a fifth (B237).
2026-10-16 11:48:15 +02:00
nvrl 8aca7836f9 fix(fuse): upload thumbnails with imported, re-dated and app photos
Takeout imports, re-dates and the app's photo upload all sent an empty
thumbnail list, so the photos showed blank in Proton's apps. Re-dates and
app uploads now make thumbnails from their file. A Takeout photo that can
have one is copied out of the archive to the private staging folder,
thumbnailed and uploaded from the copy (B236).
2026-10-16 11:46:17 +02:00
nvrl 60a5a98174 perf(core): find Takeout sidecars by prefix lookup, not by ranking all
Each photo was ranked against every sidecar in its folder, so a folder
of 10,000 photos took about 30 s to scan. The sidecars are now indexed
by stem and each photo is ranked only against the stems a few range
lookups return: 0.1 s for the same folder, with the same matches (B235).
2026-10-16 11:41:02 +02:00
nvrl 51800c77d7 fix(core,fuse): refuse sync roots that overlap home, the mount or app data
Nothing stopped `/`, the home folder, the Proton Drive mount, the app's
own state or cache, or a folder nested in another root from becoming a
sync root, and a restore took a path from another machine's profile as
it was. One validator, `syncroot::problem`, now runs where roots are
added and restored, on paths resolved through symlinks with `..`
refused (B234).
2026-10-16 11:30:40 +02:00
nvrl 518763c5c9 fix(core,cli): end the session on Proton when signing out
Logout only deleted the keyring entry, so the session's tokens stayed
valid on Proton's side. It now ends the session first (DELETE auth/v4,
bounded to 10 s) and signs out locally either way. `pdfs logout` says
when Proton could not be reached (B233).
2026-10-16 11:26:29 +02:00
nvrl ef0f046939 fix(gui): show remote names as text, not Pango markup
Rows, toasts and status page descriptions parse their text as markup by
default. Shared item names, inviter addresses, device names, link URLs,
paths, EXIF values and daemon errors went in unescaped, so a name with
`&` showed as an empty row and other names were drawn as markup. Those
rows and toasts now use plain text, and status page descriptions are
escaped (B232).
2026-10-16 11:24:43 +02:00
nvrl 519ba0591a fix(fuse): give uploads under a low cap the time their share needs
The upload deadline assumed at least 32 KiB/s. Under an upload cap shared
by every drain worker and sync task, a large file ran out of time on every
attempt, took the mount offline and restarted from byte 0. The deadline
now counts the upload's share of the cap as the slowest rate when that
share is lower (B231).
2026-10-16 11:21:57 +02:00
nvrl 42b2f69fa0 fix(core,fuse): keep queued writes durable and their retries their own
- A gap-filled revision blob is recorded as complete once filled, so a
  retry after a lost upload answer recognises its own bytes on Drive
  instead of forking a conflict copy (B228).
- Commits that accept a change into the queue run at synchronous=FULL,
  so an op `close()` reported as done survives a power cut (B229).
- A queued create's adoption window starts at its first send, not when
  it was queued, so a create queued offline no longer takes another
  device's empty node of the same name. A send record that does not
  parse is kept instead of overwritten (B230).
2026-10-16 11:21:48 +02:00
nvrl 0204f69e9e docs(changelog): list the 3.5.0 fixes under Unreleased 2026-10-16 11:07:23 +02:00
nvrl d4ef9116c8 fix(fuse): download the base of a file open for writing on a worker
A read on a write handle was answered on the dispatch loop to keep its
order with the handle's writes, and when the bytes it asked for were
neither written nor cached it downloaded them there, holding every request
of the mount for up to the read deadline. The loop now answers from the
scratch file and the local caches only, and hands a read that needs a
download to a transfer worker with the handle as it was when the read came
in (B227).
2026-10-16 10:47:58 +02:00
nvrl f8c60c9753 build: add a release profile that strips the binaries
No package ships a separate debug package, and the unstripped binaries were
43, 24 and 12 MB. LTO stays off, as the packaging comments already said the
release profile kept it, and unwinding stays on for the workers that catch
panics.
2026-10-16 10:32:05 +02:00
nvrl 607972cf1e fix(fuse): turn generated thumbnails the way their EXIF orientation says
Thumbnails were decoded without the EXIF orientation, so a portrait photo
stored as landscape pixels plus a tag got a sideways tile, and the drain
uploaded that thumbnail to Drive for every client to show (B226).
2026-10-16 10:32:05 +02:00
nvrl 96b70600f0 fix(core): match a Takeout sidecar by prefix only where Google cut its name
The prefix rule for sidecars whose names Google cut at 51 characters also
matched names that were never cut, so IMG_1.jpg's sidecar gave its title
and date to an IMG_12.jpg that had none. It now applies only to a sidecar
name at the cap. Edited copies, which have no sidecar of their own, take
their original's date by a rule of their own and keep their own name
(B225).
2026-10-16 10:32:05 +02:00
nvrl b0ff6ee441 fix(core): never write defaults over a config file that does not parse
load_config gives defaults for a file it cannot read, and every setter
loads, changes one field and saves. The first settings change after the file
was damaged therefore wrote defaults over the mountpoint, the adopted device
and the ignore rules. save_config now refuses while the file on disk does
not parse, and the first-run default is written atomically with mode 0600
(B223).
2026-10-16 10:32:05 +02:00
nvrl bca22056b0 fix(fuse): report write errors at close, and take fsync and fallocate as POSIX does
flush answered ok at once and release synced the scratch file, but close(2)
only returns what flush answers, so a full disk at close was reported to
nobody. flush now syncs a handle with writes on a worker and returns ENOSPC,
EDQUOT or EIO; release then finds the pages on disk instead of writing a
whole file back on the dispatch loop. fsync on a descriptor opened for
reading no longer fails with EBADF (B222).

fallocate passed any mode through and tracked only a punched hole or
growth: a zeroed range came back from the remote base at commit, and a
collapse or insert corrupted the file. Unsupported modes now get EOPNOTSUPP,
zeroed ranges count as written, the real errno is returned, and the state
lock is not held across the syscall (B224).
2026-10-16 10:32:05 +02:00
nvrl 830cfd9faa fix(fuse): allocate a listing's inodes before taking the state lock
A cold listing allocated the local id of every new child in a write
transaction while it held the global state lock, then marked the folder
listed, still under it. Every inline handler of every mount waited on that
transaction, and the transaction on the write connection the drain shares.
The ids are now allocated before the lock is taken, and the folder is marked
listed through the outbox after its children's rows, so a crash in between
no longer leaves it recorded as complete and empty (B221).
2026-10-16 10:05:58 +02:00
nvrl 77dac97afe fix(fuse): keep slow remote calls off the dispatch loop and the metadata workers
A shared folder's listing is fetched again once it is a minute old, and that
fetch ran on the dispatch loop whenever the old listing was still resident,
so every request on the mount waited for Drive (B219).

Thumbnail downloads, size upgrades and the Shared with me listing had no
deadline of their own. On a stalled link each held a mount thread for the
SDK's retry budget, and a folder of photos filled the three metadata workers
that every cold lookup and rename needs. Offline, an uncached thumbnail is
now answered as absent, and these calls give up after 20 or 60 seconds
(B220).
2026-10-16 10:02:35 +02:00
nvrl 2bea1f7672 fix(core): check and count the database on a read connection
PRAGMA integrity_check and the row counts behind pdfs cache inspect read
the whole file, 34 s on a 567 MiB database, and they held the write
connection while they did. Every write in the daemon waited behind them.
They now read a WAL snapshot on a pooled read connection (B218).
2026-10-16 09:56:25 +02:00
nvrl 0f279c7536 fix(core,fuse): compact the search indexes and stop pruning after partial scans
FTS5 keeps deleted entries until their segments are merged, which with
these indexes is close to never. On one machine local_fts held 317 MiB for
40,000 paths, against 20 MiB rebuilt, and search ran four times slower. The
indexes are now compacted once a day after a local scan. A scan that hit the
entry cap or lost a batch no longer prunes the paths it never reached, which
the next scan used to add back (B217).
2026-10-16 09:56:13 +02:00
nvrl d6bc921a4b fix(core): keep the upload queue when a damaged database is rebuilt
A database SQLite reported as corrupt at open was moved aside and replaced
by an empty one, on the grounds that everything in it is fetched again. The
queue of writes Drive has not received, the mirror baselines and the pins
are not, so they were lost. They are now copied out of the damaged file,
read only and table by table, into the new one (B216).
2026-10-16 09:50:53 +02:00
nvrl 3221e4aa97 fix(fuse): mirror sync no longer deletes what Drive or this disk never had
Five ways a mirror pass could lose files:

- A folder deleted on Drive was removed here with everything in it, including
  new files and edits whose uploads had just failed for want of a parent, and
  ignored content such as .git. Such a folder now stays and is created on
  Drive again (B211).
- A node the SDK could not decrypt was missing from the remote walk and so
  read as deleted on Drive. A short listing or an unreadable size now leaves
  the affected paths out of the pass, and a pass in which no synced path is
  left on Drive is refused (B212).
- A file Drive deleted was removed here even when it had been edited since
  the scan. It is now checked again first, and kept (B213).
- A folder deleted here was trashed on Drive although another device had just
  put new files in it. It now stays on Drive (B214).
- A decrypted name holding / or .. became a path outside the sync folder, and
  failed the whole listing in the mount. Such names are left out (B215).
2026-10-16 09:47:08 +02:00
Nils Pukropp 3d96cdbab1 chore(aur): update packages to 3.4.1 2026-10-15 23:47:24 +00:00
nvrl 18cc2ea498 chore(release): 3.4.1 2026-10-16 01:32:59 +02:00
nvrl a9cb389f04 docs: a locked volume after a password reset (B210)
Issue #40: after a password reset the daemon failed every mount with
"failed to decrypt: missing key", and the app waited on "Connecting…"
because control.sock was never created. proton-drive-rs 0.9.1 now
creates a new My Files volume when no active regular volume is left,
as the web client does.

- BUGS.md: B210 with cause, fix and the SDK tests that cover it
- TROUBLESHOOTING.md: what the log line means and how to restore the
  files of the locked volume from the web
2026-10-16 01:32:59 +02:00
nvrl 923c47acb8 chore(deps) updated sdk to 0.9.1
0.9.1 replaces a locked My Files volume, the state a password reset leaves behind, instead of failing every mount with "missing key" (#40).
2026-10-15 23:02:36 +02:00
nvrl 478a0e6cdd fix(core,gui,cli): a blocked sign-in no longer asks to update the app (B209)
Proton answers a sign-in it blocks for abuse or rate limits with code
2028 and the text "This version of the app is no longer supported". The
SDK read 2028 as Unknown, so the app passed that text through and told
users to update a current release (issues #28, #39).

- deps: proton-sdk and proton-drive-rs 0.8.3, which name 2028 Blocked
- core: error::sign_in_blocked detects it
- gui: the login page says Proton is refusing sign-in for now and that
  waiting helps, translated in every catalog
- cli: pdfs login adds the same hint to the error
- cli: pdfs diagnose warns instead of failing when cache.db does not
  exist yet; the daemon creates it after the first sign-in
- docs: BUGS.md B209, changelog, troubleshooting entry
2026-10-13 20:29:23 +02:00
Nils Pukropp 2633b4ed72 chore(aur): update packages to 3.4.0 2026-10-12 16:21:56 +00:00
nvrl b46abfc07c chore(release): 3.4.0 2026-10-12 18:12:00 +02:00
nvrl fd78f088ec test(fuse): let a held create carry its bytes in the simulation
FUSE delivers `release` after `close()` returns, and the release is what
attaches the written bytes to the queued create. A drain worker that is
free can claim the create first and send the file empty, with the bytes
following as a revision. Tests that hold a create, its reply, or lose
its reply then act on the empty create, and their assertions about the
file's content race the revision. CI saw this as an occasional failure
of a_listing_taken_before_a_create_commits_keeps_the_landed_file.

Add Daemon::write_new, which pauses sync, writes the file, waits until
the pending create carries the bytes, and resumes. Use it for every
write that directly follows a create hold.
2026-10-12 18:05:45 +02:00
nvrl 005f7ded1f fix(fuse): a file landing while its folder is pinned stays available offline (B208)
A folder pin reads each file's modification time and size before it
downloads the file. If the file's create landed during the download, the
landing set Drive's time, and the pin then stored the blob under the old
time. The file showed as kept offline but read as stale.

After each download, the pin now compares the file's time and size with
the tree's. If they changed, it downloads the file again under the new
values, at most three times.

The simulation gains FakeClient::hold_next_download to force that order.
2026-10-12 15:40:56 +02:00
nvrl 3e4886f965 fix(fuse): refuse a move between locations under a just-opened file (B207)
The account run of 3.3.0 failed "move refuses a file still being
written" on the on-demand to mirror pairing: pdfs move moved the folder
while open.txt in it was open for writing, and the write landed after
the move.

retire_create re-keys a landed create's nodes row to the real uid first
and repoints the tree and the open write handles after. A file opened in
between holds a handle with the placeholder uid. has_pending_work_under
placed each open handle by looking its uid up in the nodes table, which
no longer has the placeholder, so it saw nothing open under the folder,
and with the queue already empty the move went ahead. Open handles are
now placed in the in-memory tree by inode, which a landing create does
not change.

The simulation test holds the drain at that point with the new
FakeClient::hold_next_landing_of_any, and fails without the fix.
2026-10-12 00:57:10 +02:00
nvrl aac53e3139 test(fuse): let the simulation sync local folders
A simulated daemon could not add a synced folder, so nothing that moves
between a mount, an on-demand folder and a mirror folder could be tested
without a real account.

Listing and registering devices now go through DriveApi, and the fake
Drive keeps the account's computers, each with a root folder outside My
files. The simulated daemons also read their config.json from a scratch
XDG directory instead of the developer's own: an adopted device of the
real account made every sync folder fail to register against the fake
Drive.
2026-10-12 00:57:04 +02:00
nvrl c2bf87596f test(acceptance): remove the local-only symlink the mirror-copy case leaves
"move never loses a mirror copy Drive lacks" puts a symlink in a mirror
folder to prove a move does not lose what sync never uploads, and left it
there. The next mode pairing switches that folder to on-demand, which the
daemon refuses because the link exists only on this computer, so the
account run stopped after pairing 2 of 4. The case now removes the link
wherever the move left it, whether it passes or fails.
2026-10-12 00:24:40 +02:00
nvrl 69d4feaa07 docs: add a security model page for users
Readers asked whether the client can be trusted with their files. The
threat model in ARCHITECTURE.md is written for contributors, so
docs/SECURITY_MODEL.md summarises it for users: encryption comes from
the SDK and rPGP, sign-in uses SRP, what is stored where and how it is
protected, who can reach the daemon, every network connection, what the
client does not protect against, and how each package is built and
signed. It says that there has been no independent audit.

It is linked from the README, the documentation index and SECURITY.md.
2026-10-12 00:07:18 +02:00
nvrl d481dcc15c feat(fuse,core,cli,gui): make folders available offline from the app
The daemon has kept recursive folder pins for a long time, but the app
offered "Available offline" for files only. The context menu, the
details pane and the selection bar now offer it for folders too, and a
pinned folder carries the offline badge in the browser and the launcher.

A folder pin used to download the whole subtree inside the control
request, so a large folder outlasted the client's 120 s read timeout,
and every pinned file was read into memory before it was cached. The
daemon now records the pin, answers at once and downloads on a
background thread as a job the tray and the app show, stopping when the
folder is unpinned or the daemon stops. Pins of files and folders both
go through fetch_content, which streams to disk and skips fresh blobs.
A folder pin is refused while offline instead of looking kept.

A file kept only because a folder above it is pinned has no pin of its
own, and unpinning it silently did nothing. ListDir now reports
pinned_by_folder; the app shows such an entry as available offline with
the toggle greyed out, and the daemon refuses the unpin with a message
naming the folder. Unpinning a folder no longer evicts files that are
pinned on their own.

Requested on Reddit.
2026-10-12 00:07:18 +02:00
nvrl e0442c56b9 fix(packaging): stop the service before the package is removed
Removing the package left the user service running from the deleted
binary, with ~/ProtonDrive still mounted. A user who then deleted the
folder to clean up had every file in it moved to the Proton Drive trash,
as any delete through the mount is.

The .deb (prerm on remove), the .rpm (%preun on erase) and the Arch
packages (pre_remove) now stop proton-drive.service for every user with
a running user manager, through runuser with that user's
XDG_RUNTIME_DIR, so no systemd-machined is needed. Upgrades are left
alone. Queued writes stay in the state directory for a later install.

The pacman install script lives once in packaging/ and the AUR
directories link it; push.sh copies its content into each AUR
repository. The uninstall guide now warns not to delete the mount point
while the service runs.

Reported on Reddit by a user who restored the files from the trash.
2026-10-12 00:07:07 +02:00
nvrl ee76f4ac60 fix(packaging): recommend each keyring only on its own desktop
The RPM recommended both gnome-keyring and kwallet unconditionally, and
dnf installs weak dependencies by default, so a GNOME install on Fedora
pulled in KWallet and KDE Frameworks with it. The recommendations are
now rich dependencies: KWallet only with plasma-workspace, GNOME Keyring
only without it, and the AppIndicator extension only with gnome-shell.
RPM does not accept `unless` in a weak dependency, so the keyring uses
the equivalent `(gnome-keyring or plasma-workspace)`.

Reported on Reddit by a Fedora GNOME user.
2026-10-12 00:06:51 +02:00
nvrl f6c651b06b ci(nix): push Nix builds to a Cachix binary cache
Every Nix build of main and of each release tag now goes to
proton-drive-linux.cachix.org, so installing the flake downloads the
client instead of compiling it. The flake names the cache in nixConfig
for nix run and nix shell, and the install guide shows the nix.settings
a NixOS system build needs, since it does not read an input's
nixConfig. A fork's pull request has no token and only pulls.
2026-10-11 23:02:22 +02:00
Nils Pukropp 8b7d7ae87f chore(aur): update packages to 3.3.0 2026-10-11 20:08:31 +00:00
nvrl 160626b29d chore(release): 3.3.0 2026-10-11 21:53:52 +02:00
nvrl f1f1f8ad4d ci(release): build the Nix flake at every release tag
A flake input pinned to vX.Y.Z is the Nix release, so the release
workflow now builds the flake at the tag and checks its version before
the GitHub release is published. The install guide shows how to pin the
input to a release tag, and the release checklist names the files that
carry the version.
2026-10-11 21:53:52 +02:00
nvrl bb28a18f49 Merge branch 'feat/issue-37-nix' 2026-10-11 21:48:28 +02:00
nvrl a3c0c9ee9b ci(release): take the release notes from the changelog
The release workflow created each GitHub release without a body, so 3.1.2,
3.2.0 and 3.2.1 shipped with empty notes. It now copies the version's
section of docs/CHANGELOG.md into the release, and fails when the section
is missing.
2026-10-11 21:24:39 +02:00
nvrl 91fa1614ed fix(core,fuse,cli,gui): say when an upgrade left the old daemon running
A package upgrade replaces the binaries but does not restart the user
service, so the daemon keeps the old release and its bugs until the next
login. On 3.2.0 the reporter of #33 still saw "Couldn't sync Documents:
read .../snapshots/37: Permission denied", which only a daemon older than
3.2.0 can produce: since then the scan leaves an unreadable subfolder out
and only an unreadable sync root fails the pass (B206).

The daemon now reports its release in Status; one from before the field
omits it, which reads as older. When the daemon is older than the
front-end, the tray says "Update not finished - restart Proton Drive" and
offers a restart, the sync card shows "Restart to finish updating" with a
Restart button, and pdfs status and pdfs diagnose print the systemctl
command. A newer daemon is not flagged, since then the front-end is the
stale one.

Refs #33
2026-10-11 21:20:51 +02:00
nvrl 87361a4573 fix(core,fuse,gui): a photo upload the SDK refuses no longer says offline
Every SDK error that was not an API reply was classified as Offline.
Only a transport failure is. An upload on an account without a photo
library fails in the SDK before any request, and the app said "You're
offline" while every other call worked (issue #38).

Classify by variant: Transport is Offline, InvalidOperation is Invalid,
Serialization is Remote, Crypto and KeysLocked are Internal. The daemon
logs a failed photo upload, and the Photos page greys out Upload when
the account has no photo library.

docs/BUGS.md B205.
2026-10-11 19:26:53 +02:00
Nils Pukropp 6d0b6ad553 chore(aur): update packages to 3.2.1 2026-10-11 17:24:37 +00:00
nvrl 170c3f01ff chore(release): 3.2.1 2026-10-11 19:19:36 +02:00
nvrl 976ad2aa04 docs(changelog): list the issue #35 fixes under Unreleased
The CPU spin while offline, the expired session shown as offline, the
lost refresh tokens and the keyring-refused rotation were fixed without
a changelog entry.
2026-10-11 18:56:55 +02:00
nvrl b73cd087aa test(core): time the B12 index write as the best of five runs
A single timed run lost the CPU to the rest of the suite running
alongside it and read as the full-scan regression (171 ms against
701 ms). The fastest of five runs still catches the 6x scaling of the
old path.
2026-10-11 18:56:55 +02:00
141 changed files with 27071 additions and 12561 deletions

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ contact_links:
- name: Security vulnerability - name: Security vulnerability
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
about: Report privately — never in a public issue. about: Report privately — never in a public issue.
- name: Troubleshooting and FAQ
url: https://github.com/narrrl/proton-drive-linux/wiki/Troubleshooting
about: Fixes for common problems, and what to include in a bug report.
- name: Known issues - name: Known issues
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
about: The authoritative ledger of tracked bugs and their status. about: The authoritative ledger of tracked bugs and their status.
+1
View File
@@ -21,6 +21,7 @@ cargo test --workspace --locked
- [ ] Touches recovery, staging, or anything that can be the only copy of user data - [ ] Touches recovery, staging, or anything that can be the only copy of user data
- [ ] Changes packaging, the systemd unit, or desktop entries - [ ] Changes packaging, the systemd unit, or desktop entries
- [ ] Changes the control protocol (daemon and front ends updated together) - [ ] Changes the control protocol (daemon and front ends updated together)
- [ ] Adds or renames a page in `docs/` (listed in `SECTIONS` in `scripts/publish-wiki.sh`, which publishes it to the wiki)
- [ ] None of the above - [ ] None of the above
<!-- Screenshots for GTK changes. --> <!-- Screenshots for GTK changes. -->
+3 -2
View File
@@ -45,7 +45,7 @@ jobs:
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Lints - name: Lints
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Unit tests - name: Unit tests
run: cargo test --workspace --locked run: cargo test --workspace --locked
@@ -66,8 +66,9 @@ jobs:
# The seeded simulation runs (docs/TESTING.md): real FUSE # The seeded simulation runs (docs/TESTING.md): real FUSE
# daemons against an in-memory Drive, so they need /dev/fuse but no account. # daemons against an in-memory Drive, so they need /dev/fuse but no account.
# They run on pull requests too: most data-loss fixes are only exercised
# here, and a change that breaks one should not reach main first.
simulation: simulation:
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 30 timeout-minutes: 30
+66
View File
@@ -0,0 +1,66 @@
name: COPR
on:
# Called by release.yml once the GitHub release and its assets exist: COPR
# downloads the tag archive and the vendored crates from the release.
workflow_call:
inputs:
version:
required: true
type: string
secrets:
COPR_CONFIG:
required: true
workflow_dispatch:
inputs:
version:
description: 'Release to build, such as 3.5.0. It must already have its vendor tarball.'
required: true
type: string
permissions:
contents: read
concurrency:
group: copr
cancel-in-progress: false
jobs:
build:
name: Build in COPR
runs-on: ubuntu-24.04
container: fedora:latest
env:
VERSION: ${{ inputs.version }}
COPR_PROJECT: narl-rs/proton-drive-linux
steps:
- name: Install packaging tools
run: dnf -y install copr-cli rpm-build rpmdevtools
- name: Checkout repository
uses: actions/checkout@v7
- name: Configure COPR access
env:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
run: |
install -d -m700 ~/.config
printf '%s\n' "$COPR_CONFIG" > ~/.config/copr
chmod 600 ~/.config/copr
# COPR rebuilds the source package without our --define, so the version
# has to be written into the spec itself.
- name: Build the source package
run: |
version="${VERSION#v}"
spec=packaging/proton-drive-linux.spec
sed -i -E "s/^(%\{!\?version: %global version )[^}]*\}/\1${version}}/" "$spec"
grep -qF "%global version ${version}}" "$spec"
mkdir -p rpmbuild/SOURCES
spectool -g -C rpmbuild/SOURCES "$spec"
rpmbuild -bs --define "_topdir $PWD/rpmbuild" "$spec"
# Waits for the build, so a failed COPR build fails this job.
- name: Submit to COPR
run: copr-cli build "$COPR_PROJECT" rpmbuild/SRPMS/*.src.rpm
+7
View File
@@ -38,6 +38,13 @@ jobs:
- uses: cachix/install-nix-action@v31 - uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Check the flake - name: Check the flake
run: nix flake check --print-build-logs --no-build run: nix flake check --print-build-logs --no-build
+78 -1
View File
@@ -52,8 +52,10 @@ jobs:
tag_version="${GITHUB_REF_NAME#v}" tag_version="${GITHUB_REF_NAME#v}"
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)" manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)" pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
spec_version="$(sed -n 's/^%{!?version: %global version \([^}]*\)}/\1/p' packaging/proton-drive-linux.spec)"
test "$tag_version" = "$manifest_version" test "$tag_version" = "$manifest_version"
test "$tag_version" = "$pkgbuild_version" test "$tag_version" = "$pkgbuild_version"
test "$tag_version" = "$spec_version"
- name: Release quality gates - name: Release quality gates
run: | run: |
@@ -129,6 +131,12 @@ jobs:
# Create tarball # Create tarball
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries . tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
# Every crate the build needs, so COPR can build the spec without network.
- name: Package vendored crates (.tar.xz)
run: |
cargo vendor --locked dist/vendor > /dev/null
tar -cJf proton-drive-linux-vendor-${{ env.VERSION }}.tar.xz -C dist vendor
- name: Package Debian package (.deb) - name: Package Debian package (.deb)
run: | run: |
mkdir -p deb/DEBIAN mkdir -p deb/DEBIAN
@@ -153,6 +161,9 @@ jobs:
cp LICENSE deb/usr/share/licenses/proton-drive-linux/ cp LICENSE deb/usr/share/licenses/proton-drive-linux/
po/build.sh deb/usr/share/locale po/build.sh deb/usr/share/locale
# Stops the running daemon before removal, so the mount stops mirroring deletes
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
# Calculate Installed-Size in KB for the control file # Calculate Installed-Size in KB for the control file
INSTALLED_SIZE=$(du -sk deb | cut -f1) INSTALLED_SIZE=$(du -sk deb | cut -f1)
@@ -227,27 +238,75 @@ jobs:
path: | path: |
*.deb *.deb
*.tar.gz *.tar.gz
*.tar.xz
*.rpm *.rpm
if-no-files-found: error if-no-files-found: error
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
# pins, so it has to build before the release is announced.
nix:
name: Build the Nix package
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
# Pulls from the project's binary cache, and pushes what this run built
# when the token is there; a fork's pull request has none and only pulls.
- uses: cachix/cachix-action@v17
with:
name: proton-drive-linux
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: Build the flake
run: nix build --print-build-logs .#proton-drive-linux
- name: Check the version
run: |
built="$(./result/bin/pdfs --version)"
echo "$built"
case "$GITHUB_REF" in
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
esac
publish: publish:
name: Create GitHub Release name: Create GitHub Release
needs: [checks, build-release] needs: [checks, build-release, nix]
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Checkout
uses: actions/checkout@v7
- name: Download packages - name: Download packages
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: proton-drive-linux-builds name: proton-drive-linux-builds
# The release notes are this version's section of the changelog, from its
# heading up to the next one.
- name: Extract release notes
run: |
version="${GITHUB_REF_NAME#v}"
awk -v heading="## [$version]" \
'index($0, heading) == 1 { found = 1; print; next }
found && /^## \[/ { exit }
found' docs/CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::docs/CHANGELOG.md has no section for $version"
exit 1
fi
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
body_path: release-notes.md
files: | files: |
*.deb *.deb
*.tar.gz *.tar.gz
*.tar.xz
*.rpm *.rpm
draft: false draft: false
prerelease: false prerelease: false
@@ -263,3 +322,21 @@ jobs:
version: ${{ github.ref_name }} version: ${{ github.ref_name }}
secrets: secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
copr:
name: Build COPR packages
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/copr.yml
with:
version: ${{ github.ref_name }}
secrets:
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
wiki:
name: Publish the wiki
needs: publish
if: startsWith(github.ref, 'refs/tags/')
uses: ./.github/workflows/wiki.yml
with:
ref: ${{ github.ref_name }}
+77
View File
@@ -0,0 +1,77 @@
name: Wiki
# docs/ is the source of the GitHub wiki (docs/DEVELOPMENT.md#common-changes).
# Every change to the documentation checks its links; release.yml calls this
# once the release is published, so the wiki documents the latest release. Run
# it from the Actions tab to publish main in between.
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
pull_request:
paths:
- 'docs/**'
- 'images/**'
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'scripts/publish-wiki.sh'
- '.github/workflows/wiki.yml'
workflow_call:
inputs:
ref:
description: 'Tag or branch to publish'
required: true
type: string
workflow_dispatch:
inputs:
ref:
description: 'Tag or branch to publish'
required: false
default: 'main'
type: string
permissions:
contents: read
jobs:
check:
name: Check documentation links
if: inputs.ref == ''
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Check links
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
publish:
name: Publish to the wiki
# Set only by release.yml and the Actions tab, so pushes and pull requests
# never publish.
if: inputs.ref != ''
runs-on: ubuntu-24.04
concurrency:
group: wiki-publish
cancel-in-progress: false
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref }}
- name: Publish
env:
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
+8 -6
View File
@@ -6,10 +6,10 @@ deliberately high — everything else is ordinary Rust review.
## Before you start ## Before you start
- Read [`AGENTS.md`](AGENTS.md) for repository conventions, - Read [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source, source, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit SDK fit together. [`docs/README.md`](docs/README.md) indexes the rest. The same pages are on
together. [`docs/README.md`](docs/README.md) indexes the rest. the [wiki](https://github.com/narrrl/proton-drive-linux/wiki).
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may - Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
already be tracked there with context. already be tracked there with context.
- For anything larger than a fix, open an issue first so the design can be discussed before you - For anything larger than a fix, open an issue first so the design can be discussed before you
@@ -58,8 +58,10 @@ runs are destructive: use a dedicated test account, never one holding data you c
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md). catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
The CLI and daemon messages stay in English. The CLI and daemon messages stay in English.
- **Document what users see.** A new command, setting or behaviour belongs in the matching page - **Document what users see.** A new command, setting or behaviour belongs in the matching page
under `docs/` (user guide, CLI reference or configuration) and in the `[Unreleased]` section of under `docs/` (a user guide topic, the CLI reference or configuration) and in the `[Unreleased]`
[`docs/CHANGELOG.md`](docs/CHANGELOG.md). section of [`docs/CHANGELOG.md`](docs/CHANGELOG.md). Each release publishes `docs/` to the
wiki. A new page goes into `SECTIONS` in `scripts/publish-wiki.sh`, and
`scripts/publish-wiki.sh --check` must pass. CI runs it on every change to `docs/`.
## Commits and pull requests ## Commits and pull requests
Generated
+9 -8
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]] [[package]]
name = "pdfs-cli" name = "pdfs-cli"
version = "3.2.0" version = "3.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -3490,13 +3490,14 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-core" name = "pdfs-core"
version = "3.2.0" version = "3.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
"dbus-secret-service-keyring-store", "dbus-secret-service-keyring-store",
"directories", "directories",
"ignore", "ignore",
"kamadak-exif",
"keyring-core", "keyring-core",
"libc", "libc",
"parking_lot", "parking_lot",
@@ -3516,7 +3517,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-fuse" name = "pdfs-fuse"
version = "3.2.0" version = "3.6.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3538,7 +3539,7 @@ dependencies = [
[[package]] [[package]]
name = "pdfs-gui" name = "pdfs-gui"
version = "3.2.0" version = "3.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3778,9 +3779,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-drive-rs" name = "proton-drive-rs"
version = "0.8.2" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da" checksum = "8f70318394b2815b009c52f804d75747086db93a338ceb6eae62541f2b25d5bf"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.23.1", "base64 0.23.1",
@@ -3805,9 +3806,9 @@ dependencies = [
[[package]] [[package]]
name = "proton-sdk" name = "proton-sdk"
version = "0.8.2" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619" checksum = "4196392032f56b3bbaac0bb1f24eea62fa7e2a0af72004de8d08e518d14720fb"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"async-trait", "async-trait",
+15 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package] [workspace.package]
version = "3.2.0" version = "3.6.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"] authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"] categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies] [workspace.dependencies]
proton-sdk = { version = "0.8.2" } proton-sdk = { version = "0.10.0" }
proton-drive-rs = { version = "0.8.2" } proton-drive-rs = { version = "0.10.0" }
# Internal crates. # Internal crates.
pdfs-core = { path = "crates/pdfs-core" } pdfs-core = { path = "crates/pdfs-core" }
@@ -61,3 +61,15 @@ async-trait = "0.1"
futures = "0.3" futures = "0.3"
fuser = "0.18" fuser = "0.18"
libc = "0.2" libc = "0.2"
[profile.release]
# No package ships a separate debug package (the PKGBUILD sets `!debug`, the
# RPM spec sets `debug_package` to nil), and the unstripped binaries weighed
# 43 MB (`pdfs`), 24 MB (`pdfs-app`) and 12 MB (`pdfs-tray`).
strip = "symbols"
# Off on purpose: LTO has broken GTK links for this workspace, which is why
# the packages turn it off as well.
lto = false
# `Workers` and the thumbnail build catch panics to keep the daemon alive, so
# unwinding must stay on.
panic = "unwind"
+31 -9
View File
@@ -73,16 +73,30 @@ scriptable CLI.
| Distribution | Package | | Distribution | Package |
|---|---| |---|---|
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) | | Fedora 44 or newer | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, updated with `dnf upgrade` |
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR | | Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) | | Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| NixOS | The flake in this repository: see [Installation → NixOS](docs/INSTALL.md#nixos-flake) |
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) | | Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
**Fedora**
```bash ```bash
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu sudo dnf copr enable narl-rs/proton-drive-linux
sudo dnf install ./proton-drive-linux-*.rpm # Fedora sudo dnf install proton-drive-linux
paru -S proton-drive-for-linux # Arch ```
**Arch Linux**
```bash
paru -S proton-drive-for-linux
```
**Debian and Ubuntu:** download the `.deb` from the
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then:
```bash
sudo apt install ./proton-drive-linux_*_amd64.deb
``` ```
Then open **Proton Drive** from your applications menu and sign in. The app starts the background Then open **Proton Drive** from your applications menu and sign in. The app starts the background
@@ -97,18 +111,25 @@ systemctl --user enable --now proton-drive.service
pdfs status pdfs status
``` ```
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and New to the client? [Getting started](docs/GETTING_STARTED.md) walks through the first steps.
video thumbnails), headless use and uninstalling. [Installation](docs/INSTALL.md) has the optional extras (tray on GNOME, RAW and video
thumbnails), updating, headless use and uninstalling.
## Documentation ## Documentation
The documentation is in [`docs/`](docs/README.md) and on the
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).
| | | | | |
|---|---| |---|---|
| [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 | | [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 and scripting with `--json` |
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service | | [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones | | [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 | | [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
| [Changelog](docs/CHANGELOG.md) | What changed in each release | | [Changelog](docs/CHANGELOG.md) | What changed in each release |
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md), For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
@@ -150,7 +171,8 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
Files you open are decrypted into a local cache so programs can read them. What that means for Files you open are decrypted into a local cache so programs can read them. What that means for
the security of the machine is spelled out in the the security of the machine is spelled out in the
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). [security model](docs/SECURITY_MODEL.md#what-is-kept-on-this-computer), and in full detail in
the [threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
## Contributing ## Contributing
+3
View File
@@ -4,6 +4,9 @@
Fixes land on the latest release. Please reproduce on the most recent version before reporting. Fixes land on the latest release. Please reproduce on the most recent version before reporting.
For what the client protects and what it does not, written for users, see the
[security model](docs/SECURITY_MODEL.md).
## Reporting a vulnerability ## Reporting a vulnerability
**Do not open a public issue.** Report privately through GitHub's **Do not open a public issue.** Report privately through GitHub's
+393 -26
View File
@@ -13,9 +13,9 @@ use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::cache::ContentCache;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest, ConflictKeep, ErrorKind, LinkChange, MountKind, RefreshScope, Request as CtlRequest,
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase, Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
pending_summary, daemon_outdated, pending_summary,
}; };
use pdfs_core::db::Db; use pdfs_core::db::Db;
use pdfs_core::service; use pdfs_core::service;
@@ -172,17 +172,19 @@ enum Command {
/// Mountpoint; defaults to ~/ProtonDrive. /// Mountpoint; defaults to ~/ProtonDrive.
mountpoint: Option<PathBuf>, mountpoint: Option<PathBuf>,
}, },
/// Keep a file on this device (download + cache it). Needs a running mount. /// Keep a file or folder on this device (download + cache it). A folder's
/// files download in the background; `pdfs transfers` shows the progress.
/// Needs a running mount.
Pin { Pin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// Stop keeping a file on this device, evicting its cached content. /// Stop keeping a file or folder on this device, evicting its cached content.
Unpin { Unpin {
/// File path, inside the mountpoint or relative to it. /// File or folder path, inside the mountpoint or relative to it.
path: PathBuf, path: PathBuf,
}, },
/// List files pinned to this device. /// List files and folders pinned to this device.
Pins, Pins,
/// List a directory via the running daemon (in-app browser backend). /// List a directory via the running daemon (in-app browser backend).
Ls { Ls {
@@ -354,6 +356,26 @@ enum Command {
#[arg(required = true, num_args = 2.., value_name = "PATH")] #[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>, paths: Vec<PathBuf>,
}, },
/// Copy files or folders into another folder in My files via the running
/// daemon.
///
/// The copy happens on Proton Drive, so nothing is downloaded or uploaded,
/// unlike `cp` inside the mount. A name already taken in the destination
/// gets a " (1)" suffix. The last path is the destination folder.
Copy {
/// Files/folders to copy, then the destination folder: inside My files,
/// or relative to the mountpoint.
#[arg(required = true, num_args = 2.., value_name = "PATH")]
paths: Vec<PathBuf>,
},
/// Show how much a folder holds on Proton Drive.
///
/// Proton Drive adds it up, so nothing is downloaded. The size is the
/// encrypted bytes on storage and includes items in the trash.
Du {
/// Folder path, inside My files or relative to the mountpoint.
path: PathBuf,
},
/// Trash a file or folder via the running daemon. /// Trash a file or folder via the running daemon.
Rm { Rm {
/// File/folder path, inside My files or an on-demand synced folder, or /// File/folder path, inside My files or an on-demand synced folder, or
@@ -628,6 +650,16 @@ enum DeviceCmd {
#[arg(long, conflicts_with = "uid")] #[arg(long, conflicts_with = "uid")]
clear: bool, clear: bool,
}, },
/// Sync the same folders as another computer: this machine continues that
/// computer's device alongside it, and its folders are offered for syncing
/// here. Both computers keep them in step.
Join {
/// Device uid (from `devices list`).
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
yes: bool,
},
/// Move another computer's folders into this computer's backup and sync /// Move another computer's folders into this computer's backup and sync
/// them here. The other computer stays registered, without those folders. /// them here. The other computer stays registered, without those folders.
Migrate { Migrate {
@@ -677,6 +709,13 @@ enum SyncCmd {
/// Optional synced-folder id; omit to reconcile every folder. /// Optional synced-folder id; omit to reconcile every folder.
id: Option<i64>, id: Option<i64>,
}, },
/// Forget what a synced folder synced before and compare it with Proton
/// Drive afresh: what one side has alone is copied to the other, and a file
/// that differs keeps both versions. Nothing is deleted.
Reset {
/// Synced-folder id (from `sync list`).
id: i64,
},
/// Pause syncing: queued uploads and mirror folders wait, reads keep working. /// Pause syncing: queued uploads and mirror folders wait, reads keep working.
Pause { Pause {
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`. /// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
@@ -765,6 +804,29 @@ enum PublicLinkCmd {
#[arg(long)] #[arg(long)]
expires: Option<i64>, expires: Option<i64>,
}, },
/// Change a node's public link. The URL stays the same; an option not
/// given is kept.
Update {
/// File/folder path, inside the mountpoint or relative to it.
path: PathBuf,
/// Public-link id, from `pdfs members`.
id: String,
/// New role: viewer or editor.
#[arg(long)]
role: Option<String>,
/// New custom password protecting the link.
#[arg(long, conflicts_with = "no_password")]
password: Option<String>,
/// Remove the custom password.
#[arg(long)]
no_password: bool,
/// New expiry, Unix epoch seconds.
#[arg(long, conflicts_with = "no_expiry")]
expires: Option<i64>,
/// Remove the expiry.
#[arg(long)]
no_expiry: bool,
},
/// Remove a node's public link. /// Remove a node's public link.
Remove { Remove {
/// File/folder path, inside the mountpoint or relative to it. /// File/folder path, inside the mountpoint or relative to it.
@@ -829,6 +891,20 @@ fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
// The commands that hold the password, the key passphrases or the session
// tokens in memory: keep them out of core dumps and away from same-user
// ptrace. Only the daemon did, though `mount` holds the same keys for as
// long as it runs.
if matches!(
cli.command,
Command::Login { .. }
| Command::Unlock { .. }
| Command::Logout
| Command::Mount { .. }
| Command::Daemon { .. }
) {
pdfs_core::kernelkey::forbid_dumps();
}
match cli.command { match cli.command {
Command::Login { username, remember } => cmd_login(username, remember), Command::Login { username, remember } => cmd_login(username, remember),
Command::Unlock { remember } => cmd_unlock(remember), Command::Unlock { remember } => cmd_unlock(remember),
@@ -893,6 +969,11 @@ fn main() -> Result<()> {
let new_parent = paths.pop().expect("clap requires at least two paths"); let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_move(paths, new_parent) cmd_move(paths, new_parent)
} }
Command::Copy { mut paths } => {
let new_parent = paths.pop().expect("clap requires at least two paths");
cmd_copy(paths, new_parent)
}
Command::Du { path } => cmd_du(path),
Command::Rm { path } => cmd_rm(path), Command::Rm { path } => cmd_rm(path),
Command::Mkdir { parent, name } => cmd_mkdir(parent, name), Command::Mkdir { parent, name } => cmd_mkdir(parent, name),
Command::Upload { sources, parent } => cmd_upload(sources, parent), Command::Upload { sources, parent } => cmd_upload(sources, parent),
@@ -976,6 +1057,26 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
let uid = if clear { None } else { uid }; let uid = if clear { None } else { uid };
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)? ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
} }
DeviceCmd::Join { uid, yes } => {
if !yes {
println!(
"This computer syncs into that device alongside it. Its folders are kept\n\
in step on both computers: an edit or a deletion on one reaches the other.\n\
New synced folders here go under that device too. Folders already synced\n\
here stay where they are."
);
confirm("Sync with it?")?;
}
let response = control_request(CtlRequest::AdoptDevice { uid: Some(uid) })?;
// A script gets the one reply and runs `sync restore` itself.
if emit_json(&response)? {
return Ok(());
}
ok_or_bail(response)?;
// The device is this machine's now, so the plain restore offers its
// folders.
cmd_sync_restore(yes, None)?
}
DeviceCmd::Migrate { uid, yes } => { DeviceCmd::Migrate { uid, yes } => {
if !yes { if !yes {
println!( println!(
@@ -1042,7 +1143,14 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
// A pass in flight says what it is doing, indented under it. // A pass in flight says what it is doing, indented under it.
if let Some(p) = &f.progress { if let Some(p) = &f.progress {
match p.phase { match p.phase {
SyncPhase::Scanning if p.total == 0 => println!(" scanning…"), SyncPhase::Scanning if p.total == 0 && p.done == 0 => {
println!(" scanning…")
}
// A first pass has no estimate yet; the count alone
// shows a long first scan is moving.
SyncPhase::Scanning if p.total == 0 => {
println!(" scanning: {} checked", p.done)
}
SyncPhase::Scanning => { SyncPhase::Scanning => {
println!( println!(
" scanning: {} of {}", " scanning: {} of {}",
@@ -1071,6 +1179,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
})?)? })?)?
} }
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?, SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
SyncCmd::Reset { id } => ok_or_bail(control_request(CtlRequest::ResetSyncFolder { id })?)?,
SyncCmd::Pause { SyncCmd::Pause {
folder: Some(id), .. folder: Some(id), ..
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused { } => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
@@ -1447,9 +1556,13 @@ fn cmd_versions(action: VersionCmd) -> Result<()> {
} }
fn cmd_members(path: PathBuf) -> Result<()> { fn cmd_members(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::ListShare { let response = control_request(CtlRequest::ListShare {
path: path_arg(&path)?, path: path_arg(&path)?,
})? { })?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::Share { entries, link } => { CtlResponse::Share { entries, link } => {
if entries.is_empty() { if entries.is_empty() {
println!("No members or pending invitations."); println!("No members or pending invitations.");
@@ -1466,7 +1579,11 @@ fn cmd_members(path: PathBuf) -> Result<()> {
Some(l) => { Some(l) => {
let url = l.url.as_deref().unwrap_or("(url hidden)"); let url = l.url.as_deref().unwrap_or("(url hidden)");
let pw = if l.has_password { " +password" } else { "" }; let pw = if l.has_password { " +password" } else { "" };
println!("public link ({}{pw}): {url} [{}]", l.role, l.id); let expiry = l
.expires
.map(|at| format!(", expires {}", format_epoch_utc(at)))
.unwrap_or_default();
println!("public link ({}{pw}{expiry}): {url} [{}]", l.role, l.id);
} }
None => println!("public link: none"), None => println!("public link: none"),
} }
@@ -1512,6 +1629,29 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
password, password,
expires, expires,
})? { })? {
response if emit_json(&response)? => {}
CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
},
PublicLinkCmd::Update {
path,
id,
role,
password,
no_password,
expires,
no_expiry,
} => match control_request(CtlRequest::UpdatePublicLink {
path: path_arg(&path)?,
id,
role,
password: link_change(password, no_password),
expires: link_change(expires, no_expiry),
})? {
response if emit_json(&response)? => {}
CtlResponse::PublicLink { link } => { CtlResponse::PublicLink { link } => {
println!("{}", link.url.as_deref().unwrap_or("(no url returned)")); println!("{}", link.url.as_deref().unwrap_or("(no url returned)"));
} }
@@ -1528,6 +1668,15 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
Ok(()) Ok(())
} }
/// A `--x VALUE` / `--no-x` flag pair as one link option change.
fn link_change<T>(value: Option<T>, remove: bool) -> LinkChange<T> {
match (value, remove) {
(Some(value), _) => LinkChange::Set(value),
(None, true) => LinkChange::Remove,
(None, false) => LinkChange::Keep,
}
}
fn cmd_shared() -> Result<()> { fn cmd_shared() -> Result<()> {
match control_request(CtlRequest::ListSharedByMe)? { match control_request(CtlRequest::ListSharedByMe)? {
CtlResponse::SharedByMe { items } if items.is_empty() => { CtlResponse::SharedByMe { items } if items.is_empty() => {
@@ -1719,6 +1868,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
Sync => "sync", Sync => "sync",
Rename => "rename", Rename => "rename",
Move => "move", Move => "move",
Copy => "copy",
CreateFolder => "mkdir", CreateFolder => "mkdir",
Trash => "trash", Trash => "trash",
Restore => "restore", Restore => "restore",
@@ -1865,7 +2015,22 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
get_mailbox, get_mailbox,
get_hv, get_hv,
)) ))
.context("login failed")?; .map_err(|e| {
// Proton words this block as "update the app", which the user can't
// act on: the release is current and only waiting helps.
let blocked =
matches!(&e, pdfs_core::Error::Proton(p) if pdfs_core::error::sign_in_blocked(p));
let e = anyhow::Error::new(e).context("login failed");
if blocked {
e.context(
"Proton is temporarily refusing sign-in from this device (code 2028). \
Your version is not the problem. Wait several hours, up to a day, \
without trying again, then sign in.",
)
} else {
e
}
})?;
// A daemon that is already up is parked in `wait_for_session`, sleeping // A daemon that is already up is parked in `wait_for_session`, sleeping
// between keyring checks; restart it so the mount appears now instead of // between keyring checks; restart it so the mount appears now instead of
@@ -1960,8 +2125,14 @@ fn key_state_name() -> &'static str {
} }
fn cmd_logout() -> Result<()> { fn cmd_logout() -> Result<()> {
auth::logout()?; if auth::logout()? {
println!("Stored session cleared."); println!("Signed out: the session is ended on Proton and cleared here.");
} else {
println!(
"Stored session cleared. Proton could not be reached to end the session; \
it stays valid until it expires or is signed out in your account's security settings."
);
}
Ok(()) Ok(())
} }
@@ -2026,6 +2197,7 @@ fn cmd_status() -> Result<()> {
paused_until, paused_until,
session_expired, session_expired,
tokens_unsaved, tokens_unsaved,
version,
.. ..
}) => { }) => {
let state = match (online, paused) { let state = match (online, paused) {
@@ -2067,6 +2239,9 @@ fn cmd_status() -> Result<()> {
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`" "Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
); );
} }
if daemon_outdated(&version) {
println!("Daemon {}", outdated_daemon_note(&version));
}
} }
Ok(other) => println!("Mount: unexpected response {other:?}"), Ok(other) => println!("Mount: unexpected response {other:?}"),
Err(_) => println!("Mount: not running."), Err(_) => println!("Mount: not running."),
@@ -2074,6 +2249,20 @@ fn cmd_status() -> Result<()> {
Ok(()) Ok(())
} }
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
/// an older release than this CLI.
fn outdated_daemon_note(version: &str) -> String {
let running = if version.is_empty() {
"an older release".to_string()
} else {
version.to_string()
};
format!(
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
env!("CARGO_PKG_VERSION"),
)
}
fn cmd_locations() -> Result<()> { fn cmd_locations() -> Result<()> {
let response = control_request(CtlRequest::ListLocations)?; let response = control_request(CtlRequest::ListLocations)?;
if emit_json(&response)? { if emit_json(&response)? {
@@ -2186,9 +2375,6 @@ where
/// an external unmount triggers a remount; errors back off and retry. This is /// an external unmount triggers a remount; errors back off and retry. This is
/// the entry point for the systemd user service. /// the entry point for the systemd user service.
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> { fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
// The daemon holds the account's key passphrases in memory; keep them out
// of core dumps and away from same-user ptrace.
pdfs_core::kernelkey::forbid_dumps();
loop { loop {
// Wait until a session is stored. The GUI enables this service on login, // Wait until a session is stored. The GUI enables this service on login,
// but the service may also start at boot before the user has logged in. // but the service may also start at boot before the user has logged in.
@@ -2557,7 +2743,10 @@ fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -
} => continue, } => continue,
CtlResponse::ImportStatus { summary, .. } => { CtlResponse::ImportStatus { summary, .. } => {
print_import_summary(summary.as_ref()); print_import_summary(summary.as_ref());
return Ok(()); return finished_run(
"the import",
summary.map(|s| (s.failed, s.cancelled, s.error)),
);
} }
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"), other => bail!("unexpected response: {other:?}"),
@@ -2600,7 +2789,10 @@ fn cmd_redate_photos(
CtlResponse::RedateStatus { running: true, .. } => continue, CtlResponse::RedateStatus { running: true, .. } => continue,
CtlResponse::RedateStatus { summary, .. } => { CtlResponse::RedateStatus { summary, .. } => {
print_redate_summary(summary.as_ref()); print_redate_summary(summary.as_ref());
return Ok(()); return finished_run(
"the re-date",
summary.map(|s| (s.failed, s.cancelled, s.error)),
);
} }
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"), other => bail!("unexpected response: {other:?}"),
@@ -2608,6 +2800,22 @@ fn cmd_redate_photos(
} }
} }
/// How a run `--wait` followed ended, as the exit status: an error when it
/// failed, was cancelled, or left photos failed, so a script can tell.
/// `outcome` is `(failed, cancelled, error)`; `None` when the daemon has no
/// result, which happens when it restarted during the run.
fn finished_run(what: &str, outcome: Option<(usize, bool, Option<String>)>) -> Result<()> {
match outcome {
None => bail!("{what} ended without a result; the service may have restarted"),
Some((_, _, Some(error))) => bail!("{what} failed: {error}"),
Some((_, true, None)) => bail!("{what} was cancelled"),
Some((failed, false, None)) if failed > 0 => {
bail!("{what} finished, but {failed} photo(s) failed")
}
Some(_) => Ok(()),
}
}
/// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout /// A `YYYY-MM-DD` day as epoch seconds at UTC midnight. Reuses the Takeout
/// scanner's date reader, which already accepts exactly this spelling. /// scanner's date reader, which already accepts exactly this spelling.
fn parse_day(day: &str) -> Result<i64> { fn parse_day(day: &str) -> Result<i64> {
@@ -2643,6 +2851,10 @@ fn print_redate_summary(summary: Option<&pdfs_core::control::RedateSummary>) {
println!("No re-date has run yet."); println!("No re-date has run yet.");
return; return;
}; };
if let Some(error) = &summary.error {
println!("Failed : {error}");
return;
}
println!("Photos examined : {}", summary.examined); println!("Photos examined : {}", summary.examined);
println!("Mis-dated : {}", summary.candidates); println!("Mis-dated : {}", summary.candidates);
if summary.dry_run { if summary.dry_run {
@@ -2702,6 +2914,10 @@ fn print_import_summary(summary: Option<&pdfs_core::control::ImportSummary>) {
println!("No import has run yet."); println!("No import has run yet.");
return; return;
}; };
if let Some(error) = &summary.error {
println!("Failed : {error}");
return;
}
println!("Photos in export : {}", summary.found); println!("Photos in export : {}", summary.found);
println!("Uploaded : {}", summary.uploaded); println!("Uploaded : {}", summary.uploaded);
println!("Already on Proton: {}", summary.duplicates); println!("Already on Proton: {}", summary.duplicates);
@@ -2808,6 +3024,56 @@ fn cmd_move(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_copy(sources: Vec<PathBuf>, new_parent: PathBuf) -> Result<()> {
let new_parent = path_arg(&new_parent)?;
let mut failed = 0;
for source in &sources {
let outcome = path_arg(source).and_then(|path| {
match control_request(CtlRequest::Copy {
path,
new_parent: new_parent.clone(),
})? {
CtlResponse::Ok { message } => Ok(message),
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
});
match outcome {
Ok(message) => println!("{message}"),
Err(e) if sources.len() == 1 => return Err(e),
Err(e) => {
eprintln!("{}: {e:#}", source.display());
failed += 1;
}
}
}
if failed > 0 {
bail!("{failed} of {} could not be copied", sources.len());
}
Ok(())
}
fn cmd_du(path: PathBuf) -> Result<()> {
let response = control_request(CtlRequest::FolderSize {
path: path_arg(&path)?,
})?;
if emit_json(&response)? {
return Ok(());
}
match response {
CtlResponse::FolderSize { size, items } => {
let noun = if items == 1 { "item" } else { "items" };
println!(
"{} in {items} {noun} (on Proton Drive, trash included)",
human_bytes(size)
);
}
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
other => bail!("unexpected response: {other:?}"),
}
Ok(())
}
fn cmd_rm(path: PathBuf) -> Result<()> { fn cmd_rm(path: PathBuf) -> Result<()> {
match control_request(CtlRequest::Delete { match control_request(CtlRequest::Delete {
path: path_arg(&path)?, path: path_arg(&path)?,
@@ -3376,12 +3642,25 @@ fn cmd_diagnose() -> Result<()> {
} }
let db_path = dirs.db_path(); let db_path = dirs.db_path();
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); if db_path.exists() {
report.check( let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
" database", report.check(
db_path.is_file(), " database",
format!("{} ({})", db_path.display(), human_bytes(db_size)), db_path.is_file(),
); format!("{} ({})", db_path.display(), human_bytes(db_size)),
);
} else {
// The daemon creates it on its first start, which waits for a sign-in,
// so a fresh install has none yet (issue #39 read this as the fault).
report.finding(
DiagnoseLevel::Warn,
" database",
format!(
"{} not created yet (the daemon creates it on first start)",
db_path.display()
),
);
}
println!("\nFUSE"); println!("\nFUSE");
let fuse = Path::new("/dev/fuse"); let fuse = Path::new("/dev/fuse");
@@ -3394,10 +3673,13 @@ fn cmd_diagnose() -> Result<()> {
); );
println!("\nAccount"); println!("\nAccount");
let mut keys_unlocked = false;
match pdfs_core::auth::load() { match pdfs_core::auth::load() {
Ok(session) => { Ok(session) => {
report.finding(DiagnoseLevel::Ok, " keyring session", session.username); report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
match key_state_name() { let key_state = key_state_name();
keys_unlocked = key_state != "locked";
match key_state {
"locked" => report.finding( "locked" => report.finding(
DiagnoseLevel::Fail, DiagnoseLevel::Fail,
" account keys", " account keys",
@@ -3436,6 +3718,7 @@ fn cmd_diagnose() -> Result<()> {
); );
// A stale socket file with no listener is the common failure, so the reply // A stale socket file with no listener is the common failure, so the reply
// is what decides this rather than the file's existence. // is what decides this rather than the file's existence.
let mut daemon_responding = false;
match control_request(CtlRequest::Status) { match control_request(CtlRequest::Status) {
Ok(CtlResponse::Status { Ok(CtlResponse::Status {
mountpoint, mountpoint,
@@ -3444,9 +3727,18 @@ fn cmd_diagnose() -> Result<()> {
tokens_unsaved, tokens_unsaved,
pending_uploads, pending_uploads,
pending_changes, pending_changes,
version,
.. ..
}) => { }) => {
daemon_responding = true;
report.finding(DiagnoseLevel::Ok, " daemon responding", ""); report.finding(DiagnoseLevel::Ok, " daemon responding", "");
if daemon_outdated(&version) {
report.finding(
DiagnoseLevel::Warn,
" daemon version",
outdated_daemon_note(&version),
);
}
let live_type = mounted_fs_type(Path::new(&mountpoint)); let live_type = mounted_fs_type(Path::new(&mountpoint));
let is_fuse = live_type let is_fuse = live_type
.as_deref() .as_deref()
@@ -3580,6 +3872,14 @@ fn cmd_diagnose() -> Result<()> {
mountpoint.display().to_string(), mountpoint.display().to_string(),
); );
// The daemon opens its control socket only once My Files is mounted, so a
// daemon that answers has the keys it needs. One that does not may be stuck
// on a key that does not open (#40); only then is Proton asked.
if keys_unlocked && !daemon_responding {
println!("\nKeys");
diagnose_keys(&mut report);
}
if report.failures == 0 { if report.failures == 0 {
if report.warnings == 0 { if report.warnings == 0 {
println!("\nNo problems found."); println!("\nNo problems found.");
@@ -3599,6 +3899,43 @@ fn cmd_diagnose() -> Result<()> {
} }
} }
/// Ask Proton which account and address keys open, and which of them the My
/// Files share needs. The findings hold key IDs and no secrets.
fn diagnose_keys(report: &mut DiagnoseReport) {
use pdfs_core::keycheck::{self, Level};
let checked = tokio::runtime::Runtime::new()
.map_err(anyhow::Error::from)
.and_then(|rt| {
rt.block_on(async {
tokio::time::timeout(std::time::Duration::from_secs(30), keycheck::run())
.await
.map_err(|_| anyhow!("Proton did not answer within 30 seconds"))?
.map_err(anyhow::Error::from)
})
});
let (keys, share) = match checked {
Ok(checked) => checked,
Err(e) => {
report.finding(DiagnoseLevel::Warn, " key check", format!("{e:#}"));
return;
}
};
let findings = keycheck::findings(&keys, &share);
let any_failed = findings.iter().any(|f| f.level != Level::Ok);
for finding in findings {
let level = match finding.level {
Level::Ok => DiagnoseLevel::Ok,
Level::Warn => DiagnoseLevel::Warn,
Level::Fail => DiagnoseLevel::Fail,
};
report.finding(level, &format!(" {}", finding.label), finding.detail);
}
if any_failed {
println!(" (key IDs are not secret; include this section when you report it)");
}
}
/// Send one request to the running mount daemon's control socket and read its /// Send one request to the running mount daemon's control socket and read its
/// reply. Errors if no daemon is listening. /// reply. Errors if no daemon is listening.
fn control_request(req: CtlRequest) -> Result<CtlResponse> { fn control_request(req: CtlRequest) -> Result<CtlResponse> {
@@ -3695,6 +4032,28 @@ mod login_wait_tests {
assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15)); assert_eq!(login_poll_delay(u32::MAX), Duration::from_secs(15));
} }
/// The file-manager script passes each selected path after `--`, so a
/// file named like an option is still the path.
#[test]
fn a_path_after_a_double_dash_is_a_path_even_when_it_looks_like_a_flag() {
let cli = Cli::try_parse_from(["pdfs", "pin", "--", "--json"]).unwrap();
assert!(!cli.json);
match cli.command {
Command::Pin { path } => assert_eq!(path, PathBuf::from("--json")),
_ => panic!("not a pin"),
}
}
#[test]
fn a_waited_for_run_that_did_not_fully_succeed_exits_with_an_error() {
assert!(finished_run("the import", Some((0, false, None))).is_ok());
assert!(finished_run("the import", Some((3, false, None))).is_err());
assert!(finished_run("the import", Some((0, true, None))).is_err());
let failed = finished_run("the import", Some((0, false, Some("not a zip".into()))));
assert!(failed.unwrap_err().to_string().contains("not a zip"));
assert!(finished_run("the import", None).is_err());
}
#[test] #[test]
fn waiting_for_a_session_stops_hammering_the_keyring() { fn waiting_for_a_session_stops_hammering_the_keyring() {
let mut checks_left = 20; let mut checks_left = 20;
@@ -3751,6 +4110,13 @@ mod login_wait_tests {
assert_eq!(slept, 0); assert_eq!(slept, 0);
} }
#[test]
fn a_link_flag_pair_keeps_removes_or_sets() {
assert_eq!(link_change::<i64>(None, false), LinkChange::Keep);
assert_eq!(link_change::<i64>(None, true), LinkChange::Remove);
assert_eq!(link_change(Some(5), false), LinkChange::Set(5));
}
#[test] #[test]
fn epoch_seconds_format_as_utc_calendar_dates() { fn epoch_seconds_format_as_utc_calendar_dates() {
assert_eq!(format_epoch_utc(0), "1970-01-01"); assert_eq!(format_epoch_utc(0), "1970-01-01");
@@ -3767,6 +4133,7 @@ mod login_wait_tests {
size: 1, size: 1,
modified: 0, modified: 0,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: "vol~link".into(), uid: "vol~link".into(),
path: String::new(), path: String::new(),
+1
View File
@@ -30,3 +30,4 @@ async-trait.workspace = true
libc.workspace = true libc.workspace = true
zeroize.workspace = true zeroize.workspace = true
base64.workspace = true base64.workspace = true
exif = { version = "0.6", package = "kamadak-exif" }
+54 -3
View File
@@ -516,8 +516,59 @@ pub fn lock(forget: bool) -> Result<()> {
Ok(()) Ok(())
} }
/// Forget the persisted session (best-effort; absent entry is not an error). /// How long [`logout`] waits for Proton to end the session before it signs
pub fn logout() -> Result<()> { /// out locally without.
const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10);
/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false
/// when there is none or Proton could not be reached in time.
///
/// Runs on a thread of its own, so it works the same whether the caller is
/// inside a tokio runtime or not.
fn end_server_session() -> bool {
let Ok(stored) = load() else {
return false;
};
let ended = std::thread::scope(|scope| {
scope
.spawn(|| -> std::result::Result<(), String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let session = resume_stored(&stored).map_err(|e| e.to_string())?;
match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await {
Ok(result) => result.map_err(|e| e.to_string()),
Err(_) => Err(format!(
"no answer within {}s",
END_SESSION_TIMEOUT.as_secs()
)),
}
})
})
.join()
.unwrap_or_else(|_| Err("the sign-out thread panicked".into()))
});
match ended {
Ok(()) => true,
Err(error) => {
tracing::warn!(%error, "ending the session on Proton failed; signing out locally");
false
}
}
}
/// End the session on Proton, then forget it here. Returns whether Proton
/// ended it: offline, the session stays valid on Proton's side until it
/// expires or is signed out from the account's security settings.
///
/// Before this, signing out only deleted the keyring entry. The tokens stayed
/// valid on Proton, so a copy of them taken from this machine still worked.
///
/// The local half is best effort; an absent entry is not an error.
pub fn logout() -> Result<bool> {
let ended = end_server_session();
// The persisted entity cache describes the account being logged out of. It // The persisted entity cache describes the account being logged out of. It
// is encrypted under a key derived from that account's passphrases, but // is encrypted under a key derived from that account's passphrases, but
// leaving it behind would also mean the next account inherits a store it can // leaving it behind would also mean the next account inherits a store it can
@@ -539,7 +590,7 @@ pub fn logout() -> Result<()> {
} }
let _ = kernelkey::UNSAVED_TOKENS.clear(); let _ = kernelkey::UNSAVED_TOKENS.clear();
match keyring_entry()?.delete_credential() { match keyring_entry()?.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
+207 -3
View File
@@ -163,6 +163,10 @@ pub struct AppConfig {
/// Defaulted for configs predating the field. /// Defaulted for configs predating the field.
#[serde(default)] #[serde(default)]
pub files_view: FilesView, pub files_view: FilesView,
/// The desktop app's main window size, remembered between runs. Defaulted
/// for configs predating the field.
#[serde(default)]
pub window: WindowState,
/// The user hid the tray icon from its own menu. The tray exits at start /// The user hid the tray icon from its own menu. The tray exits at start
/// while this is set, and the app does not spawn it; Preferences turns it /// while this is set, and the app does not spawn it; Preferences turns it
/// back on. Defaulted for configs predating the field. /// back on. Defaulted for configs predating the field.
@@ -186,6 +190,24 @@ pub struct AppConfig {
/// the field. /// the field.
#[serde(default)] #[serde(default)]
pub local_first: Option<bool>, pub local_first: Option<bool>,
/// Ask GitHub once a day whether a newer release is out, and say so in the
/// desktop app. Off by default: GitHub then sees the address and version
/// asking. Never used where a package manager installs updates (see
/// [`crate::update::updates_managed`]). Defaulted for configs predating the
/// field.
#[serde(default)]
pub update_check: bool,
/// When the update check last asked GitHub, in Unix seconds, so a restart
/// does not ask again within [`crate::update::CHECK_INTERVAL`]. Defaulted
/// for configs predating the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_last_checked: Option<i64>,
/// The latest release that check found, such as `3.6.0`, so the app can
/// say so again after a restart without asking. Older than or equal to the
/// running version once the update is installed. Defaulted for configs
/// predating the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_latest: Option<String>,
} }
/// The My Files page's view choices, remembered between runs. /// The My Files page's view choices, remembered between runs.
@@ -211,6 +233,44 @@ impl Default for FilesView {
} }
} }
/// The desktop app's main window, as it was when it last closed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(default)]
pub struct WindowState {
/// The unmaximised size, in logical pixels.
pub width: i32,
pub height: i32,
pub maximized: bool,
}
impl WindowState {
/// Below this the saved size is taken for damage and the default is used.
pub const MIN_WIDTH: i32 = 480;
pub const MIN_HEIGHT: i32 = 400;
/// This state, with a size too small to use replaced by the default.
pub fn usable(self) -> Self {
if self.width < Self::MIN_WIDTH || self.height < Self::MIN_HEIGHT {
Self {
maximized: self.maximized,
..Self::default()
}
} else {
self
}
}
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: 980,
height: 680,
maximized: false,
}
}
}
/// The key a My Files listing is ordered by. /// The key a My Files listing is ordered by.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@@ -310,6 +370,22 @@ impl AppConfig {
} }
} }
/// Fail when the config file at `path` exists but does not parse, so that
/// [`AppDirs::save_config`] never writes over settings it could not read.
fn refuse_to_replace_unreadable(path: &Path) -> Result<()> {
match std::fs::read_to_string(path) {
Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
Ok(_) => Ok(()),
Err(e) => Err(Error::Other(format!(
"{} cannot be read ({e}); fix it or move it aside before changing settings",
path.display()
))),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Resolved XDG locations for state, cache, and the default mountpoint. /// Resolved XDG locations for state, cache, and the default mountpoint.
pub struct AppDirs { pub struct AppDirs {
dirs: ProjectDirs, dirs: ProjectDirs,
@@ -341,6 +417,7 @@ impl AppDirs {
// Preserve the original bytes for diagnosis/recovery. In // Preserve the original bytes for diagnosis/recovery. In
// particular, never replace a truncated config with defaults // particular, never replace a truncated config with defaults
// and silently lose mount, device, or ignore settings. // and silently lose mount, device, or ignore settings.
// `save_config` refuses to write over it for the same reason.
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it"); tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
return AppConfig::default(); return AppConfig::default();
} }
@@ -348,9 +425,8 @@ impl AppDirs {
} }
let default_config = AppConfig::default(); let default_config = AppConfig::default();
if let Ok(content) = serde_json::to_string_pretty(&default_config) { if let Err(e) = self.save_config(&default_config) {
let _ = std::fs::create_dir_all(self.dirs.config_dir()); tracing::warn!(path = %path.display(), error = %e, "could not write the default config");
let _ = std::fs::write(&path, content);
} }
default_config default_config
} }
@@ -358,8 +434,15 @@ impl AppDirs {
/// Persist `config` to [`config_path`](Self::config_path), creating the /// Persist `config` to [`config_path`](Self::config_path), creating the
/// config dir if missing. The Settings page calls this after editing the /// config dir if missing. The Settings page calls this after editing the
/// cache budget or mountpoint; the daemon re-reads the file on its next mount. /// cache budget or mountpoint; the daemon re-reads the file on its next mount.
///
/// Refuses while the file on disk does not parse. Every caller loads,
/// changes one field and saves, and [`load_config`](Self::load_config)
/// gives defaults for a file it cannot read, so the first change after
/// the file was damaged wrote defaults over the mountpoint, the device and
/// the ignore rules the user had (`docs/BUGS.md` B223).
pub fn save_config(&self, config: &AppConfig) -> Result<()> { pub fn save_config(&self, config: &AppConfig) -> Result<()> {
let path = self.config_path(); let path = self.config_path();
refuse_to_replace_unreadable(&path)?;
let parent = path let parent = path
.parent() .parent()
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?; .ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
@@ -383,6 +466,46 @@ impl AppDirs {
Ok(()) Ok(())
} }
/// This machine's id (e.g. ~/.config/proton-drive-linux/machine-id). See
/// [`machine_id`](Self::machine_id).
pub fn machine_id_path(&self) -> PathBuf {
self.dirs.config_dir().join("machine-id")
}
/// A random id for this machine, made on first use and kept in
/// [`machine_id_path`](Self::machine_id_path).
///
/// It names this machine's profile in a device it may share with other
/// computers. Random rather than `/etc/machine-id`, so nothing that
/// identifies the host goes to Drive. In a file of its own rather than
/// `config.json`, because the app saves the config from a copy it loaded
/// earlier, and that would drop an id the daemon had just added.
pub fn machine_id(&self) -> Result<String> {
let path = self.machine_id_path();
match std::fs::read_to_string(&path) {
Ok(id) if is_machine_id(id.trim()) => return Ok(id.trim().to_string()),
Ok(_) => {
tracing::warn!(path = %path.display(), "machine id is unusable; making a new one");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
let mut bytes = [0u8; 16];
use std::io::Read as _;
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
let parent = path
.parent()
.ok_or_else(|| Error::Other("machine id path has no parent".into()))?;
std::fs::create_dir_all(parent)?;
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
std::fs::write(&tmp, format!("{id}\n"))?;
std::fs::File::open(&tmp)?.sync_all()?;
std::fs::rename(&tmp, &path)?;
std::fs::File::open(parent)?.sync_all()?;
Ok(id)
}
/// Persistent state: inode map / cache index DB lives here. /// Persistent state: inode map / cache index DB lives here.
pub fn state_dir(&self) -> PathBuf { pub fn state_dir(&self) -> PathBuf {
// `state_dir` is Linux-only in `directories`; fall back to data dir. // `state_dir` is Linux-only in `directories`; fall back to data dir.
@@ -471,6 +594,12 @@ impl AppDirs {
} }
} }
/// Whether `id` is what [`AppDirs::machine_id`] writes: 32 lowercase hex
/// digits.
fn is_machine_id(id: &str) -> bool {
id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes, /// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
/// plaintext names, and an authenticated control socket, so failure is fatal. /// plaintext names, and an authenticated control socket, so failure is fatal.
fn restrict_dir(dir: &Path) -> Result<()> { fn restrict_dir(dir: &Path) -> Result<()> {
@@ -526,6 +655,28 @@ mod tests {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777 std::fs::metadata(p).unwrap().permissions().mode() & 0o777
} }
/// A damaged config must not be replaced by defaults the next time a
/// setting changes (`docs/BUGS.md` B223).
#[test]
fn an_unreadable_config_is_never_written_over() {
let dir = std::env::temp_dir().join(format!(
"pdfs-cfg-guard-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.json");
assert!(refuse_to_replace_unreadable(&path).is_ok(), "no file yet");
std::fs::write(&path, serde_json::to_vec(&AppConfig::default()).unwrap()).unwrap();
assert!(refuse_to_replace_unreadable(&path).is_ok());
std::fs::write(&path, br#"{"mountpoint": "/home/me/Pro"#).unwrap();
assert!(refuse_to_replace_unreadable(&path).is_err(), "truncated");
let _ = std::fs::remove_dir_all(&dir);
}
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these /// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
/// directories hold decrypted content, a plaintext index of every node name, /// directories hold decrypted content, a plaintext index of every node name,
/// and the control socket that commands the daemon's session. /// and the control socket that commands the daemon's session.
@@ -611,10 +762,18 @@ mod tests {
descending: true, descending: true,
folders_first: false, folders_first: false,
}, },
window: WindowState {
width: 1200,
height: 800,
maximized: true,
},
tray_hidden: true, tray_hidden: true,
language: Some("de".to_string()), language: Some("de".to_string()),
online_map: true, online_map: true,
local_first: Some(false), local_first: Some(false),
update_check: true,
update_last_checked: Some(1_760_000_000),
update_latest: Some("3.6.0".to_string()),
}; };
let json = serde_json::to_string(&config).unwrap(); let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap(); let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -626,10 +785,46 @@ mod tests {
assert_eq!(decoded.proton_theme, Some(true)); assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden); assert!(decoded.tray_hidden);
assert!(decoded.online_map); assert!(decoded.online_map);
assert!(decoded.update_check);
assert_eq!(decoded.update_last_checked, Some(1_760_000_000));
assert_eq!(decoded.update_latest.as_deref(), Some("3.6.0"));
assert_eq!(decoded.language.as_deref(), Some("de")); assert_eq!(decoded.language.as_deref(), Some("de"));
assert_eq!(decoded.upload_limit, Some(500_000)); assert_eq!(decoded.upload_limit, Some(500_000));
assert_eq!(decoded.download_limit, None); assert_eq!(decoded.download_limit, None);
assert_eq!(decoded.files_view, config.files_view); assert_eq!(decoded.files_view, config.files_view);
assert_eq!(decoded.window, config.window);
}
#[test]
fn a_config_without_a_window_opens_at_the_default_size() {
let decoded: AppConfig =
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
assert_eq!(decoded.window, WindowState::default());
let partial: WindowState = serde_json::from_str(r#"{"maximized":true}"#).unwrap();
assert_eq!((partial.width, partial.height), (980, 680));
assert!(partial.maximized);
}
#[test]
fn a_window_saved_too_small_opens_at_the_default_size() {
let tiny = WindowState {
width: 0,
height: 500,
maximized: true,
};
assert_eq!(
tiny.usable(),
WindowState {
maximized: true,
..WindowState::default()
}
);
let fine = WindowState {
width: 640,
height: 480,
maximized: false,
};
assert_eq!(fine.usable(), fine);
} }
#[test] #[test]
@@ -760,6 +955,15 @@ mod tests {
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce)); assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
} }
#[test]
fn a_machine_id_is_32_lowercase_hex_digits() {
assert!(is_machine_id("0123456789abcdef0123456789abcdef"));
assert!(!is_machine_id("0123456789ABCDEF0123456789ABCDEF"));
assert!(!is_machine_id("0123456789abcdef"));
assert!(!is_machine_id("../../../../etc/passwd/0123456789"));
assert!(!is_machine_id(""));
}
#[test] #[test]
fn a_config_predating_ignore_patterns_still_loads() { fn a_config_predating_ignore_patterns_still_loads() {
// Every installed config on disk looks like this; the field must not be // Every installed config on disk looks like this; the field must not be
+170 -3
View File
@@ -344,6 +344,17 @@ pub enum Request {
/// or a mirror synced folder), and the move is still made on Proton Drive /// or a mirror synced folder), and the move is still made on Proton Drive
/// without transferring content. Replies with [`Response::Ok`]. /// without transferring content. Replies with [`Response::Ok`].
Move { path: String, new_parent: String }, Move { path: String, new_parent: String },
/// Copy a file or folder into a folder on Proton Drive, without
/// downloading or uploading it. Both `path` and `new_parent` are
/// mountpoint-relative, or absolute paths inside My files. A name already
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
/// [`Response::Ok`].
Copy { path: String, new_parent: String },
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
/// holds. Replies with [`Response::FolderSize`].
FolderSize { path: String },
/// The by-uid twin of [`Request::FolderSize`].
FolderSizeByUid { uid: String },
/// Trash a file or folder. `path` is mountpoint-relative. Replies with /// Trash a file or folder. `path` is mountpoint-relative. Replies with
/// [`Response::Ok`]. /// [`Response::Ok`].
Delete { path: String }, Delete { path: String },
@@ -469,6 +480,11 @@ pub enum Request {
/// Pause or resume one mirror folder's reconcile, independent of the /// Pause or resume one mirror folder's reconcile, independent of the
/// global pause. Persisted until resumed. Replies with [`Response::Ok`]. /// global pause. Persisted until resumed. Replies with [`Response::Ok`].
SetSyncFolderPaused { id: i64, paused: bool }, SetSyncFolderPaused { id: i64, paused: bool },
/// Forget what a mirror folder synced before, so its next pass compares
/// both sides afresh: what is on one side only is copied to the other, and
/// a file that differs keeps both versions. Nothing is deleted. Replies
/// with [`Response::Ok`].
ResetSyncFolder { id: i64 },
/// Force a reconcile pass: one folder by id, or all when `id` is `None`. /// Force a reconcile pass: one folder by id, or all when `id` is `None`.
/// Replies with [`Response::Ok`]. /// Replies with [`Response::Ok`].
SyncNow { id: Option<i64> }, SyncNow { id: Option<i64> },
@@ -596,6 +612,24 @@ pub enum Request {
RemovePublicLink { path: String, id: String }, RemovePublicLink { path: String, id: String },
/// The by-uid twin of [`Request::RemovePublicLink`]. /// The by-uid twin of [`Request::RemovePublicLink`].
RemovePublicLinkByUid { uid: String, id: String }, RemovePublicLinkByUid { uid: String, id: String },
/// Change the public link `id` on the node at `path`, keeping its URL.
/// `role` is "viewer" or "editor", or `None` to keep the current one.
/// Replies with [`Response::PublicLink`].
UpdatePublicLink {
path: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// The by-uid twin of [`Request::UpdatePublicLink`].
UpdatePublicLinkByUid {
uid: String,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
},
/// Stop sharing the node at `path`: remove its public link, every pending /// Stop sharing the node at `path`: remove its public link, every pending
/// invitation and every member. Each removal is attempted even if an /// invitation and every member. Each removal is attempted even if an
/// earlier one fails. Replies with [`Response::Ok`], or an error naming /// earlier one fails. Replies with [`Response::Ok`], or an error naming
@@ -1075,6 +1109,18 @@ pub struct PublicLinkInfo {
pub created: i64, pub created: i64,
} }
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
/// custom password or its expiry.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum LinkChange<T> {
/// Leave the option as it is.
Keep,
/// Remove the option: no custom password, or no expiry.
Remove,
/// Set the option to this value.
Set(T),
}
/// One invitation addressed to me (in [`Response::Invitations`]). /// One invitation addressed to me (in [`Response::Invitations`]).
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct InvitationInfo { pub struct InvitationInfo {
@@ -1169,6 +1215,7 @@ pub enum ActivityKind {
Sync, Sync,
Rename, Rename,
Move, Move,
Copy,
CreateFolder, CreateFolder,
Trash, Trash,
Restore, Restore,
@@ -1260,8 +1307,15 @@ pub struct DirEntry {
pub size: u64, pub size: u64,
/// Modification time, epoch seconds. /// Modification time, epoch seconds.
pub modified: i64, pub modified: i64,
/// Whether the file is pinned to this device. /// Whether the node is pinned to this device, on its own or through a
/// pinned folder above it.
pub pinned: bool, pub pinned: bool,
/// Whether the node is kept on this device because a folder above it is
/// pinned. Such a node cannot be unpinned on its own. Set in
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
/// daemons predating the field.
#[serde(default)]
pub pinned_by_folder: bool,
/// Whether the file's full content is present in the local cache (a current, /// Whether the file's full content is present in the local cache (a current,
/// non-stale blob). Always false for folders. Defaulted for wire-compat with /// non-stale blob). Always false for folders. Defaulted for wire-compat with
/// clients/daemons predating the field. /// clients/daemons predating the field.
@@ -1584,6 +1638,10 @@ pub struct RedateSummary {
/// name implies)` — what a dry run is read for. /// name implies)` — what a dry run is read for.
#[serde(default)] #[serde(default)]
pub samples: Vec<(String, i64, i64)>, pub samples: Vec<(String, i64, i64)>,
/// Why the run as a whole failed, when it did. The counts are then
/// empty: the run never got to the photos.
#[serde(default)]
pub error: Option<String>,
} }
/// How a Google Photos Takeout import is going, or how the last one ended /// How a Google Photos Takeout import is going, or how the last one ended
@@ -1613,6 +1671,10 @@ pub struct ImportSummary {
pub bytes: u64, pub bytes: u64,
/// True when the run stopped early because it was cancelled. /// True when the run stopped early because it was cancelled.
pub cancelled: bool, pub cancelled: bool,
/// Why the run as a whole failed, when it did, such as an archive that
/// is not a zip. The counts are then empty.
#[serde(default)]
pub error: Option<String>,
} }
/// One album in a [`Response::Albums`] listing. /// One album in a [`Response::Albums`] listing.
@@ -1850,6 +1912,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
} }
} }
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
/// older release than this build. Installing a package replaces the binaries
/// but leaves the running user service on the old one, which keeps its bugs
/// until it restarts, while the app and the CLI already describe the new
/// release (#33). Empty means a daemon from before the field, so older too.
///
/// Only older counts. A newer daemon means this front-end is the one still
/// running from before the upgrade, and restarting the service would not help.
pub fn daemon_outdated(version: &str) -> bool {
fn parse(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.split('.').map(|p| p.parse().ok());
let parsed = (parts.next()??, parts.next()??, parts.next()??);
parts.next().is_none().then_some(parsed)
}
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
(Some(running), Some(this)) => running < this,
(None, _) => version.is_empty(),
(Some(_), None) => false,
}
}
/// One worker thread in a [`Diagnostics`] report. /// One worker thread in a [`Diagnostics`] report.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct WorkerState { pub struct WorkerState {
@@ -1969,6 +2052,11 @@ pub enum Response {
/// out. /// out.
#[serde(default)] #[serde(default)]
tokens_unsaved: bool, tokens_unsaved: bool,
/// The release of the daemon that answered. A package upgrade does not
/// restart the user service, so it can be older than the front-end
/// asking; daemons before 3.3.0 omit it, which reads as empty.
#[serde(default)]
version: String,
}, },
/// A human-readable success message. /// A human-readable success message.
Ok { message: String }, Ok { message: String },
@@ -2144,9 +2232,14 @@ pub enum Response {
entries: Vec<ShareEntry>, entries: Vec<ShareEntry>,
link: Option<PublicLinkInfo>, link: Option<PublicLinkInfo>,
}, },
/// A freshly created public link, carrying the shareable URL (reply to /// A freshly created or changed public link, carrying the shareable URL
/// [`Request::CreatePublicLink`]). /// (reply to [`Request::CreatePublicLink`] and
/// [`Request::UpdatePublicLink`]).
PublicLink { link: PublicLinkInfo }, PublicLink { link: PublicLinkInfo },
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
/// the bytes on storage and the number of items below it, folders and
/// trashed items included.
FolderSize { size: u64, items: u64 },
/// Invitations addressed to me (reply to [`Request::ListInvitations`]). /// Invitations addressed to me (reply to [`Request::ListInvitations`]).
Invitations { items: Vec<InvitationInfo> }, Invitations { items: Vec<InvitationInfo> },
/// Saved public links (reply to [`Request::ListBookmarks`]). /// Saved public links (reply to [`Request::ListBookmarks`]).
@@ -2612,6 +2705,27 @@ mod tests {
); );
} }
/// #33: a daemon left running over an upgrade is named as older, also when
/// it predates the version field, but a newer one is not, since restarting
/// the service cannot bring it closer to this build.
#[test]
fn only_an_older_daemon_is_outdated() {
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
assert!(daemon_outdated(""));
assert!(daemon_outdated("0.9.9"));
assert!(!daemon_outdated("999.0.0"));
assert!(!daemon_outdated("not-a-version"));
let old: Response = serde_json::from_str(
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
"used":0,"budget":0,"pins":[]}}"#,
)
.unwrap();
let Response::Status { version, .. } = old else {
panic!("not a status: {old:?}");
};
assert!(daemon_outdated(&version));
}
/// The mutation requests must survive a line-delimited JSON round-trip, since /// The mutation requests must survive a line-delimited JSON round-trip, since
/// that is exactly how they cross the control socket. /// that is exactly how they cross the control socket.
#[test] #[test]
@@ -2625,6 +2739,10 @@ mod tests {
path: "a/b.txt".into(), path: "a/b.txt".into(),
new_parent: "d".into(), new_parent: "d".into(),
}, },
Request::Copy {
path: "a/b.txt".into(),
new_parent: "d".into(),
},
Request::Delete { Request::Delete {
path: "a/b.txt".into(), path: "a/b.txt".into(),
}, },
@@ -3096,6 +3214,7 @@ mod tests {
id: 3, id: 3,
paused: true, paused: true,
}, },
Request::ResetSyncFolder { id: 3 },
Request::SetBandwidthLimits { Request::SetBandwidthLimits {
upload: 1_000_000, upload: 1_000_000,
download: 0, download: 0,
@@ -3185,6 +3304,24 @@ mod tests {
uid: "vol~link".into(), uid: "vol~link".into(),
id: "url-1".into(), id: "url-1".into(),
}, },
Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: Some("editor".into()),
password: LinkChange::Set("pw".into()),
expires: LinkChange::Remove,
},
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
},
Request::FolderSize { path: "a".into() },
Request::FolderSizeByUid {
uid: "vol~link".into(),
},
Request::ListSharedByMe, Request::ListSharedByMe,
Request::ListActivity { limit: 100 }, Request::ListActivity { limit: 100 },
Request::ListSharedWithMe, Request::ListSharedWithMe,
@@ -3559,6 +3696,16 @@ mod tests {
}, },
"RemovePublicLinkByUid", "RemovePublicLinkByUid",
), ),
(
Request::UpdatePublicLinkByUid {
uid: "vol~link".into(),
id: "link".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Keep,
},
"UpdatePublicLinkByUid",
),
( (
Request::StopSharingByUid { Request::StopSharingByUid {
uid: "vol~link".into(), uid: "vol~link".into(),
@@ -3584,6 +3731,7 @@ mod tests {
| Request::RemoveShareEntryByUid { uid, .. } | Request::RemoveShareEntryByUid { uid, .. }
| Request::CreatePublicLinkByUid { uid, .. } | Request::CreatePublicLinkByUid { uid, .. }
| Request::RemovePublicLinkByUid { uid, .. } | Request::RemovePublicLinkByUid { uid, .. }
| Request::UpdatePublicLinkByUid { uid, .. }
| Request::StopSharingByUid { uid } => uid, | Request::StopSharingByUid { uid } => uid,
other => panic!("decoded into the wrong dispatch variant: {other:?}"), other => panic!("decoded into the wrong dispatch variant: {other:?}"),
}; };
@@ -3592,6 +3740,25 @@ mod tests {
} }
} }
#[test]
fn a_link_update_names_what_it_keeps_removes_and_sets() {
let request = Request::UpdatePublicLink {
path: "a/b".into(),
id: "url-1".into(),
role: None,
password: LinkChange::Keep,
expires: LinkChange::Set(1_700_000_000),
};
assert_eq!(
serde_json::to_string(&request).unwrap(),
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
);
assert_eq!(
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
r#""Remove""#
);
}
#[test] #[test]
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() { fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#; let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
+71
View File
@@ -111,9 +111,80 @@ impl Db {
)?; )?;
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?; let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
tx.commit()?; tx.commit()?;
drop(conn);
self.compact_search_indexes_if_due(finished_at)?;
Ok(count) Ok(count)
} }
/// Close a scan that did not see everything: it stopped at the entry cap,
/// or a batch failed to write. Nothing is pruned, since a path it missed is
/// not a path that is gone; the next complete scan prunes. Returns the
/// number of indexed entries.
///
/// Pruning after such a scan dropped every entry it had not reached, and
/// the next scan put them back. That churn is most of what grew
/// `local_fts` to 16 times its size (`docs/BUGS.md` B217).
pub fn local_finish_partial_scan(&self, finished_at: i64) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[finished_at.to_string()],
)?;
let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
Ok(count)
}
/// Compact both full-text indexes, at most once a day.
///
/// FTS5 keeps a deleted entry's data until the segments holding it are
/// merged, and with the few large segments these indexes settle into that
/// is close to never. Every file the local scan sees disappear or appear
/// leaves some behind, and every node row the tree replaces does too: on
/// one machine `local_fts` held 317 MiB for 40,000 paths, against 20 MiB
/// rebuilt, and a search took four times as long (`docs/BUGS.md` B217).
///
/// `local_fts` is rebuilt from `local_files`, which also drops anything
/// left over, and `nodes_fts`, which has no content table to rebuild from,
/// is merged into one segment. Each runs in its own transaction on the
/// write connection, about half a second on an index that size; once a
/// day keeps the leftovers small without paying that at every scan.
pub fn compact_search_indexes_if_due(&self, now: i64) -> Result<()> {
const EVERY_SECS: i64 = 24 * 60 * 60;
let last: i64 = self
.read()
.query_row(
"SELECT value FROM sync_state WHERE key = 'fts_compacted_at'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if now - last < EVERY_SECS {
return Ok(());
}
let started = std::time::Instant::now();
{
let conn = self.conn.lock();
conn.execute("INSERT INTO local_fts(local_fts) VALUES('rebuild')", [])?;
}
{
let conn = self.conn.lock();
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('optimize')", [])?;
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('fts_compacted_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[now.to_string()],
)?;
}
tracing::info!(
elapsed_ms = started.elapsed().as_millis() as u64,
"compacted the search indexes"
);
Ok(())
}
/// When the last local scan completed (epoch seconds), or `None` if the index /// When the last local scan completed (epoch seconds), or `None` if the index
/// has never been built. The daemon uses this to decide whether a fresh mount /// has never been built. The daemon uses this to decide whether a fresh mount
/// needs an immediate rescan or can serve the existing index. /// needs an immediate rescan or can serve the existing index.
+44 -3
View File
@@ -91,8 +91,13 @@ impl Db {
/// Returns an empty vector when the database is sound; otherwise each entry /// Returns an empty vector when the database is sound; otherwise each entry
/// is one problem SQLite found. Read-only and safe against a live daemon, /// is one problem SQLite found. Read-only and safe against a live daemon,
/// though it reads every page, so it is not free on a large file. /// though it reads every page, so it is not free on a large file.
///
/// Runs on a read connection: it reads every page, which took 34 s on a
/// 567 MiB file, and on the write connection every write in the daemon
/// waited that long behind it (`docs/BUGS.md` B218). WAL lets it read a
/// consistent snapshot while the writer goes on.
pub fn integrity_check(&self) -> Result<Vec<String>> { pub fn integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn.lock(); let conn = self.read();
let mut stmt = conn.prepare("PRAGMA integrity_check")?; let mut stmt = conn.prepare("PRAGMA integrity_check")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut problems = Vec::new(); let mut problems = Vec::new();
@@ -107,9 +112,11 @@ impl Db {
Ok(problems) Ok(problems)
} }
/// Size and row-count snapshot for `pdfs cache inspect`. /// Size and row-count snapshot for `pdfs cache inspect`. On a read
/// connection, for the reason [`integrity_check`](Self::integrity_check)
/// gives: counting the rows of every table reads most of the file.
pub fn stats(&self) -> Result<DbStats> { pub fn stats(&self) -> Result<DbStats> {
let conn = self.conn.lock(); let conn = self.read();
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?; let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?; let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?; let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
@@ -218,6 +225,40 @@ mod tests {
assert!(db.integrity_check().unwrap().is_empty()); assert!(db.integrity_check().unwrap().is_empty());
} }
/// `pdfs diagnose` and `pdfs cache inspect` read the whole file. Behind the
/// write lock they froze every write in the daemon for as long as that
/// took (`docs/BUGS.md` B218).
#[test]
fn the_integrity_check_and_stats_do_not_wait_for_the_writer() {
let path = std::env::temp_dir().join(format!(
"pdfs-check-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let db = std::sync::Arc::new(Db::open(&path).unwrap());
let writer = db.conn.lock();
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::sync::Arc::clone(&db);
std::thread::spawn(move || {
let checked = reader.integrity_check().map(|p| p.is_empty());
let counted = reader.stats().map(|s| s.page_count > 0);
let _ = tx.send((checked.ok(), counted.ok()));
});
let answer = rx.recv_timeout(std::time::Duration::from_secs(10));
drop(writer);
assert_eq!(answer.ok(), Some((Some(true), Some(true))));
drop(db);
for suffix in ["", "-wal", "-shm"] {
let mut file = path.as_os_str().to_owned();
file.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
}
let _ = std::fs::remove_file(path.with_extension("db.lock"));
}
#[test] #[test]
fn stats_report_the_schema_and_the_known_tables() { fn stats_report_the_schema_and_the_known_tables() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+53 -1
View File
@@ -11,7 +11,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 42; pub(super) const SCHEMA_VERSION: i64 = 44;
impl Db { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -440,6 +440,33 @@ impl Db {
tx.execute_batch(MIGRATION_V42)?; tx.execute_batch(MIGRATION_V42)?;
} }
} }
if current < 43 {
let has_ops: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_ops {
tx.execute_batch(MIGRATION_V43)?;
}
}
if current < 44 {
// Same guards as V26-V42.
let has_column: bool = tx.query_row(
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
WHERE name = 'remote_revision_id'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
let has_entries: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
[],
|row| row.get::<_, i64>(0),
)? > 0;
if has_entries && !has_column {
tx.execute_batch(MIGRATION_V44)?;
}
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1330,3 +1357,28 @@ const MIGRATION_V42: &str = "
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT; ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
ALTER TABLE pending_op ADD COLUMN sent_to TEXT; ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
"; ";
/// Schema v43: queued ops by kind and name.
///
/// Before each create, rename and trash the drain asks whether a queued trash
/// or rename holds the name it wants, and a released name wakes the ops
/// waiting for it. Each of those scanned the whole queue, so a large offline
/// batch made draining it quadratic. The kind leads, so a lookup of every
/// queued rename uses the index too.
const MIGRATION_V43: &str = "
CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
";
/// Schema v44: a synced file's baseline remembers the revision it was synced
/// at.
///
/// A mirrored file edited here is uploaded as a new revision of whatever is
/// current on Drive, and another computer syncing the same folder may have
/// committed one since this pass looked. The revision id is what the upload
/// checks first, so that edit becomes a conflict copy instead of being
/// replaced (B249). `NULL` means "this row predates the column", and the check
/// falls back to the modification time and size; each row gains a real value
/// the next time its path is synced.
const MIGRATION_V44: &str = "
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
";
+163 -15
View File
@@ -179,6 +179,45 @@ impl Drop for Reader<'_> {
} }
} }
/// The write connection with `synchronous=FULL`, back to NORMAL on drop.
///
/// NORMAL in WAL mode keeps a commit across a crash of the process but not
/// across a power loss: the WAL is synced only at checkpoints. That is fine for
/// metadata the next listing re-fetches, and not for the queue. A staged blob
/// is synced before its op is queued, and the op row is what makes it
/// reachable; losing that row loses a write `close()` already reported as
/// done. A FULL commit syncs the WAL, which covers every NORMAL commit before
/// it as well.
pub(super) struct Durable<'a>(parking_lot::MutexGuard<'a, Connection>);
impl<'a> Durable<'a> {
pub(super) fn new(conn: parking_lot::MutexGuard<'a, Connection>) -> Result<Self> {
conn.pragma_update(None, "synchronous", "FULL")?;
Ok(Self(conn))
}
}
impl std::ops::Deref for Durable<'_> {
type Target = Connection;
fn deref(&self) -> &Connection {
&self.0
}
}
impl std::ops::DerefMut for Durable<'_> {
fn deref_mut(&mut self) -> &mut Connection {
&mut self.0
}
}
impl Drop for Durable<'_> {
fn drop(&mut self) {
// A failure leaves the writer at FULL: slower, never less safe.
let _ = self.0.pragma_update(None, "synchronous", "NORMAL");
}
}
/// Open a second connection to the same file for reads only. /// Open a second connection to the same file for reads only.
/// ///
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops /// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
@@ -216,18 +255,34 @@ impl Db {
let conn = match Self::open_configured(path) { let conn = match Self::open_configured(path) {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) if is_corrupt(&e) => { Err(e) if is_corrupt(&e) => {
// Every byte in here is derived from the account or from the // Most of what is in here is derived from the account and is
// content cache directory, so the recovery is to start over — // fetched again. The queue is not: it holds writes accepted
// loudly, and keeping the damaged file for a post-mortem rather // here that Drive does not have yet, and the mirror baselines
// than deleting evidence. // and pins are this machine's alone. So the damaged file is
// moved aside, kept as evidence, and those tables are copied
// out of it into the new one as far as SQLite can still read
// them (`docs/BUGS.md` B216).
tracing::error!( tracing::error!(
db = %path.display(), db = %path.display(),
error = %e, error = %e,
"cache database is corrupt — moving it aside and rebuilding \ "cache database is corrupt; moving it aside, rebuilding, and \
(metadata will be re-fetched; no user data is stored here)" copying the upload queue, sync baselines and pins out of it"
); );
quarantine_corrupt_db(path)?; let damaged = quarantine_corrupt_db(path)?;
Self::open_configured(path)? let conn = Self::open_configured(path)?;
for (table, result) in salvage(&conn, &damaged) {
match result {
Ok(rows) => {
tracing::warn!(table, rows, "salvaged from the damaged database")
}
Err(error) => tracing::error!(
table,
%error,
"could not salvage from the damaged database"
),
}
}
conn
} }
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
@@ -254,6 +309,12 @@ impl Db {
Ok(db) Ok(db)
} }
/// The write connection for a commit that accepts a user's change into
/// the queue. See [`Durable`].
pub(super) fn durable(&self) -> Result<Durable<'_>> {
Durable::new(self.conn.lock())
}
/// A connection for a `SELECT`-only method. /// A connection for a `SELECT`-only method.
/// ///
/// Reads see the last committed state, which is the same thing they saw /// Reads see the last committed state, which is the same thing they saw
@@ -281,7 +342,8 @@ impl Db {
fn open_configured(path: &Path) -> Result<Connection> { fn open_configured(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
// WAL: readers never block the single writer. NORMAL sync is the // WAL: readers never block the single writer. NORMAL sync is the
// standard durability/throughput tradeoff for WAL. // standard durability/throughput tradeoff for WAL; the queue commits
// that need more raise it to FULL for themselves (`Durable`).
conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "foreign_keys", "ON")?;
@@ -428,12 +490,96 @@ fn is_corrupt(e: &Error) -> bool {
) )
} }
/// Move a damaged database (and its WAL sidecars) out of the way so the next /// The tables [`salvage`] copies out of a damaged database, in order, each
/// open starts from empty. /// with the rows it keeps.
/// ///
/// Renamed rather than deleted: this is a cache, but it is also the only /// The queue goes with the node rows it names by local id: a create made here
/// evidence of what went wrong, and it costs one file the user can remove. /// has no other record of its node, and a real node keeps its local id when its
fn quarantine_corrupt_db(path: &Path) -> Result<()> { /// folder is listed again. Those rows come back unlisted, so their folders are
/// fetched again rather than trusted. Claims are cleared by [`Db::open`] as
/// after any other crash.
const SALVAGE: &[(&str, &str)] = &[
(
"nodes",
"lid IN (SELECT lid FROM old.pending_op WHERE lid IS NOT NULL
UNION SELECT parent_lid FROM old.pending_op WHERE parent_lid IS NOT NULL)
OR uid LIKE 'local~%'",
),
("pending_op", "1"),
("sync_folder", "1"),
("sync_entry", "1"),
("pins", "1"),
("own_sealed_rev", "1"),
];
/// Copy what only this machine has out of the damaged database at `damaged`
/// into the fresh one `conn` just created. Returns how each table went.
///
/// Each table is copied in its own transaction, so a table SQLite cannot read
/// to the end is left out whole rather than half copied, and does not stop
/// the others. Only the columns both files have are copied, which covers a
/// file that broke in the middle of an upgrade. The damaged file is opened read
/// only and left as it was.
fn salvage(conn: &Connection, damaged: &Path) -> Vec<(&'static str, Result<usize>)> {
let uri = format!(
"file:{}?mode=ro",
damaged
.to_string_lossy()
.replace('%', "%25")
.replace('?', "%3f")
.replace('#', "%23")
);
if let Err(e) = conn.execute("ATTACH DATABASE ?1 AS old", [&uri]) {
let e = Error::Db(e);
tracing::error!(error = %e, "could not open the damaged database to salvage it");
return Vec::new();
}
let results = SALVAGE
.iter()
.map(|&(table, rows)| (table, salvage_table(conn, table, rows)))
.collect();
let _ = conn.execute("UPDATE nodes SET listed = 0", []);
if let Err(e) = conn.execute("DETACH DATABASE old", []) {
tracing::warn!(error = %e, "could not detach the damaged database");
}
results
}
fn salvage_table(conn: &Connection, table: &str, rows: &str) -> Result<usize> {
let columns = |schema: &str| -> Result<Vec<String>> {
let mut stmt = conn.prepare(&format!("PRAGMA {schema}.table_info({table})"))?;
let names = stmt
.query_map([], |r| r.get::<_, String>(1))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(names)
};
let new = columns("main")?;
let shared: Vec<String> = columns("old")?
.into_iter()
.filter(|c| new.contains(c))
.map(|c| format!("\"{c}\""))
.collect();
if shared.is_empty() {
return Ok(0);
}
let shared = shared.join(", ");
let tx = conn.unchecked_transaction()?;
let copied = tx.execute(
&format!(
"INSERT OR IGNORE INTO main.{table} ({shared}) SELECT {shared} FROM old.{table} WHERE {rows}"
),
[],
)?;
tx.commit()?;
Ok(copied)
}
/// Move a damaged database (and its WAL sidecars) out of the way so the next
/// open starts from empty. Returns where the database itself went.
///
/// Renamed rather than deleted: it is the only evidence of what went wrong,
/// and [`salvage`] reads what only this machine has back out of it.
fn quarantine_corrupt_db(path: &Path) -> Result<PathBuf> {
let stamp = std::time::SystemTime::now() let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
@@ -457,7 +603,9 @@ fn quarantine_corrupt_db(path: &Path) -> Result<()> {
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"), Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
} }
} }
Ok(()) let mut kept = path.as_os_str().to_owned();
kept.push(format!(".corrupt-{stamp}"));
Ok(PathBuf::from(kept))
} }
#[cfg(test)] #[cfg(test)]
+129 -21
View File
@@ -279,6 +279,45 @@ fn claimable() -> String {
/// drain replaces it with the uid the server assigns. /// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local"; pub const LOCAL_VOLUME: &str = "local";
/// One place a queued create was sent, with when it first was.
struct SentTarget {
parent: String,
name: String,
at_ms: Option<i64>,
}
/// The `sent_to` column: `[[parent, name, at_ms], …]`, or `[[parent, name], …]`
/// as written before the time was kept. `None` when it does not parse.
fn parse_sent_to(json: Option<&str>) -> Option<Vec<SentTarget>> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Entry {
At(String, String, i64),
Bare(String, String),
}
let Some(json) = json else {
return Some(Vec::new());
};
let entries: Vec<Entry> = serde_json::from_str(json).ok()?;
Some(
entries
.into_iter()
.map(|entry| match entry {
Entry::At(parent, name, at) => SentTarget {
parent,
name,
at_ms: Some(at),
},
Entry::Bare(parent, name) => SentTarget {
parent,
name,
at_ms: None,
},
})
.collect(),
)
}
/// The uid a node made on this machine goes by until Drive gives it one: its /// The uid a node made on this machine goes by until Drive gives it one: its
/// row's local id under [`LOCAL_VOLUME`]. /// row's local id under [`LOCAL_VOLUME`].
/// ///
@@ -541,7 +580,7 @@ impl Db {
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail, /// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
/// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it. /// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it.
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> { pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let mut conn = self.conn.lock(); let mut conn = self.durable()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
let superseded: Option<String> = if op_supersedes(&op.kind) { let superseded: Option<String> = if op_supersedes(&op.kind) {
let lid = node_lid(&tx, &op.uid)?; let lid = node_lid(&tx, &op.uid)?;
@@ -598,7 +637,7 @@ impl Db {
name: &str, name: &str,
created_at: i64, created_at: i64,
) -> Result<(i64, Vec<String>)> { ) -> Result<(i64, Vec<String>)> {
let mut conn = self.conn.lock(); let mut conn = self.durable()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
let blobs = drop_doomed_ops(&tx, uid)?; let blobs = drop_doomed_ops(&tx, uid)?;
tx.execute( tx.execute(
@@ -628,7 +667,7 @@ impl Db {
blob_path: &str, blob_path: &str,
meta_json: &str, meta_json: &str,
) -> Result<Option<AttachedBlob>> { ) -> Result<Option<AttachedBlob>> {
let conn = self.conn.lock(); let conn = self.durable()?;
let lid = node_lid(&conn, uid)?; let lid = node_lid(&conn, uid)?;
let existing: Option<(i64, Option<String>)> = conn let existing: Option<(i64, Option<String>)> = conn
.query_row( .query_row(
@@ -691,7 +730,7 @@ impl Db {
rewrite: impl FnOnce(&str) -> Option<String>, rewrite: impl FnOnce(&str) -> Option<String>,
) -> Result<CreateRetired> { ) -> Result<CreateRetired> {
let (local, real) = (landing.local, landing.real); let (local, real) = (landing.local, landing.real);
let mut conn = self.conn.lock(); let mut conn = self.durable()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
let row: Option<CreateRow> = tx let row: Option<CreateRow> = tx
.query_row( .query_row(
@@ -808,12 +847,19 @@ impl Db {
Ok(()) Ok(())
} }
/// Record that queued create `id` is being sent to `parent` under `name`. /// Record that queued create `id` is being sent to `parent` under `name`
/// at `at_ms`.
/// ///
/// A rename while the op waits rewrites its target, but the file a lost /// A rename while the op waits rewrites its target, but the file a lost
/// answer left stays where it was sent. The retry and the trash look /// answer left stays where it was sent. The retry and the trash look
/// there too (`docs/BUGS.md` B184). /// there too (`docs/BUGS.md` B184). A target sent before keeps its first
pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> { /// time.
///
/// A record that does not parse is left as it is rather than replaced
/// with this one target: the earlier ones are what the duplicate checks
/// look for. Without them a lost answer forks a conflict copy, which
/// loses nothing.
pub fn note_create_target(&self, id: i64, parent: &str, name: &str, at_ms: i64) -> Result<()> {
let mut conn = self.conn.lock(); let mut conn = self.conn.lock();
let tx = conn.transaction()?; let tx = conn.transaction()?;
let sent: Option<String> = tx let sent: Option<String> = tx
@@ -822,15 +868,26 @@ impl Db {
}) })
.optional()? .optional()?
.flatten(); .flatten();
let mut targets: Vec<(String, String)> = sent let Some(mut targets) = parse_sent_to(sent.as_deref()) else {
.and_then(|json| serde_json::from_str(&json).ok()) tracing::warn!(
.unwrap_or_default(); id,
let target = (parent.to_string(), name.to_string()); "a queued create's send record does not parse; keeping it"
if !targets.contains(&target) { );
targets.push(target); return Ok(());
};
if !targets.iter().any(|t| t.parent == parent && t.name == name) {
targets.push(SentTarget {
parent: parent.to_string(),
name: name.to_string(),
at_ms: Some(at_ms),
});
let json: Vec<(&str, &str, i64)> = targets
.iter()
.map(|t| (t.parent.as_str(), t.name.as_str(), t.at_ms.unwrap_or(0)))
.collect();
tx.execute( tx.execute(
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1", "UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
params![id, serde_json::to_string(&targets)?], params![id, serde_json::to_string(&json)?],
)?; )?;
} }
tx.commit()?; tx.commit()?;
@@ -840,6 +897,24 @@ impl Db {
/// The parent and name queued op `id` was sent under as a create, in the /// The parent and name queued op `id` was sent under as a create, in the
/// order it was ([`Db::note_create_target`]). /// order it was ([`Db::note_create_target`]).
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> { pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
Ok(self
.sent_targets(id)?
.into_iter()
.map(|t| (t.parent, t.name))
.collect())
}
/// When queued create `id` was first sent, if that was recorded. Rows
/// written before the time was kept have none.
pub fn create_first_sent(&self, id: i64) -> Result<Option<i64>> {
Ok(self
.sent_targets(id)?
.into_iter()
.filter_map(|t| t.at_ms.filter(|&at| at > 0))
.min())
}
fn sent_targets(&self, id: i64) -> Result<Vec<SentTarget>> {
let sent: Option<String> = self let sent: Option<String> = self
.conn .conn
.lock() .lock()
@@ -848,9 +923,7 @@ impl Db {
}) })
.optional()? .optional()?
.flatten(); .flatten();
Ok(sent Ok(parse_sent_to(sent.as_deref()).unwrap_or_default())
.and_then(|json| serde_json::from_str(&json).ok())
.unwrap_or_default())
} }
/// The SHA-1 of each blob queued op `id` sent as a create /// The SHA-1 of each blob queued op `id` sent as a create
@@ -898,6 +971,27 @@ impl Db {
Ok(n > 0) Ok(n > 0)
} }
/// Replace the sidecar of op `id`, but only while it still holds the blob
/// at `blob_path`.
///
/// The drain fills an incomplete blob in place and then records it as
/// whole, so a retry after a lost answer can recognise its own bytes on the
/// server. A write that closed meanwhile has pointed the row at a newer
/// blob with its own sidecar; that one must not be overwritten. Returns
/// false when the row is gone or holds another blob.
pub fn update_op_meta_for_blob(
&self,
id: i64,
blob_path: &str,
meta_json: &str,
) -> Result<bool> {
let n = self.conn.lock().execute(
"UPDATE pending_op SET meta_json = ?3 WHERE id = ?1 AND blob_path = ?2",
params![id, blob_path, meta_json],
)?;
Ok(n > 0)
}
/// Point a queued create at a new parent and name, for a node renamed or /// Point a queued create at a new parent and name, for a node renamed or
/// moved before it ever reached the server. /// moved before it ever reached the server.
/// ///
@@ -911,7 +1005,7 @@ impl Db {
/// name it no longer wants, such as one held by a queued change. A parked /// name it no longer wants, such as one held by a queued change. A parked
/// create stays parked. /// create stays parked.
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> { pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
let conn = self.conn.lock(); let conn = self.durable()?;
let lid = node_lid(&conn, uid)?; let lid = node_lid(&conn, uid)?;
let parent_lid = node_lid(&conn, parent_uid)?; let parent_lid = node_lid(&conn, parent_uid)?;
let n = conn.execute( let n = conn.execute(
@@ -1389,12 +1483,26 @@ impl Db {
} }
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at /// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
/// least once. Returns how many rows changed. /// least once, for the user's "Retry all". Returns how many rows changed.
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> { pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
self.retry_ops_now(now, "")
}
/// [`retry_failed_ops_now`](Self::retry_failed_ops_now) for the ops Drive
/// did not refuse, for a reconnect. A link coming back changes nothing
/// about a full quota or a missing permission, so those keep their backoff
/// instead of all being sent again at once.
pub fn retry_unrefused_ops_now(&self, now: i64) -> Result<usize> {
self.retry_ops_now(now, "AND issue IS NULL")
}
fn retry_ops_now(&self, now: i64, filter: &str) -> Result<usize> {
let conn = self.conn.lock(); let conn = self.conn.lock();
let changed = conn.execute( let changed = conn.execute(
"UPDATE pending_op SET next_attempt_at = ?1 &format!(
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2", "UPDATE pending_op SET next_attempt_at = ?1
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2 {filter}"
),
params![now, PARK_UNTIL], params![now, PARK_UNTIL],
)?; )?;
Ok(changed) Ok(changed)
+23 -12
View File
@@ -45,6 +45,12 @@ pub struct StoredSyncEntry {
pub local_mtime_ns: Option<i64>, pub local_mtime_ns: Option<i64>,
pub remote_rev: Option<String>, pub remote_rev: Option<String>,
pub remote_hash: Option<String>, pub remote_hash: Option<String>,
/// The id of the remote revision this row was synced at. A mirror upload
/// checks it before it supersedes what Drive holds, because another client
/// may have committed a revision since (`docs/BUGS.md` B249). `None` on a
/// row written before schema 44, or by a pass that did not learn it; the
/// check then falls back to `remote_rev` and `remote_hash`.
pub remote_revision_id: Option<String>,
} }
impl Db { impl Db {
@@ -188,7 +194,7 @@ impl Db {
let conn = self.read(); let conn = self.read();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns local_mtime_ns, remote_revision_id
FROM sync_entry WHERE folder_id = ?1", FROM sync_entry WHERE folder_id = ?1",
)?; )?;
let rows = stmt.query_map(params![folder_id], |r| { let rows = stmt.query_map(params![folder_id], |r| {
@@ -200,6 +206,7 @@ impl Db {
remote_rev: r.get(4)?, remote_rev: r.get(4)?,
remote_hash: r.get(5)?, remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?, local_mtime_ns: r.get(6)?,
remote_revision_id: r.get(7)?,
}) })
})?; })?;
let mut map = HashMap::new(); let mut map = HashMap::new();
@@ -238,15 +245,16 @@ impl Db {
conn.execute( conn.execute(
"INSERT INTO sync_entry "INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns) remote_hash, local_mtime_ns, remote_revision_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid, remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime, local_mtime = excluded.local_mtime,
local_size = excluded.local_size, local_size = excluded.local_size,
remote_rev = excluded.remote_rev, remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash, remote_hash = excluded.remote_hash,
local_mtime_ns = excluded.local_mtime_ns", local_mtime_ns = excluded.local_mtime_ns,
remote_revision_id = excluded.remote_revision_id",
params![ params![
folder_id, folder_id,
e.rel_path, e.rel_path,
@@ -256,6 +264,7 @@ impl Db {
e.remote_rev, e.remote_rev,
e.remote_hash, e.remote_hash,
e.local_mtime_ns, e.local_mtime_ns,
e.remote_revision_id,
], ],
)?; )?;
Ok(()) Ok(())
@@ -306,7 +315,7 @@ impl Db {
{ {
let mut select = tx.prepare( let mut select = tx.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash, "SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
local_mtime_ns local_mtime_ns, remote_revision_id
FROM sync_entry FROM sync_entry
WHERE folder_id = ?1 WHERE folder_id = ?1
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')", AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
@@ -321,6 +330,7 @@ impl Db {
remote_rev: r.get(4)?, remote_rev: r.get(4)?,
remote_hash: r.get(5)?, remote_hash: r.get(5)?,
local_mtime_ns: r.get(6)?, local_mtime_ns: r.get(6)?,
remote_revision_id: r.get(7)?,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -332,8 +342,8 @@ impl Db {
let mut insert = tx.prepare( let mut insert = tx.prepare(
"INSERT OR REPLACE INTO sync_entry "INSERT OR REPLACE INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, (folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
remote_hash, local_mtime_ns) remote_hash, local_mtime_ns, remote_revision_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)?; )?;
for row in rows { for row in rows {
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]); let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
@@ -346,6 +356,7 @@ impl Db {
row.remote_rev, row.remote_rev,
row.remote_hash, row.remote_hash,
row.local_mtime_ns, row.local_mtime_ns,
row.remote_revision_id,
])?; ])?;
} }
} }
+449 -7
View File
@@ -1283,6 +1283,90 @@ fn a_corrupt_database_is_moved_aside_and_rebuilt() {
let _ = std::fs::remove_file(path.with_extension("db.lock")); let _ = std::fs::remove_file(path.with_extension("db.lock"));
} }
/// A rebuilt database must not lose what only this machine has: writes Drive
/// has not received, the mirror baselines and the pins (`docs/BUGS.md` B216).
#[test]
fn a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file() {
let id = now_test_id();
let damaged =
std::env::temp_dir().join(format!("pdfs-salvage-old-{}-{id}.db", std::process::id()));
let fresh =
std::env::temp_dir().join(format!("pdfs-salvage-new-{}-{id}.db", std::process::id()));
let db = Db::open(&damaged).unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&file("listed", "root", "listed.txt", 1))
.unwrap();
db.upsert_node(&file("queued", "root", "queued.txt", 2))
.unwrap();
db.set_listed(&uid("root"), true).unwrap();
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: uid("queued").to_string(),
parent_uid: None,
name: None,
blob_path: Some("/staging/queued".into()),
meta_json: None,
created_at: 1,
attempts: 3,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
let folder_id = db
.sync_folder_add("/home/me/Docs", "vol~docs", "share")
.unwrap();
db.sync_entry_upsert(
folder_id,
&StoredSyncEntry {
rel_path: "a.txt".into(),
remote_uid: Some("vol~a".into()),
local_mtime: 5,
local_mtime_ns: None,
local_size: 6,
remote_rev: Some("7".into()),
remote_hash: Some("8".into()),
remote_revision_id: None,
},
)
.unwrap();
db.pin_add("vol~pinned", "/pinned", true).unwrap();
drop(db);
let db = Db::open(&fresh).unwrap();
let results = salvage(&db.conn.lock(), &damaged);
assert!(results.iter().all(|(_, r)| r.is_ok()), "{results:?}");
let ops = db.pending_ops().unwrap();
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].uid, uid("queued").to_string());
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/queued"));
// The op's node came with it; the rest of the tree is fetched again.
assert!(
db.node_by_uid(&uid("queued").to_string())
.unwrap()
.is_some()
);
assert!(
db.node_by_uid(&uid("listed").to_string())
.unwrap()
.is_none()
);
assert_eq!(db.sync_entries(folder_id).unwrap().len(), 1);
assert!(db.is_pinned("vol~pinned").unwrap());
drop(db);
for path in [&damaged, &fresh] {
for suffix in ["", "-wal", "-shm"] {
let mut file = path.as_os_str().to_owned();
file.push(suffix);
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
}
let _ = std::fs::remove_file(path.with_extension("db.lock"));
}
}
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
LocalEntry { LocalEntry {
path: path.into(), path: path.into(),
@@ -1346,6 +1430,77 @@ fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
} }
} }
/// A scan that stopped early or lost a batch did not see every file, so it
/// must not prune the ones it missed (`docs/BUGS.md` B217).
#[test]
fn a_partial_local_scan_prunes_nothing() {
let db = Db::open_in_memory().unwrap();
let g = db.local_begin_scan().unwrap();
db.local_upsert_batch(
g,
&[
local("/home/u/a.txt", "a.txt", false),
local("/home/u/b.txt", "b.txt", false),
],
)
.unwrap();
assert_eq!(db.local_finish_scan(g, 1).unwrap(), 2);
let g = db.local_begin_scan().unwrap();
db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)])
.unwrap();
assert_eq!(db.local_finish_partial_scan(2).unwrap(), 2);
assert_eq!(db.search_local("b.txt", 10).unwrap().len(), 1);
assert_eq!(db.local_indexed_at().unwrap(), Some(2));
}
/// Deleted entries pile up in an FTS5 index until it is compacted. The daily
/// compaction must leave both indexes whole and searchable, and must not run
/// again the same day (`docs/BUGS.md` B217).
#[test]
fn compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&file("f1", "root", "quarterly-report.pdf", 1))
.unwrap();
for round in 0..20 {
let g = db.local_begin_scan().unwrap();
let name = format!("churn-{round}.txt");
db.local_upsert_batch(
g,
&[
local("/home/u/keep.txt", "keep.txt", false),
local(&format!("/home/u/{name}"), &name, false),
],
)
.unwrap();
// Far in the past, so no scan compacts on its own.
db.local_finish_scan(g, 0).unwrap();
}
let day = 24 * 60 * 60;
db.compact_search_indexes_if_due(day).unwrap();
{
let conn = db.conn.lock();
for table in ["local_fts", "nodes_fts"] {
conn.execute(
&format!("INSERT INTO {table}({table}) VALUES('integrity-check')"),
[],
)
.unwrap();
}
}
assert_eq!(db.search_local("keep", 10).unwrap().len(), 1);
assert_eq!(db.search_local("churn-19", 10).unwrap().len(), 1);
assert!(db.search_local("churn-3.txt", 10).unwrap().is_empty());
assert_eq!(db.search("quarterly", 10).unwrap().len(), 1);
let stamp = |db: &Db| db.state_str("fts_compacted_at").unwrap();
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
db.compact_search_indexes_if_due(day + 60).unwrap();
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
}
/// Queries below the trigram minimum still match, via the `LIKE` fallback. /// Queries below the trigram minimum still match, via the `LIKE` fallback.
#[test] #[test]
fn local_search_short_query_like_fallback() { fn local_search_short_query_like_fallback() {
@@ -1689,9 +1844,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
.collect(); .collect();
db.upsert_nodes(&batch).unwrap(); db.upsert_nodes(&batch).unwrap();
let t = Instant::now(); // The fastest of a few runs: the rest of the suite runs alongside, and
db.upsert_nodes(&batch).unwrap(); // one run that lost the CPU to it read as the regression.
t.elapsed() (0..5)
.map(|_| {
let t = Instant::now();
db.upsert_nodes(&batch).unwrap();
t.elapsed()
})
.min()
.unwrap()
} }
let small = cost_against_index_of(200); let small = cost_against_index_of(200);
@@ -2191,6 +2353,100 @@ fn refuses_a_schema_newer_than_the_running_build() {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
/// Queueing a write syncs it to disk, and leaves the writer at NORMAL after.
///
/// The op row is what makes a staged blob reachable once `close()` has
/// reported success, so its commit must survive a power loss. Everything else
/// the writer commits is metadata the next listing re-fetches, and pays only
/// NORMAL's cost.
#[test]
fn queueing_a_write_commits_at_full_sync_and_restores_normal() {
let path = std::env::temp_dir().join(format!("pdfs-db-sync-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let db = Db::open(&path).unwrap();
let level = |db: &Db| -> i64 {
db.conn
.lock()
.query_row("PRAGMA synchronous", [], |r| r.get(0))
.unwrap()
};
// 1 = NORMAL, 2 = FULL.
assert_eq!(level(&db), 1);
{
let durable = db.durable().unwrap();
let during: i64 = durable
.query_row("PRAGMA synchronous", [], |r| r.get(0))
.unwrap();
assert_eq!(during, 2);
}
assert_eq!(level(&db), 1);
db.enqueue_op(&PendingOp {
id: 0,
kind: OP_TRASH.to_string(),
uid: uid("a").to_string(),
parent_uid: None,
name: Some("a".to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
assert_eq!(level(&db), 1);
assert_eq!(db.pending_ops().unwrap().len(), 1);
drop(db);
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
}
}
/// A gap-filled blob is recorded as whole only while the op still holds it.
///
/// A write that closes during the drain points the row at a newer blob with
/// its own sidecar. Overwriting that sidecar with the older blob's would
/// describe bytes the row no longer holds.
#[test]
fn marking_a_blob_whole_leaves_a_newer_blob_alone() {
let db = Db::open_in_memory().unwrap();
let op = PendingOp {
id: 0,
kind: OP_REVISION.to_string(),
uid: uid("a").to_string(),
parent_uid: None,
name: None,
blob_path: Some("/staging/first".to_string()),
meta_json: Some(r#"{"complete":false}"#.to_string()),
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
};
db.enqueue_op(&op).unwrap();
let id = db.pending_ops().unwrap()[0].id;
assert!(
db.update_op_meta_for_blob(id, "/staging/first", r#"{"complete":true}"#)
.unwrap()
);
assert_eq!(
db.pending_ops().unwrap()[0].meta_json.as_deref(),
Some(r#"{"complete":true}"#)
);
assert!(
!db.update_op_meta_for_blob(id, "/staging/older", r#"{"stale":true}"#)
.unwrap(),
"the row holds another blob"
);
assert_eq!(
db.pending_ops().unwrap()[0].meta_json.as_deref(),
Some(r#"{"complete":true}"#)
);
}
/// A queued write whose baseline is restamped must keep everything else. /// A queued write whose baseline is restamped must keep everything else.
/// ///
/// The restamp happens after our *own* upload seals a new revision under a /// The restamp happens after our *own* upload seals a new revision under a
@@ -3920,15 +4176,15 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
.unwrap(); .unwrap();
assert!(db.create_sent(id).unwrap().is_empty()); assert!(db.create_sent(id).unwrap().is_empty());
assert!(db.create_targets(id).unwrap().is_empty()); assert!(db.create_targets(id).unwrap().is_empty());
db.note_create_target(id, &parent, "c").unwrap(); db.note_create_target(id, &parent, "c", 10).unwrap();
db.note_create_target(id, &parent, "c").unwrap(); db.note_create_target(id, &parent, "c", 20).unwrap();
db.note_create_sent(id, "aaaa").unwrap(); db.note_create_sent(id, "aaaa").unwrap();
db.note_create_sent(id, "aaaa").unwrap(); db.note_create_sent(id, "aaaa").unwrap();
db.record_op_failure(id, "no answer", 0).unwrap(); db.record_op_failure(id, "no answer", 0).unwrap();
db.attach_blob_to_create("local~sent", "/staging/second", "{}") db.attach_blob_to_create("local~sent", "/staging/second", "{}")
.unwrap() .unwrap()
.expect("the create is still queued"); .expect("the create is still queued");
db.note_create_target(id, &parent, "d").unwrap(); db.note_create_target(id, &parent, "d", 30).unwrap();
db.note_create_sent(id, "bbbb").unwrap(); db.note_create_sent(id, "bbbb").unwrap();
db.record_op_failure(id, "no answer", 0).unwrap(); db.record_op_failure(id, "no answer", 0).unwrap();
db.delete_ops_for_uid("local~sent").unwrap(); db.delete_ops_for_uid("local~sent").unwrap();
@@ -3951,6 +4207,73 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }
/// A queued create knows when it first went out, which is where the window
/// for adopting a node its lost answer left starts. Records written before
/// the time was kept still list their targets, and one that does not parse is
/// not replaced by the next target.
#[test]
fn a_create_remembers_when_it_was_first_sent() {
let db = Db::open_in_memory().unwrap();
let parent = uid("parent").to_string();
let (id, _) = db
.enqueue_op(&PendingOp {
id: 0,
kind: OP_CREATE.to_string(),
uid: "local~first".to_string(),
parent_uid: Some(parent.clone()),
name: Some("a.txt".to_string()),
blob_path: None,
meta_json: None,
created_at: 1,
attempts: 0,
last_error: None,
next_attempt_at: 0,
})
.unwrap();
assert_eq!(db.create_first_sent(id).unwrap(), None, "never sent");
db.note_create_target(id, &parent, "a.txt", 500).unwrap();
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
db.note_create_target(id, &parent, "b.txt", 700).unwrap();
assert_eq!(db.create_first_sent(id).unwrap(), Some(500));
let set = |json: &str| {
db.conn
.lock()
.execute(
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
rusqlite::params![id, json],
)
.unwrap();
};
set(&format!(r#"[["{parent}","old.txt"]]"#));
assert_eq!(
db.create_targets(id).unwrap(),
vec![(parent.clone(), "old.txt".to_string())]
);
assert_eq!(db.create_first_sent(id).unwrap(), None);
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
assert_eq!(
db.create_targets(id).unwrap().len(),
2,
"the old target stays"
);
assert_eq!(db.create_first_sent(id).unwrap(), Some(900));
set("not json");
db.note_create_target(id, &parent, "c.txt", 1000).unwrap();
let raw: String = db
.conn
.lock()
.query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap();
assert_eq!(
raw, "not json",
"an unreadable record is kept, not replaced"
);
}
/// A write after a create failed clears its attempts, so the next try goes /// A write after a create failed clears its attempts, so the next try goes
/// out at once. The create was still sent: deleting the file then left no /// out at once. The create was still sent: deleting the file then left no
/// trash, and the file a lost answer made stayed on Drive under the name it /// trash, and the file a lost answer made stayed on Drive under the name it
@@ -3974,7 +4297,7 @@ fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() {
next_attempt_at: 0, next_attempt_at: 0,
}) })
.unwrap(); .unwrap();
db.note_create_target(id, &parent, "a.txt").unwrap(); db.note_create_target(id, &parent, "a.txt", 10).unwrap();
db.note_create_sent(id, "aaaa").unwrap(); db.note_create_sent(id, "aaaa").unwrap();
db.record_op_failure(id, "no answer", 0).unwrap(); db.record_op_failure(id, "no answer", 0).unwrap();
db.attach_blob_to_create("local~sent", "/staging/second", "{}") db.attach_blob_to_create("local~sent", "/staging/second", "{}")
@@ -5021,6 +5344,7 @@ fn baseline_row(rel: &str) -> StoredSyncEntry {
local_size: 7, local_size: 7,
remote_rev: Some("1700000000".into()), remote_rev: Some("1700000000".into()),
remote_hash: Some("7".into()), remote_hash: Some("7".into()),
remote_revision_id: None,
} }
} }
@@ -5115,6 +5439,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
local_size: 2, local_size: 2,
remote_rev: Some("3".into()), remote_rev: Some("3".into()),
remote_hash: Some("2".into()), remote_hash: Some("2".into()),
remote_revision_id: None,
local_mtime_ns: None, local_mtime_ns: None,
}, },
) )
@@ -5132,6 +5457,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
local_size: 2, local_size: 2,
remote_rev: Some("3".into()), remote_rev: Some("3".into()),
remote_hash: Some("2".into()), remote_hash: Some("2".into()),
remote_revision_id: None,
local_mtime_ns: None, local_mtime_ns: None,
}, },
) )
@@ -6309,6 +6635,71 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
let _ = std::fs::remove_file(format!("{}.lock", path.display())); let _ = std::fs::remove_file(format!("{}.lock", path.display()));
} }
/// V44 adds the remote revision a baseline row was synced at. A row carried
/// over from V43 has none, and the mirror then compares the modification time
/// and size as before, so upgrading changes nothing about what is in step
/// (`docs/BUGS.md` B249).
#[test]
fn migration_v44_adds_the_synced_revision_without_disturbing_a_v43_baseline() {
let path = std::env::temp_dir().join(format!(
"pdfs-db-v43-fixture-{}-{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let row = StoredSyncEntry {
rel_path: "notes.txt".into(),
remote_uid: Some("vol~notes".into()),
local_mtime: 1_700_000_000,
local_mtime_ns: Some(1_700_000_000_123_456_789),
local_size: 42,
remote_rev: Some("1700000000".into()),
remote_hash: Some("42".into()),
remote_revision_id: Some("rev-1".into()),
};
{
let db = Db::open(&path).unwrap();
let folder = db
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
.unwrap();
db.sync_entry_upsert(folder, &row).unwrap();
}
{
// Put the file back in the state a released V43 database was in.
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"ALTER TABLE sync_entry DROP COLUMN remote_revision_id;
UPDATE sync_state SET value = '43' WHERE key = 'schema_version';",
)
.unwrap();
}
let db = Db::open(&path).unwrap();
let entries = db.sync_entries(1).unwrap();
let entry = entries.get("notes.txt").expect("the baseline row survives");
assert_eq!(
entry,
&StoredSyncEntry {
remote_revision_id: None,
..row.clone()
}
);
// And the column is live, including through a move of the row.
db.sync_entry_upsert(1, &row).unwrap();
db.sync_entries_move(1, "notes.txt", 1, "moved.txt")
.unwrap();
let entries = db.sync_entries(1).unwrap();
assert_eq!(
entries["moved.txt"].remote_revision_id.as_deref(),
Some("rev-1")
);
drop(db);
let _ = std::fs::remove_file(&path);
}
/// A mirror baseline carried over from V27 keeps its rows, and each one reads /// A mirror baseline carried over from V27 keeps its rows, and each one reads
/// back with no nanosecond time. That `None` is the whole point: it makes the /// back with no nanosecond time. That `None` is the whole point: it makes the
/// comparison fall back to whole seconds for rows written before the column /// comparison fall back to whole seconds for rows written before the column
@@ -6339,6 +6730,7 @@ fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline()
local_size: 42, local_size: 42,
remote_rev: Some("1700000000".into()), remote_rev: Some("1700000000".into()),
remote_hash: Some("42".into()), remote_hash: Some("42".into()),
remote_revision_id: None,
}, },
) )
.unwrap(); .unwrap();
@@ -7209,6 +7601,56 @@ fn a_refused_change_names_its_node_by_local_id() {
); );
} }
/// The name checks the drain makes before each op look the name up in an
/// index. Scanning the queue instead made a large offline batch quadratic.
#[test]
fn queue_name_checks_use_an_index() {
let db = Db::open_in_memory().unwrap();
let conn = db.conn.lock();
let plan = |sql: &str| -> String {
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
stmt.query_map([], |r| r.get::<_, String>(3))
.unwrap()
.map(|row| row.unwrap())
.collect::<Vec<_>>()
.join("; ")
};
for sql in [
"SELECT 1 FROM pending_op WHERE kind = 'trash' AND name = 'a'",
"SELECT 1 FROM pending_op WHERE name = 'a' AND kind IN ('create', 'mkdir', 'rename')",
"SELECT 1 FROM pending_op WHERE kind = 'rename'",
] {
let plan = plan(sql);
assert!(plan.contains("pending_op_kind_name"), "{sql}: {plan}");
}
}
/// A reconnect moves up the ops that failed for want of a link. One Drive
/// refused, for quota or access, keeps its backoff: the link coming back
/// changes nothing about it. "Retry all" still moves both.
#[test]
fn a_reconnect_leaves_refused_ops_to_their_backoff() {
let db = Db::open_in_memory().unwrap();
let offline = queued_op(&db, "vol~a", 2, 90_000);
let refused = queued_op(&db, "vol~b", 2, 90_000);
db.set_op_issue(refused, Some("quota")).unwrap();
assert_eq!(db.retry_unrefused_ops_now(5_000).unwrap(), 1);
let due = |id: i64| {
db.pending_ops()
.unwrap()
.into_iter()
.find(|op| op.id == id)
.unwrap()
.next_attempt_at
};
assert_eq!(due(offline), 5_000);
assert_eq!(due(refused), 90_000);
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
assert_eq!(due(refused), 5_000);
}
#[test] #[test]
fn retry_all_touches_only_ops_that_have_failed() { fn retry_all_touches_only_ops_that_have_failed() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+59 -12
View File
@@ -154,17 +154,17 @@ impl CoreError {
/// through the boxes the drain deals in, where the concrete type survives /// through the boxes the drain deals in, where the concrete type survives
/// but the static one does not. /// but the static one does not.
/// ///
/// A failure that is not an API response at all — a transport error, a /// A transport error, a timeout or a TLS failure is what being offline
/// timeout, a TLS failure — is what being offline looks like from here, so /// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
/// it classifies as [`ErrorKind::Offline`] rather than /// than [`ErrorKind::Remote`]: the request never reached Proton, and
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the /// telling the user their connection is down beats telling them the
/// user their connection is down beats telling them the server refused. /// server refused. The SDK's other failures say what went wrong instead
/// (see [`proton_kind`]).
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self { pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() { let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), Some(proton) => proton_kind(proton),
// A `ProtonError` that is not an `Api` variant never reached the // Not the SDK's at all: a timeout around the call, or the socket.
// API; so does anything that is not a `ProtonError` at all. None => ErrorKind::Offline,
_ => ErrorKind::Offline,
}; };
Self::new(kind, format!("{what}: {e}")) Self::new(kind, format!("{what}: {e}"))
} }
@@ -214,15 +214,33 @@ impl From<Error> for CoreError {
| Error::Locked | Error::Locked
| Error::WrongMailboxPassword | Error::WrongMailboxPassword
| Error::HumanVerificationRequired(_) => ErrorKind::Denied, | Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), Error::Proton(proton) => proton_kind(proton),
// A `ProtonError` that never reached the API is a transport failure.
Error::Proton(_) => ErrorKind::Offline,
_ => ErrorKind::Internal, _ => ErrorKind::Internal,
}; };
Self::new(kind, e.to_string()) Self::new(kind, e.to_string())
} }
} }
/// What a failed SDK call means to a front-end.
///
/// Only a transport failure is the connection. The rest happened on this
/// machine, and calling them "offline" sent the user to check a network that
/// worked (issue #38: a photo upload on an account without a photo library
/// read "You're offline").
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
use proton_sdk::ProtonError as P;
match e {
P::Api(api) => api_kind(api.code),
P::Transport(_) => ErrorKind::Offline,
// Proton answered, but not with anything this client can read.
P::Serialization(_) => ErrorKind::Remote,
// The SDK refused the call before sending it, such as an upload to a
// photo library the account does not have.
P::InvalidOperation(_) => ErrorKind::Invalid,
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
}
}
/// What an API response code means to a front-end. /// What an API response code means to a front-end.
/// ///
/// The grouping is by *what the user can do about it*, which is what /// The grouping is by *what the user can do about it*, which is what
@@ -295,6 +313,15 @@ pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
} }
} }
/// Whether `e` is Proton's temporary block on a sign-in (code 2028).
///
/// Its message says the app version is no longer supported, which is not what
/// happened: the release is current and only waiting helps (issues #28, #39).
/// A front-end must say that instead of passing the message through.
pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool {
matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked)
}
/// The result every request-serving method in the daemon returns. /// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>; pub type CoreResult<T> = std::result::Result<T, CoreError>;
@@ -337,6 +364,14 @@ mod tests {
assert!(!session_revoked(&ProtonError::KeysLocked)); assert!(!session_revoked(&ProtonError::KeysLocked));
} }
#[test]
fn only_code_2028_is_a_blocked_sign_in() {
assert!(sign_in_blocked(&api(C::Blocked)));
assert!(!sign_in_blocked(&api(C::OutdatedApp)));
assert!(!sign_in_blocked(&api(C::TooManyRequests)));
assert!(!sign_in_blocked(&ProtonError::KeysLocked));
}
/// The regression this whole pass exists to prevent: before it, every SDK /// The regression this whole pass exists to prevent: before it, every SDK
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
/// request that could never succeed. /// request that could never succeed.
@@ -367,6 +402,18 @@ mod tests {
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline); assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
} }
/// Issue #38: the SDK refused a photo upload before sending it, and the
/// app said "You're offline" while every other call went through.
#[test]
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
let refused = ProtonError::invalid_operation("account has no photos volume");
let e = CoreError::from_api(&refused, "upload photo failed");
assert_eq!(e.kind, ErrorKind::Invalid);
assert!(e.message.ends_with("account has no photos volume"));
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
assert_ne!(e.kind, ErrorKind::Offline);
}
/// `Error` is the plumbing type; converting it must classify as sharply as /// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure. /// the direct path, or the two disagree about the same failure.
#[test] #[test]
+133
View File
@@ -0,0 +1,133 @@
//! When a photo was taken, as its own EXIF tags say.
//!
//! The fallback for a photo nothing else dates: a Takeout photo without a
//! sidecar or a date in its name, and a photo uploaded from the app. Without
//! it the first was filed under the moment of the import and the second under
//! the file's modification time, which a copy resets.
use std::io::{BufRead, Seek};
use std::path::Path;
use crate::takeout::days_from_civil;
/// The capture time in the EXIF of the file at `path`, in epoch seconds.
pub fn capture_time_of(path: &Path) -> Option<i64> {
let file = std::fs::File::open(path).ok()?;
capture_time(&mut std::io::BufReader::new(file))
}
/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then
/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera
/// wrote one. Without an offset the time is read as UTC, as a date in a file
/// name is (`takeout::capture_time_from_name`).
pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option<i64> {
let exif = exif::Reader::new().read_from_container(reader).ok()?;
let ascii = |tag: exif::Tag| -> Option<String> {
let field = exif.get_field(tag, exif::In::PRIMARY)?;
match &field.value {
exif::Value::Ascii(parts) => parts.first().map(|part| {
String::from_utf8_lossy(part)
.trim_matches('\0')
.trim()
.to_string()
}),
_ => None,
}
};
[
(exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal),
(exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized),
]
.into_iter()
.find_map(|(time, offset)| {
let local = parse_exif_time(&ascii(time)?)?;
let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0);
Some(local - offset)
})
}
/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the
/// all-zero or blank value cameras write when they do not know.
fn parse_exif_time(text: &str) -> Option<i64> {
let bytes = text.as_bytes();
if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' {
return None;
}
let number = |range: std::ops::Range<usize>| text.get(range)?.parse::<i64>().ok();
let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?);
let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?);
if !(1800..=9999).contains(&year)
|| !(1..=12).contains(&month)
|| !(1..=31).contains(&day)
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
}
/// `+HH:MM` or `-HH:MM` as seconds east of UTC.
fn parse_offset(text: &str) -> Option<i64> {
let sign = match text.as_bytes().first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let (hours, minutes) = text.get(1..)?.split_once(':')?;
let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
(hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_exif_time_reads_as_utc_and_its_offset_moves_it() {
// 2023-06-01 12:00:00 UTC.
let noon = 1_685_620_800;
assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon));
assert_eq!(parse_offset("+02:00"), Some(7200));
assert_eq!(parse_offset("-05:30"), Some(-19_800));
assert_eq!(
parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()),
Some(noon)
);
}
#[test]
fn an_unknown_exif_time_is_no_time() {
assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None);
assert_eq!(parse_exif_time(" : : : : "), None);
assert_eq!(parse_exif_time(""), None);
assert_eq!(parse_offset("02:00"), None);
}
#[test]
fn a_photo_is_dated_by_its_original_time_and_offset() {
let ascii = |tag, text: &str| exif::Field {
tag,
ifd_num: exif::In::PRIMARY,
value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]),
};
let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00");
let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00");
let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00");
let mut writer = exif::experimental::Writer::new();
writer.push_field(&taken);
writer.push_field(&offset);
writer.push_field(&digitized);
let mut tiff = std::io::Cursor::new(Vec::new());
writer.write(&mut tiff, false).unwrap();
tiff.set_position(0);
assert_eq!(capture_time(&mut tiff), Some(1_685_620_800));
}
#[test]
fn a_file_without_exif_has_no_capture_time() {
let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec());
assert_eq!(capture_time(&mut png), None);
}
}
+294
View File
@@ -0,0 +1,294 @@
//! Trace the key chain that opens My Files, for `pdfs diagnose`.
//!
//! The daemon only says "missing key" when the My Files share does not open
//! (#40). This walks the chain the other way and names the link that breaks:
//! the share's passphrase is encrypted to an address key, whose token is
//! encrypted to an account key, which opens with the stored passphrase. Every
//! finding holds key IDs and flags only, never key material, so the output is
//! safe to post in an issue.
use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport};
use proton_sdk::ids::AddressId;
use crate::auth;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Ok,
Warn,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub level: Level,
pub label: String,
pub detail: String,
}
/// The address and recipients of the My Files share passphrase, or why they
/// could not be read.
pub type ShareRecipients = std::result::Result<(AddressId, Vec<Option<String>>), String>;
/// Resume the stored session and ask Proton for the account's keys and the
/// My Files share. Fails when there is no session, the keys are locked, or
/// Proton cannot be reached; reading the share is allowed to fail on its own.
///
/// The session's tokens are the daemon's too. A refresh here spends the
/// refresh token the daemon holds, which it recovers from by resuming the
/// stored session (see [`auth::stored_session_is_newer`]).
pub async fn run() -> Result<(KeyReport, ShareRecipients)> {
let (client, _session) = auth::resume_client().await?;
let report = client.account().key_report().await?;
let share = client
.my_files_share_recipients()
.await
.map_err(|e| e.to_string());
Ok((report, share))
}
/// One finding per key, then one for the My Files share.
pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec<Finding> {
let mut findings = Vec::new();
for key in &report.user_keys {
findings.push(user_key_finding(key));
}
for address in &report.addresses {
for key in &address.keys {
findings.push(address_key_finding(report, &address.email, key));
}
}
findings.push(share_finding(report, share));
findings
}
fn user_key_finding(key: &UserKeyReport) -> Finding {
let label = format!("account key {}", short(key.id.as_str()));
let (level, detail) = match (key.active, key.unlocks) {
(true, true) => (Level::Ok, "opens".to_string()),
(true, false) => (
Level::Fail,
format!(
"does not open: {}",
key.error.as_deref().unwrap_or("unknown")
),
),
(false, true) => (Level::Ok, "inactive, opens with your password".to_string()),
(false, false) => (Level::Ok, "inactive, does not open".to_string()),
};
Finding {
level,
label,
detail: with_primary(key.primary, detail),
}
}
fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding {
let label = format!("{email} key {}", short(key.id.as_str()));
let (level, detail) = if !key.active {
(Level::Ok, "inactive, not used".to_string())
} else if key.unlocks {
(Level::Ok, "opens".to_string())
} else {
// A non-primary key matters only for what was encrypted to it; the
// share finding says whether My Files is.
let level = if key.primary {
Level::Fail
} else {
Level::Warn
};
(level, format!("does not open: {}", why_closed(report, key)))
};
Finding {
level,
label,
detail: with_primary(key.primary, detail),
}
}
/// Why an active address key does not open, named by the account key its
/// token was meant for when the token has one.
fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String {
let error = key.error.as_deref().unwrap_or("unknown");
let Some(recipients) = &key.token_recipients else {
return error.to_string();
};
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) {
let id = short(user_key.id.as_str());
return match (user_key.active, user_key.unlocks) {
(false, true) => {
format!(
"its token is for inactive account key {id}, which opens with your password"
)
}
(false, false) => {
format!("its token is for inactive account key {id}, which does not open either")
}
(true, false) => format!("its token is for account key {id}, which does not open"),
// The token opened; the key itself refused its passphrase.
(true, true) => error.to_string(),
};
}
match named.first() {
Some(recipient) => {
format!("its token is for key {recipient}, which is none of your account keys")
}
None if recipients.is_empty() => error.to_string(),
None => "its token does not name the key it is for".to_string(),
}
}
fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding {
let label = "My Files share".to_string();
let (level, detail) = match share {
Err(e) => (Level::Warn, format!("could not be read: {e}")),
Ok((address_id, recipients)) => {
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
match named.iter().find_map(|r| report.address_key_named(r)) {
Some((address, key)) => {
let name = format!("{} key {}", address.email, short(key.id.as_str()));
if key.unlocks {
(Level::Ok, format!("encrypted to {name}, which opens"))
} else {
(
Level::Fail,
format!("encrypted to {name}, which does not open"),
)
}
}
None => (
Level::Fail,
match named.first() {
Some(recipient) => format!(
"encrypted to key {recipient}, which is none of the keys of address {address_id}"
),
None => "its passphrase does not name the key it is for".to_string(),
},
),
}
}
};
Finding {
level,
label,
detail,
}
}
fn with_primary(primary: bool, detail: String) -> String {
if primary {
format!("primary, {detail}")
} else {
detail
}
}
/// The start of a Proton key ID: enough to tell keys apart and to find them
/// in the daemon log, which prints the whole ID.
fn short(id: &str) -> String {
match id.char_indices().nth(8) {
Some((end, _)) => format!("{}…", &id[..end]),
None => id.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::account::AddressReport;
use proton_sdk::ids::{AddressKeyId, UserKeyId};
fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport {
UserKeyReport {
id: UserKeyId::new(id),
primary: active,
active,
unlocks,
error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()),
key_ids: vec![pgp.to_string()],
}
}
fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport {
AddressKeyReport {
id: AddressKeyId::new(id),
primary,
active: true,
token_recipients: Some(vec![Some(token_for.to_string())]),
unlocks: token_for == "u1",
error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()),
key_ids: vec![pgp.to_string()],
}
}
/// The account in #40, as far as its log shows: the primary address key
/// opens, an older one does not, and My Files is encrypted to the older one.
fn report(stranded_token_for: &str) -> KeyReport {
KeyReport {
user_keys: vec![
user_key("UserKeyOne-123", true, true, "u1"),
user_key("UserKeyTwo-456", false, true, "u2"),
],
addresses: vec![AddressReport {
id: AddressId::new("address-1"),
email: "user@proton.me".to_string(),
keys: vec![
address_key("AddrKeyNew-789", true, "u1", "a1"),
address_key("AddrKeyOld-000", false, stranded_token_for, "a2"),
],
}],
}
}
#[test]
fn a_stranded_address_key_is_traced_to_the_inactive_account_key() {
let report = report("u2");
let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())]));
let findings = findings(&report, &share);
assert_eq!(findings[0].detail, "primary, opens");
assert_eq!(findings[1].detail, "inactive, opens with your password");
assert_eq!(findings[2].level, Level::Ok);
assert_eq!(findings[3].level, Level::Warn);
assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…");
assert_eq!(
findings[3].detail,
"does not open: its token is for inactive account key UserKeyT…, which opens with your password"
);
assert_eq!(findings[4].level, Level::Fail);
assert_eq!(
findings[4].detail,
"encrypted to user@proton.me key AddrKeyO…, which does not open"
);
}
#[test]
fn a_token_for_a_key_the_account_does_not_list_says_so() {
let report = report("ffff");
let detail = &findings(&report, &Err("offline".to_string()))[3].detail;
assert_eq!(
detail,
"does not open: its token is for key ffff, which is none of your account keys"
);
}
#[test]
fn a_share_that_opens_and_one_that_cannot_be_read() {
let report = report("u2");
let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())]));
let last = findings(&report, &open).pop().unwrap();
assert_eq!(last.level, Level::Ok);
assert_eq!(
last.detail,
"encrypted to user@proton.me key AddrKeyN…, which opens"
);
let last = findings(&report, &Err("offline".to_string()))
.pop()
.unwrap();
assert_eq!(last.level, Level::Warn);
assert_eq!(last.detail, "could not be read: offline");
}
}
+4
View File
@@ -10,7 +10,9 @@ pub mod config;
pub mod control; pub mod control;
pub mod db; pub mod db;
pub mod error; pub mod error;
pub mod exiftime;
pub mod kernelkey; pub mod kernelkey;
pub mod keycheck;
pub mod localindex; pub mod localindex;
pub mod menu; pub mod menu;
pub mod mounts; pub mod mounts;
@@ -23,8 +25,10 @@ pub mod service;
pub mod shell; pub mod shell;
pub mod similar; pub mod similar;
pub mod syncignore; pub mod syncignore;
pub mod syncroot;
pub mod takeout; pub mod takeout;
pub mod tray; pub mod tray;
pub mod update;
pub use access::{Access, access_for, perm_bits}; pub use access::{Access, access_for, perm_bits};
pub use error::{CoreError, CoreResult, Error, Result}; pub use error::{CoreError, CoreResult, Error, Result};
+1 -1
View File
@@ -42,7 +42,7 @@ const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
/// Hard cap on indexed entries, so a pathological home directory cannot grow the /// Hard cap on indexed entries, so a pathological home directory cannot grow the
/// database without bound. Reaching it stops the walk early. /// database without bound. Reaching it stops the walk early.
const MAX_ENTRIES: usize = 400_000; pub const MAX_ENTRIES: usize = 400_000;
/// Rows handed to the sink at a time. Large enough that the per-transaction cost /// Rows handed to the sink at a time. Large enough that the per-transaction cost
/// amortises, small enough that the writer lock is released often (FUSE /// amortises, small enough that the writer lock is released often (FUSE
+27 -2
View File
@@ -189,11 +189,21 @@ fn spawn(argv: &[String], path: &Path) {
.stderr(Stdio::null()) .stderr(Stdio::null())
.process_group(0) .process_group(0)
.spawn(); .spawn();
if let Err(e) = spawned { match spawned {
tracing::error!(command = ?argv, "open failed: {e}"); Ok(child) => reap(child),
Err(e) => tracing::error!(command = ?argv, "open failed: {e}"),
} }
} }
/// Wait for `child` on a thread of its own. A child nobody waits for stays a
/// zombie for as long as its parent lives, which for the app and the tray is
/// the whole session.
pub fn reap(mut child: std::process::Child) {
std::thread::spawn(move || {
let _ = child.wait();
});
}
/// [`open`] with the policy from the user's `config.json`, read once per /// [`open`] with the policy from the user's `config.json`, read once per
/// process. For callers that open the occasional path and have no reason to /// process. For callers that open the occasional path and have no reason to
/// carry a policy around; long-lived windows should hold their own copy. /// carry a policy around; long-lived windows should hold their own copy.
@@ -472,6 +482,21 @@ fn glob_match(pattern: &str, name: &str) -> bool {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_reaped_child_does_not_stay_a_zombie() {
let child = std::process::Command::new("true").spawn().unwrap();
let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id()));
reap(child);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while proc_entry.exists() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
!proc_entry.exists(),
"the child is still in the process table"
);
}
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule { fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
OpenRule { OpenRule {
patterns: patterns.iter().map(|p| (*p).to_string()).collect(), patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
+93 -1
View File
@@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize};
use crate::mounts::MountMode; use crate::mounts::MountMode;
/// File name of the profile. /// File name of the profile clients before 3.5.0 wrote, one per device.
///
/// Read, never written: two computers sharing a device each overwrote the
/// other's. See [`profile_file_name`].
pub const PROFILE_FILE_NAME: &str = "profile.json"; pub const PROFILE_FILE_NAME: &str = "profile.json";
/// File name of one machine's profile: `profile-<machine id>.json`.
///
/// One per machine, because several computers may sync into one device, and
/// each describes its own arrangement. A single shared file went to whichever
/// computer saved last.
pub fn profile_file_name(machine_id: &str) -> String {
format!("profile-{machine_id}.json")
}
/// Whether `name` is a profile: [`PROFILE_FILE_NAME`] or one machine's
/// [`profile_file_name`].
pub fn is_profile_file_name(name: &str) -> bool {
name == PROFILE_FILE_NAME
|| name
.strip_prefix("profile-")
.and_then(|rest| rest.strip_suffix(".json"))
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// Folder, directly under the device root, that holds the profile. /// Folder, directly under the device root, that holds the profile.
/// ///
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so /// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
@@ -74,6 +96,10 @@ pub struct Profile {
/// Hostname of the machine that wrote it, so a restore prompt can say /// Hostname of the machine that wrote it, so a restore prompt can say
/// *"restore settings from laptop-xyz?"* rather than quoting a uid. /// *"restore settings from laptop-xyz?"* rather than quoting a uid.
pub hostname: String, pub hostname: String,
/// The writing machine's id, as in its [`profile_file_name`]. `None` in a
/// profile written before 3.5.0.
#[serde(default)]
pub machine_id: Option<String>,
/// When it was written, epoch seconds. /// When it was written, epoch seconds.
pub saved_at: i64, pub saved_at: i64,
#[serde(default)] #[serde(default)]
@@ -92,6 +118,25 @@ pub struct Profile {
pub mountpoint: Option<String>, pub mountpoint: Option<String>,
} }
/// The saved arrangement of the folder with uid `remote_uid`, from the
/// profiles of every computer that syncs the device.
///
/// The profile of the machine named `hostname` wins, since it describes this
/// computer; otherwise the most recently saved one that has the folder.
pub fn saved_folder<'a>(
profiles: &'a [Profile],
remote_uid: &str,
hostname: &str,
) -> Option<&'a ProfileFolder> {
let holding = |p: &&'a Profile| p.folders.iter().any(|f| f.remote_uid == remote_uid);
let profile = profiles
.iter()
.filter(holding)
.find(|p| p.hostname == hostname)
.or_else(|| profiles.iter().filter(holding).max_by_key(|p| p.saved_at))?;
profile.folders.iter().find(|f| f.remote_uid == remote_uid)
}
impl Profile { impl Profile {
/// Parse a profile document, refusing one written by a newer client. /// Parse a profile document, refusing one written by a newer client.
pub fn parse(bytes: &[u8]) -> Result<Self, String> { pub fn parse(bytes: &[u8]) -> Result<Self, String> {
@@ -122,6 +167,7 @@ mod tests {
version: PROFILE_VERSION, version: PROFILE_VERSION,
device_uid: "dev-1".to_string(), device_uid: "dev-1".to_string(),
hostname: "laptop-xyz".to_string(), hostname: "laptop-xyz".to_string(),
machine_id: Some("0123456789abcdef0123456789abcdef".to_string()),
saved_at: 1_700_000_000, saved_at: 1_700_000_000,
folders: vec![ProfileFolder { folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(), remote_uid: "vol~link".to_string(),
@@ -219,6 +265,52 @@ mod tests {
assert!(p.ignore_patterns.is_none()); assert!(p.ignore_patterns.is_none());
} }
#[test]
fn a_profile_name_is_the_legacy_one_or_one_per_machine() {
assert!(is_profile_file_name(PROFILE_FILE_NAME));
let name = profile_file_name("0123456789abcdef0123456789abcdef");
assert_eq!(name, "profile-0123456789abcdef0123456789abcdef.json");
assert!(is_profile_file_name(&name));
assert!(!is_profile_file_name("profile-.json"));
assert!(!is_profile_file_name("profile-a/b.json"));
assert!(!is_profile_file_name("profile (1).json"));
assert!(!is_profile_file_name("notes.json"));
}
/// A profile written before 3.5.0 has no machine id and still parses.
#[test]
fn a_profile_without_a_machine_id_parses() {
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
assert_eq!(Profile::parse(json).unwrap().machine_id, None);
}
/// Several computers sync one device, each with its own profile. This
/// computer's own wins, then the newest that has the folder.
#[test]
fn the_saved_folder_comes_from_this_computer_then_the_newest_profile() {
let profile = |hostname: &str, saved_at: i64, path: &str| Profile {
hostname: hostname.to_string(),
saved_at,
folders: vec![ProfileFolder {
remote_uid: "vol~docs".to_string(),
local_path: path.to_string(),
mode: MountMode::Mirror,
}],
..sample()
};
let profiles = vec![
profile("desktop", 10, "/home/a/Docs"),
profile("laptop", 5, "/home/b/Docs"),
profile("old", 1, "/home/c/Docs"),
];
let path =
|host: &str| saved_folder(&profiles, "vol~docs", host).map(|f| f.local_path.as_str());
assert_eq!(path("laptop"), Some("/home/b/Docs"));
assert_eq!(path("new-machine"), Some("/home/a/Docs"));
assert_eq!(saved_folder(&profiles, "vol~other", "laptop"), None);
assert_eq!(saved_folder(&[], "vol~docs", "laptop"), None);
}
#[test] #[test]
fn future_version_is_refused() { fn future_version_is_refused() {
let mut p = sample(); let mut p = sample();
+16 -1
View File
@@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same /// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
/// selection when the script runs on a local (non-URI) folder, so we prefer the /// selection when the script runs on a local (non-URI) folder, so we prefer the
/// environment and fall back to `"$@"` for the Nemo/Caja variants. /// environment and fall back to `"$@"` for the Nemo/Caja variants.
///
/// The `--` keeps a file named like an option, such as `--json`, a path. A
/// name with a newline in it still arrives split in two from Nautilus, whose
/// list is newline-separated; `pdfs` then fails on both halves, which touches
/// nothing.
fn script_body(subcommand: &str) -> String { fn script_body(subcommand: &str) -> String {
format!( format!(
"#!/bin/sh\n\ "#!/bin/sh\n\
@@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String {
\n\ \n\
printf '%s' \"$paths\" | while IFS= read -r path; do\n\ printf '%s' \"$paths\" | while IFS= read -r path; do\n\
\x20 [ -n \"$path\" ] || continue\n\ \x20 [ -n \"$path\" ] || continue\n\
\x20 pdfs {subcommand} \"$path\" || exit 1\n\ \x20 pdfs {subcommand} -- \"$path\" || exit 1\n\
done\n" done\n"
) )
} }
@@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> {
tracing::debug!("installed file-manager scripts in {dir:?}"); tracing::debug!("installed file-manager scripts in {dir:?}");
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::script_body;
#[test]
fn the_script_ends_options_before_each_path() {
assert!(script_body("pin").contains(r#"pdfs pin -- "$path""#));
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Which local folders may become a sync root.
//!
//! A mirror uploads everything under its root and deletes what Drive deleted,
//! so a root that holds more than the user meant is dangerous. `$HOME` takes
//! the decrypted content cache, `cache.db` and `~/.ssh` up to Drive, and walks
//! into the mount, which downloads the whole Drive only to upload it again.
//! Every place a root is added or restored goes through [`problem`].
use std::path::{Component, Path, PathBuf};
use crate::config::{AppConfig, AppDirs};
/// The paths a sync root must stay clear of.
#[derive(Debug, Clone, Default)]
pub struct Reserved {
/// The user's home folder. A root may sit inside it, not be it or above it.
pub home: Option<PathBuf>,
/// The Proton Drive mount.
pub mountpoint: Option<PathBuf>,
/// This app's own configuration, state and cache folders.
pub app_dirs: Vec<PathBuf>,
}
impl Reserved {
/// The reserved paths of this user and `config`, resolved like a root.
pub fn current(config: &AppConfig) -> Self {
let Ok(dirs) = AppDirs::new() else {
return Self::default();
};
let resolved = |path: PathBuf| resolve(&path).unwrap_or(path);
let mut app_dirs = vec![dirs.state_dir(), dirs.cache_dir()];
if let Some(config_dir) = dirs.config_path().parent() {
app_dirs.push(config_dir.to_path_buf());
}
Self {
home: dirs.home_dir().map(resolved),
mountpoint: Some(resolved(dirs.resolved_mountpoint(config))),
app_dirs: app_dirs.into_iter().map(resolved).collect(),
}
}
}
/// `path` as a root is compared: absolute, with symlinks resolved as far as the
/// path exists. A folder a restore is about to create does not exist yet, so
/// the part past the deepest existing folder is kept as written.
///
/// A `..` is refused rather than resolved: before a symlink it means something
/// else than after it, and a path recorded on another machine has no business
/// holding one.
pub fn resolve(path: &Path) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("{} is not an absolute path", path.display()));
}
if path.components().any(|c| c == Component::ParentDir) {
return Err(format!("{} goes up with `..`", path.display()));
}
let mut existing = path.to_path_buf();
let mut rest = Vec::new();
loop {
match existing.canonicalize() {
Ok(canonical) => {
return Ok(rest
.into_iter()
.rev()
.fold(canonical, |path: PathBuf, part| path.join(part)));
}
Err(_) => {
let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
return Ok(path.to_path_buf());
};
rest.push(name);
if !existing.pop() {
return Ok(path.to_path_buf());
}
}
}
}
}
/// Why `root` cannot be synced, or `None` when it can. `root` is resolved
/// ([`resolve`]); `others` are the roots already synced.
pub fn problem(root: &Path, others: &[PathBuf], reserved: &Reserved) -> Option<String> {
let shown = root.display();
if root.parent().is_none() {
return Some(format!("{shown} is the root of the file system"));
}
if let Some(home) = &reserved.home {
if root == home {
return Some(format!(
"{shown} is your home folder; pick a folder inside it"
));
}
if home.starts_with(root) {
return Some(format!("{shown} holds your home folder"));
}
}
if let Some(mountpoint) = &reserved.mountpoint
&& overlaps(root, mountpoint)
{
return Some(format!(
"{shown} overlaps the Proton Drive folder {}",
mountpoint.display()
));
}
if let Some(dir) = reserved.app_dirs.iter().find(|dir| overlaps(root, dir)) {
return Some(format!(
"{shown} overlaps this app's own data in {}",
dir.display()
));
}
if others.iter().any(|other| other == root) {
return Some(format!("{shown} is already synced"));
}
if let Some(other) = others.iter().find(|other| overlaps(root, other)) {
return Some(format!(
"{shown} overlaps the synced folder {}",
other.display()
));
}
None
}
/// Whether one of `a` and `b` is the other or inside it.
fn overlaps(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)
}
#[cfg(test)]
mod tests {
use super::*;
fn reserved() -> Reserved {
Reserved {
home: Some("/home/u".into()),
mountpoint: Some("/home/u/ProtonDrive".into()),
app_dirs: vec![
"/home/u/.local/state/proton-drive-linux".into(),
"/home/u/.cache/proton-drive-linux".into(),
"/home/u/.config/proton-drive-linux".into(),
],
}
}
fn refused(root: &str, others: &[&str]) -> bool {
let others: Vec<PathBuf> = others.iter().map(PathBuf::from).collect();
problem(Path::new(root), &others, &reserved()).is_some()
}
#[test]
fn a_folder_inside_home_can_be_synced() {
assert!(!refused("/home/u/Documents", &[]));
assert!(!refused("/home/u/Pictures", &["/home/u/Documents"]));
assert!(!refused("/mnt/data/photos", &[]));
}
#[test]
fn the_file_system_root_home_and_its_parents_are_refused() {
assert!(refused("/", &[]));
assert!(refused("/home/u", &[]));
assert!(refused("/home", &[]));
}
#[test]
fn the_mount_and_the_apps_own_data_are_refused_either_way_round() {
assert!(refused("/home/u/ProtonDrive", &[]));
assert!(refused("/home/u/ProtonDrive/Documents", &[]));
assert!(refused("/home/u/.cache", &[]));
assert!(refused(
"/home/u/.local/state/proton-drive-linux/staging",
&[]
));
assert!(refused("/home/u/.config", &[]));
}
#[test]
fn a_root_inside_or_around_another_root_is_refused() {
assert!(refused("/home/u/Documents", &["/home/u/Documents"]));
assert!(refused("/home/u/Documents/Work", &["/home/u/Documents"]));
assert!(refused("/home/u/stuff", &["/home/u/stuff/Documents"]));
// A shared prefix of characters is not a shared folder.
assert!(!refused("/home/u/Documents2", &["/home/u/Documents"]));
}
#[test]
fn a_path_that_goes_up_or_is_relative_does_not_resolve() {
assert!(resolve(Path::new("/home/u/../../etc")).is_err());
assert!(resolve(Path::new("Documents")).is_err());
}
#[test]
fn a_folder_still_to_be_created_resolves_under_its_existing_parent() {
let base = std::env::temp_dir().join(format!("pdfs-syncroot-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
let canonical = base.canonicalize().unwrap();
assert_eq!(
resolve(&base.join("new/inner")).unwrap(),
canonical.join("new/inner")
);
let link = base.join("link");
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&canonical, &link).unwrap();
assert_eq!(
resolve(&link.join("later")).unwrap(),
canonical.join("later"),
"a symlink is followed to where it points"
);
std::fs::remove_dir_all(&base).unwrap();
}
}
+251 -25
View File
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
.insert(file.to_string(), body); .insert(file.to_string(), body);
} }
let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars
.iter()
.map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder)))
.collect();
let mut scan = TakeoutScan::default(); let mut scan = TakeoutScan::default();
let mut seen_albums: HashMap<String, ()> = HashMap::new(); let mut seen_albums: HashMap<String, ()> = HashMap::new();
@@ -364,9 +369,13 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
continue; continue;
}; };
let sidecar = per_folder_sidecars let found = indexes
.get(&folder) .get(folder.as_str())
.and_then(|in_folder| find_sidecar(&file_name, in_folder)) .and_then(|index| find_sidecar(&file_name, index));
// An edited copy keeps its own name: the title is its original's.
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
let sidecar = found
.map(|(_, body)| body)
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok()) .and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
.unwrap_or_default(); .unwrap_or_default();
@@ -388,6 +397,7 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
let name = sidecar let name = sidecar
.title .title
.as_deref() .as_deref()
.filter(|_| title_is_ours)
.map(str::trim) .map(str::trim)
.filter(|title| !title.is_empty() && same_extension(title, &file_name)) .filter(|title| !title.is_empty() && same_extension(title, &file_name))
.unwrap_or(&file_name) .unwrap_or(&file_name)
@@ -598,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option<i64> {
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's /// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
/// `days_from_civil`). Avoids pulling a date crate in for one conversion. /// `days_from_civil`). Avoids pulling a date crate in for one conversion.
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 { pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
let year = year - i64::from(month <= 2); let year = year - i64::from(month <= 2);
let era = if year >= 0 { year } else { year - 399 } / 400; let era = if year >= 0 { year } else { year - 399 } / 400;
let year_of_era = year - era * 400; let year_of_era = year - era * 400;
@@ -627,17 +637,106 @@ fn swap_duplicate_marker(file_name: &str) -> Option<String> {
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker)) Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
} }
/// Find the sidecar belonging to `file_name` among the JSON files of its folder. /// How long Google lets a sidecar's file name get, `.json` included. A longer
/// one is cut, into its suffix first and then into the photo name.
const SIDECAR_NAME_CAP: usize = 51;
/// The rank [`sidecar_rank`] gives an edited copy that shares its original's
/// sidecar. Its date is the original's, but its name is its own.
const EDITED_COPY: u8 = 4;
/// The sidecars of one folder by their `.json`-stripped stem, so a photo is
/// ranked only against those that can match it.
///
/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the
/// photo name that the name itself determines, and a sorted map finds the
/// stems with a given prefix as one range. Ranking every photo against every
/// sidecar instead took 27 s for a folder of 10,000 photos.
struct SidecarIndex<'a> {
by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>>,
}
impl<'a> SidecarIndex<'a> {
fn new(in_folder: &'a BTreeMap<String, &'a Vec<u8>>) -> Self {
let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>> = BTreeMap::new();
for (name, body) in in_folder {
by_stem
.entry(strip_json_suffix(name))
.or_default()
.push((name.as_str(), *body));
}
Self { by_stem }
}
/// Add every sidecar whose stem starts with `prefix` to `out`.
fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
let from = (
std::ops::Bound::Included(prefix),
std::ops::Bound::Unbounded,
);
for (stem, sidecars) in self.by_stem.range::<str, _>(from) {
if !stem.starts_with(prefix) {
break;
}
out.extend(sidecars.iter().copied());
}
}
/// Add every sidecar whose stem is `head`, or starts with `head` and a `.`:
/// those that have `head` as a boundary ([`suffix_boundaries`]).
fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
if let Some(sidecars) = self.by_stem.get(head) {
out.extend(sidecars.iter().copied());
}
self.starting_with(&format!("{head}."), out);
}
/// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A
/// superset: each one is still ranked.
fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec<u8>> {
let mut out = BTreeMap::new();
// Rules 0 and 2: the stem starts with the name, or with its swapped form.
self.starting_with(file_name, &mut out);
if let Some(swapped) = swap_duplicate_marker(file_name) {
self.starting_with(&swapped, &mut out);
}
// Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`.
for (paren, _) in file_name.match_indices('(') {
self.starting_with(&format!("{}.", &file_name[..paren]), &mut out);
}
// Rule 3: a boundary of the stem is a prefix of the name.
for end in file_name
.char_indices()
.map(|(index, _)| index)
.skip(1)
.chain([file_name.len()])
{
self.bounded_by(&file_name[..end], &mut out);
}
// Rule 4: `<original>-<word>.<ext>` has `<original>.<ext>` as a boundary.
if let Some((_, extension)) = file_name.rsplit_once('.') {
for (dash, _) in file_name.match_indices('-') {
self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out);
}
}
out
}
}
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
/// with the rank it matched at.
/// ///
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an /// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
/// unknown trailing suffix, then a prefix match for the case where truncation /// unknown trailing suffix, a prefix match for the case where truncation cut
/// cut into the photo name itself. The best-ranked candidate in the folder wins. /// into the photo name itself, and last an edited copy's original. The
/// best-ranked candidate in the folder wins; of equal ranks, the first by
/// sidecar name.
fn find_sidecar<'a>( fn find_sidecar<'a>(
file_name: &str, file_name: &str,
in_folder: &BTreeMap<String, &'a Vec<u8>>, index: &SidecarIndex<'a>,
) -> Option<&'a Vec<u8>> { ) -> Option<((u8, usize), &'a Vec<u8>)> {
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None; let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
for (sidecar_name, body) in in_folder { for (sidecar_name, body) in index.candidates(file_name) {
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else { let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
continue; continue;
}; };
@@ -645,7 +744,7 @@ fn find_sidecar<'a>(
best = Some((rank, body)); best = Some((rank, body));
} }
} }
best.map(|(_, body)| body) best
} }
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort /// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
@@ -683,19 +782,44 @@ fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
} }
// 3 — truncation ate into the photo name itself, leaving only a prefix of // 3 — truncation ate into the photo name itself, leaving only a prefix of
// it. Every suffix boundary in the stem is tried, so this holds for a // it. Every suffix boundary in the stem is tried, so this holds for a
// localized suffix too; the longest surviving prefix wins, because a folder // localized suffix too; the longest surviving prefix wins.
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the //
// longer photo. // Only for a sidecar whose name is as long as Google lets one get: a name
let mut longest: Option<usize> = None; // that short of the cap was not cut, so a prefix of it is the name of a
for head in suffix_boundaries(stem) { // different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and
if !head.is_empty() // date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225).
&& file_name.starts_with(head) if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 {
&& longest.is_none_or(|len| head.len() > len) let mut longest: Option<usize> = None;
{ for head in suffix_boundaries(stem) {
longest = Some(head.len()); if !head.is_empty()
&& file_name.starts_with(head)
&& longest.is_none_or(|len| head.len() > len)
{
longest = Some(head.len());
}
}
if let Some(len) = longest {
return Some((3, usize::MAX - len));
} }
} }
longest.map(|len| (3, usize::MAX - len)) // 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which
// Google exports without a sidecar of its own next to `IMG_1.jpg`'s.
suffix_boundaries(stem)
.filter_map(|head| head.rsplit_once('.'))
.any(|(original, extension)| is_edited_copy_of(file_name, original, extension))
.then_some((EDITED_COPY, 0))
}
/// Whether `file_name` is `<original>-<word>.<extension>`, the name Google
/// gives an edited copy of `<original>.<extension>`.
fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool {
!original.is_empty()
&& file_name
.strip_prefix(original)
.and_then(|rest| rest.strip_prefix('-'))
.and_then(|rest| rest.strip_suffix(extension))
.and_then(|rest| rest.strip_suffix('.'))
.is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic))
} }
/// The stem plus each of its prefixes ending before a `.`, longest first — the /// The stem plus each of its prefixes ending before a `.`, longest first — the
@@ -994,21 +1118,25 @@ mod tests {
#[test] #[test]
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() { fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
// Both sidecar names are at Google's cap, so both were cut into the
// photo name; the longer surviving prefix is the better claim.
let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json";
let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json";
let mut sidecars = HashMap::new(); let mut sidecars = HashMap::new();
sidecar( sidecar(
&mut sidecars, &mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_1.json", short,
r#"{"photoTakenTime":{"timestamp":"1"}}"#, r#"{"photoTakenTime":{"timestamp":"1"}}"#,
); );
sidecar( sidecar(
&mut sidecars, &mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_12.json", long,
r#"{"photoTakenTime":{"timestamp":"2"}}"#, r#"{"photoTakenTime":{"timestamp":"2"}}"#,
); );
let scan = assemble( let scan = assemble(
vec![entry( vec![entry(
0, 0,
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg", "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg",
10, 10,
)], )],
&sidecars, &sidecars,
@@ -1017,6 +1145,104 @@ mod tests {
assert_eq!(scan.photos[0].capture_time, Some(2)); assert_eq!(scan.photos[0].capture_time, Some(2));
} }
/// A short sidecar name was not cut, so a prefix of it names another photo:
/// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg`
/// (`docs/BUGS.md` B225).
#[test]
fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() {
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json",
r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#,
);
let scan = assemble(
vec![
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10),
],
&sidecars,
);
assert_eq!(scan.photos[0].capture_time, Some(1));
assert_eq!(scan.photos[1].name, "IMG_12.jpg");
assert_eq!(scan.photos[1].capture_time, None);
}
/// An edited copy has no sidecar of its own. It takes its original's date,
/// but keeps its own name rather than the original's title.
#[test]
fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() {
let mut sidecars = HashMap::new();
sidecar(
&mut sidecars,
"Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json",
r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#,
);
let scan = assemble(
vec![
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10),
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10),
],
&sidecars,
);
assert_eq!(scan.photos[0].name, "Holiday.jpg");
assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg");
assert_eq!(scan.photos[1].capture_time, Some(7));
}
/// The index looks only at the sidecars that can match, and must find the
/// same one that ranking every sidecar in the folder finds.
#[test]
fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() {
let long = "A_very_long_holiday_photo_name_from_the_beach_2019";
let names = [
"IMG_1.jpg".to_string(),
"IMG_12.jpg".to_string(),
"IMG_1(1).jpg".to_string(),
"IMG_1-edited.jpg".to_string(),
"IMG_1-bearbeitet.jpg".to_string(),
"my.photo(2).jpg".to_string(),
"x(1).jpg".to_string(),
"Ölbild.png".to_string(),
"PXL_20230101_101010.mp4".to_string(),
format!("{long}.jpg"),
format!("{long}_2.jpg"),
"nothing.heic".to_string(),
];
let sidecar_names = [
"IMG_1.jpg.supplemental-metadata.json".to_string(),
"IMG_1.jpg(1).supplemental-metadata.json".to_string(),
"IMG_12.jpg.ergänzende-Metadaten.json".to_string(),
"my.photo.jpg(2).json".to_string(),
"x.jpg(1).json".to_string(),
"Ölbild.png.suppl.json".to_string(),
"PXL_20230101_101010.mp4.JSON".to_string(),
format!("{}.json", &long[..44]),
format!("{long}.jp.json"),
"IMG.json".to_string(),
"unrelated.json".to_string(),
];
let bodies: Vec<Vec<u8>> = (0..sidecar_names.len())
.map(|i| i.to_string().into_bytes())
.collect();
let in_folder: BTreeMap<String, &Vec<u8>> =
sidecar_names.iter().cloned().zip(bodies.iter()).collect();
let index = SidecarIndex::new(&in_folder);
for name in &names {
let mut every: Option<((u8, usize), &Vec<u8>)> = None;
for (sidecar_name, body) in &in_folder {
if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name)
&& every.as_ref().is_none_or(|(current, _)| rank < *current)
{
every = Some((rank, *body));
}
}
assert_eq!(find_sidecar(name, &index), every, "{name}");
}
}
#[test] #[test]
fn sidecar_title_replaces_a_mangled_on_disk_name() { fn sidecar_title_replaces_a_mangled_on_disk_name() {
let mut sidecars = HashMap::new(); let mut sidecars = HashMap::new();
+178
View File
@@ -0,0 +1,178 @@
//! The opt-in check for a newer release ([`crate::config::AppConfig::update_check`]).
//!
//! Only the decisions live here: what GitHub's answer says, which version is
//! newer, when to ask again, and whether a package manager already owns
//! updates. The desktop app does the request itself.
use std::path::Path;
use std::time::Duration;
/// GitHub's latest published release. Drafts and pre-releases never show up
/// here, so a tagged release candidate does not reach anyone.
pub const RELEASES_LATEST_API: &str =
"https://api.github.com/repos/narrrl/proton-drive-linux/releases/latest";
/// How often the app asks, across restarts.
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// Installed next to the binaries' `share/` by packages whose package manager
/// brings new releases (COPR, the AUR, Nix). The release `.deb`, `.rpm` and
/// `.tar.gz` do not ship it, since nothing updates them.
pub const MANAGED_MARKER: &str = "share/proton-drive-linux/managed-updates";
/// The version of the release in a `/releases/latest` answer, without the
/// tag's `v`. `None` for an answer that does not parse or a tag that is not
/// `X.Y.Z`, so a changed API can never claim an update.
pub fn latest_version(body: &[u8]) -> Option<String> {
let json: serde_json::Value = serde_json::from_slice(body).ok()?;
let tag = json.get("tag_name")?.as_str()?;
let version = tag.strip_prefix('v').unwrap_or(tag);
parse(version).map(|_| version.to_owned())
}
/// Whether `version` is a later release than `current`. Anything that is not
/// `X.Y.Z` on either side is not.
pub fn is_newer(version: &str, current: &str) -> bool {
matches!((parse(version), parse(current)), (Some(v), Some(c)) if v > c)
}
/// The release page of `version` on GitHub.
pub fn release_url(version: &str) -> String {
format!("https://github.com/narrrl/proton-drive-linux/releases/tag/v{version}")
}
/// `X.Y.Z` as numbers, so `3.10.0` sorts after `3.9.0`.
fn parse(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
let triple = (parts.next()??, parts.next()??, parts.next()??);
parts.next().is_none().then_some(triple)
}
/// How long until the next check is due, given the last one in Unix seconds.
/// Zero when it is due now. A clock set back past the last check counts as
/// due, so a wrong clock cannot stop checks.
pub fn next_check_in(last_checked: Option<i64>, now: i64) -> Duration {
let interval = CHECK_INTERVAL.as_secs() as i64;
match last_checked {
Some(last) if (0..interval).contains(&(now - last)) => {
Duration::from_secs((interval - (now - last)) as u64)
}
_ => Duration::ZERO,
}
}
/// Whether the binary at `exe` was installed by a package manager that updates
/// it, which [`MANAGED_MARKER`] next to its `bin/` says.
pub fn updates_managed(exe: &Path) -> bool {
exe.parent()
.and_then(Path::parent)
.is_some_and(|prefix| prefix.join(MANAGED_MARKER).exists())
}
#[cfg(test)]
mod tests {
use super::*;
fn latest(tag: &str) -> Vec<u8> {
format!(
r#"{{"tag_name":"{tag}","html_url":"https://github.com/narrrl/proton-drive-linux/releases/tag/{tag}","draft":false}}"#
)
.into_bytes()
}
#[test]
fn the_latest_release_is_read_without_its_v() {
assert_eq!(latest_version(&latest("v3.6.0")).as_deref(), Some("3.6.0"));
assert_eq!(latest_version(&latest("3.6.0")).as_deref(), Some("3.6.0"));
}
#[test]
fn an_answer_that_does_not_parse_has_no_version() {
assert_eq!(latest_version(b"not json"), None);
assert_eq!(latest_version(b"{}"), None);
assert_eq!(latest_version(br#"{"tag_name":7}"#), None);
assert_eq!(latest_version(&latest("nightly")), None);
assert_eq!(latest_version(&latest("v4.0.0-rc1")), None);
assert_eq!(latest_version(&latest("v4.0")), None);
assert_eq!(latest_version(&latest("v4.0.0.1")), None);
}
#[test]
fn only_a_later_release_is_newer() {
assert!(is_newer("3.6.0", "3.5.0"));
assert!(!is_newer("3.5.0", "3.5.0"));
assert!(!is_newer("3.4.1", "3.5.0"));
assert!(!is_newer("2.99.99", "3.0.0"));
}
#[test]
fn versions_compare_as_numbers() {
assert!(is_newer("3.10.0", "3.9.0"));
assert!(is_newer("3.5.10", "3.5.9"));
assert!(is_newer("4.0.0", "3.99.99"));
}
#[test]
fn a_version_that_is_not_x_y_z_is_never_newer() {
assert!(!is_newer("4.0.0", "3.5.0-dev"));
assert!(!is_newer("garbage", "3.5.0"));
}
#[test]
fn the_release_page_uses_the_tag() {
assert_eq!(
release_url("3.6.0"),
"https://github.com/narrrl/proton-drive-linux/releases/tag/v3.6.0"
);
}
#[test]
fn a_check_is_due_once_a_day() {
let day = CHECK_INTERVAL.as_secs() as i64;
assert_eq!(next_check_in(None, 1_000), Duration::ZERO);
assert_eq!(next_check_in(Some(1_000), 1_000), CHECK_INTERVAL);
assert_eq!(
next_check_in(Some(1_000), 1_000 + day - 1),
Duration::from_secs(1)
);
assert_eq!(next_check_in(Some(1_000), 1_000 + day), Duration::ZERO);
assert_eq!(
next_check_in(Some(1_000), 999),
Duration::ZERO,
"a clock set back must not stop checks"
);
}
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::syncignore`]'s tests.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-update-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn the_marker_next_to_bin_means_a_package_manager_updates() {
let prefix = TempDir::new();
let exe = prefix.0.join("bin/pdfs-app");
assert!(!updates_managed(&exe));
let marker = prefix.0.join(MANAGED_MARKER);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(&marker, "").unwrap();
assert!(updates_managed(&exe));
}
}
+11 -3
View File
@@ -723,16 +723,24 @@ fn scan_local_once(
// has seen and stays indeterminate. // has seen and stays indeterminate.
let job = transfers.begin_background_job("Indexing this computer"); let job = transfers.begin_background_job("Indexing this computer");
job.detail("Scanning your files"); job.detail("Scanning your files");
let mut failed = false;
let walked = localindex::scan(&[home], &excludes, |batch| { let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) { if let Err(e) = db.local_upsert_batch(generation, &batch) {
warn!(error = %e, "local index: batch write failed"); warn!(error = %e, "local index: batch write failed");
failed = true;
} }
}); });
// Prune what this scan did not see and rebuild the FTS index over the rest, // Prune what this scan did not see, unless it did not see everything:
// even if some batches failed — a partial index still beats none. // a path it never reached, or one whose batch failed to write, is not a
// path that is gone (`docs/BUGS.md` B217).
job.detail("Building the search index"); job.detail("Building the search index");
match db.local_finish_scan(generation, now_secs()) { let finished = if failed || walked >= localindex::MAX_ENTRIES {
db.local_finish_partial_scan(now_secs())
} else {
db.local_finish_scan(generation, now_secs())
};
match finished {
Ok(indexed) => info!( Ok(indexed) => info!(
walked, walked,
indexed, indexed,
+93 -1
View File
@@ -29,6 +29,17 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024; const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10); const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Answer a [`CtlRequest::FolderSize`].
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
match size {
Ok(size) => CtlResponse::FolderSize {
size: size.size,
items: size.descendants,
},
Err(e) => CtlResponse::error(e),
}
}
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal /// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
/// into an error that names what is still shared. /// into an error that names what is still shared.
fn stop_sharing_reply( fn stop_sharing_reply(
@@ -336,6 +347,7 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
online: core.online.load(Ordering::Relaxed), online: core.online.load(Ordering::Relaxed),
session_expired: core.session_expired(), session_expired: core.session_expired(),
tokens_unsaved: pdfs_core::auth::tokens_unsaved(), tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
version: env!("CARGO_PKG_VERSION").to_string(),
pending_uploads: queued.uploads.max(0) as u64 + landing, pending_uploads: queued.uploads.max(0) as u64 + landing,
pending_changes: queued.changes.max(0) as u64, pending_changes: queued.changes.max(0) as u64,
} }
@@ -376,6 +388,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
| R::RemoveSyncFolder { .. } | R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. } | R::SetSyncFolderMode { .. }
| R::SetSyncFolderPaused { .. } | R::SetSyncFolderPaused { .. }
| R::ResetSyncFolder { .. }
| R::RestoreSyncFolders { .. } | R::RestoreSyncFolders { .. }
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices], | R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
R::RetryPendingOp { .. } => &[Topic::Queue], R::RetryPendingOp { .. } => &[Topic::Queue],
@@ -391,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
| R::CreatePublicLinkByUid { .. } | R::CreatePublicLinkByUid { .. }
| R::RemovePublicLink { .. } | R::RemovePublicLink { .. }
| R::RemovePublicLinkByUid { .. } | R::RemovePublicLinkByUid { .. }
| R::UpdatePublicLink { .. }
| R::UpdatePublicLinkByUid { .. }
| R::StopSharing { .. } | R::StopSharing { .. }
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files], | R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
R::LeaveShared { .. } R::LeaveShared { .. }
@@ -910,10 +925,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
))), ))),
Ok((file, len)) => { Ok((file, len)) => {
let photos = core.photos(); let photos = core.photos();
// Unless the caller knows better: the photo's own EXIF,
// then the file's modification time, which a copy resets.
let capture_time = capture_time.or_else(|| {
let path = Path::new(&source_path);
pdfs_core::exiftime::capture_time_of(path).or_else(|| {
let modified =
std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
Some(since.as_secs() as i64)
})
});
let metadata = proton_drive_rs::PhotoUploadMetadata { let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time, capture_time,
..Default::default() ..Default::default()
}; };
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
let guard = let guard =
core.transfers core.transfers
.begin(name.clone(), "", TransferDirection::Upload, len); .begin(name.clone(), "", TransferDirection::Upload, len);
@@ -923,7 +950,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
&media_type, &media_type,
reader, reader,
len as i64, len as i64,
Vec::new(), thumbnails,
metadata, metadata,
false, false,
)) { )) {
@@ -940,6 +967,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
} }
} }
Err(e) => { Err(e) => {
// The reply goes to the app's toast only; without
// this the journal shows a failure and no reason.
warn!(error = %e, "photo upload failed");
CtlResponse::error(CoreError::from_api(&e, "upload photo failed")) CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
} }
} }
@@ -1047,6 +1077,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e), (Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
} }
} }
Ok(CtlRequest::Copy { path, new_parent }) => {
match (
rel_to_mount(mountpoint, &path),
rel_to_mount(mountpoint, &new_parent),
) {
(Ok(rel), Ok(parent_rel)) => match core.copy_to(&rel, &parent_rel) {
Ok(name) => {
let dest = if new_parent.is_empty() {
"My files".to_string()
} else {
new_parent.clone()
};
core.log_activity(ActivityKind::Copy, &name, format!("to {dest}"), true);
CtlResponse::Ok {
message: format!("copied to {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Copy, &path, &e, false);
CtlResponse::error(e)
}
},
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) { Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
Ok((core, rel)) => match core.delete(&rel) { Ok((core, rel)) => match core.delete(&rel) {
Ok(name) => { Ok(name) => {
@@ -1373,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
} }
} }
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
Ok(message) => {
core.log_activity(ActivityKind::Sync, &message, "", true);
CtlResponse::Ok { message }
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SyncNow { id }) => { Ok(CtlRequest::SyncNow { id }) => {
core.sync_now(id); core.sync_now(id);
CtlResponse::Ok { CtlResponse::Ok {
@@ -1614,6 +1681,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
CtlResponse::error(e) CtlResponse::error(e)
} }
}, },
Ok(CtlRequest::UpdatePublicLink {
path,
id,
role,
password,
expires,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => {
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdatePublicLinkByUid {
uid,
id,
role,
password,
expires,
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
Ok(link) => CtlResponse::PublicLink { link },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) { Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => { Ok(()) => {
+77 -16
View File
@@ -105,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option<String> {
(!uid.is_empty()).then_some(uid) (!uid.is_empty()).then_some(uid)
} }
/// The paths no sync root may overlap, with the mountpoint as the config says
/// now.
pub(crate) fn sync_root_reserved() -> pdfs_core::syncroot::Reserved {
let config = pdfs_core::config::AppDirs::new()
.map(|dirs| dirs.load_config())
.unwrap_or_default();
pdfs_core::syncroot::Reserved::current(&config)
}
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`. /// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> { pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
let dirs = pdfs_core::config::AppDirs::new() let dirs = pdfs_core::config::AppDirs::new()
@@ -157,7 +166,7 @@ impl Core {
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> { pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self let devices = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "list devices"))?; .map_err(|e| CoreError::from_api(&e, "list devices"))?;
// No cached device row yet means this machine syncs nothing, so none of // No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours. // the listed devices is ours.
@@ -214,7 +223,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let dev = remote let dev = remote
.into_iter() .into_iter()
@@ -257,7 +266,7 @@ impl Core {
}; };
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let d = remote let d = remote
.into_iter() .into_iter()
@@ -395,7 +404,7 @@ impl Core {
// any cached record, and recover an existing device by name. // any cached record, and recover an existing device by name.
let remote = self let remote = self
.rt .rt
.block_on(self.client.enumerate_devices()) .block_on(self.drive.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
// An adopted uid is an explicit instruction, so it outranks everything // An adopted uid is an explicit instruction, so it outranks everything
@@ -454,7 +463,7 @@ impl Core {
None => { None => {
let d = self let d = self
.rt .rt
.block_on(self.client.create_device(&name, DeviceType::Linux)) .block_on(self.drive.create_device(&name, DeviceType::Linux))
.map_err(|e| CoreError::from_api(&e, "create device"))?; .map_err(|e| CoreError::from_api(&e, "create device"))?;
StoredDevice { StoredDevice {
uid: d.uid.to_string(), uid: d.uid.to_string(),
@@ -517,18 +526,20 @@ impl Core {
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?; .map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
let local_str = local.to_string_lossy().to_string(); let local_str = local.to_string_lossy().to_string();
// Reject duplicates up front for a clear error (UNIQUE would also catch it). // `/`, the home folder, the mount, this app's own data, and a folder
if self // inside or around another root are refused (`syncroot::problem`). A
// duplicate is one of those, refused here for a clear error before
// the UNIQUE constraint would catch it.
let others = self
.db .db
.sync_folder_list() .sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))? .map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.iter() .iter()
.any(|f| f.local_path == local_str) .map(|f| PathBuf::from(&f.local_path))
{ .collect::<Vec<_>>();
return Err(CoreError::invalid(format!( let reserved = sync_root_reserved();
"{} is already synced", if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
local.display() return Err(CoreError::invalid(problem));
)));
} }
let name = local let name = local
@@ -978,9 +989,15 @@ impl Core {
.db .db
.sync_entries(id) .sync_entries(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?; .map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
if let Some(reason) = let rules = self.ignore_rules(&local);
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local)) // Emptied before any pass saw it, the folder is still `idle`, and
{ // the check below would refuse the switch for good. The push pass
// of a queued switch forgets those paths instead (`docs/BUGS.md`
// B255).
if sync::mirror_tree_gone(&local, &baseline, &rules) {
return Err(SwitchBlocked::NotNow);
}
if let Some(reason) = sync::mirror_tree_unsynced(&local, &baseline, &rules) {
return Err(SwitchBlocked::Failed(format!( return Err(SwitchBlocked::Failed(format!(
"going online-only would delete what is only on this computer: {reason}" "going online-only would delete what is only on this computer: {reason}"
))); )));
@@ -1069,6 +1086,50 @@ impl Core {
} }
} }
/// Forget what a mirror folder synced before, so its next pass compares the
/// two sides as on the first sync: what one side has alone is copied to the
/// other, and a file that differs is kept twice as a conflict. Nothing is
/// deleted on either side.
///
/// This is the way out for a folder whose passes refuse to run because its
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
/// is what makes that read as "delete everything", and it is what this
/// drops. A queued switch to on-demand is dropped with it: the push pass
/// that switch runs reads an unrecorded local file as new and would upload
/// a second copy of every file Drive already has.
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
let lock = self.sync_lock(id);
let Some(_guard) = lock.try_lock() else {
return Err(CoreError::conflict(
"this folder is syncing; try again when the pass ends",
));
};
let folder = self
.db
.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.mode != "mirror" {
return Err(CoreError::invalid(
"an online-only folder keeps nothing on this computer to sync from scratch",
));
}
self.db
.sync_entries_clear(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
if folder.pending_mode.as_deref() == Some("ondemand") {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(id, path = %folder.local_path, "sync folder reset");
Ok(format!(
"{} will sync from scratch",
base_name(&folder.local_path)
))
}
/// Spawn a secondary FUSE session rooted at `root` over `local` from a /// Spawn a secondary FUSE session rooted at `root` over `local` from a
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave /// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY). /// one, which would fail the fresh mount with EBUSY).
+86 -23
View File
@@ -389,13 +389,7 @@ impl Core {
// A name a change of ours is about to free is a wait, not a // A name a change of ours is about to free is a wait, not a
// failure: the trash or rename holding it lands in moments. // failure: the trash or rename holding it lands in moments.
let held = waits_for_name(e.as_ref()); let held = waits_for_name(e.as_ref());
let backoff = if held { let backoff = drain_backoff(attempts, held, op.id);
DRAIN_BACKOFF_MIN
} else {
DRAIN_BACKOFF_MIN
.saturating_mul(1u32 << attempts.min(6))
.min(DRAIN_BACKOFF_MAX)
};
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry"); warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
if let Err(e) = self.db.record_attempt_failure( if let Err(e) = self.db.record_attempt_failure(
op.id, op.id,
@@ -1118,7 +1112,7 @@ impl Core {
_ => None, _ => None,
}; };
let sent = self.create_sent_digests(op.id, current); let sent = self.create_sent_digests(op.id, current);
Ok(adoptable(is_dir, &twin, op.created_at, &sent).then_some(twin.uid)) Ok(adoptable(is_dir, &twin, self.first_sent(op), &sent).then_some(twin.uid))
} }
/// Record the name the remote actually gave a node, after a conflict forced /// Record the name the remote actually gave a node, after a conflict forced
@@ -1513,8 +1507,10 @@ impl Core {
.as_deref() .as_deref()
.is_some_and(|sha| holds_bytes(&twin, sha)); .is_some_and(|sha| holds_bytes(&twin, sha));
let sent = self.create_sent_digests(op.id, current); let sent = self.create_sent_digests(op.id, current);
Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at, &sent) Ok(
.then_some((twin.uid, holds_blob))) adoptable(op.kind == OP_MKDIR, &twin, self.first_sent(op), &sent)
.then_some((twin.uid, holds_blob)),
)
} }
/// The SHA-1s of the bytes queued create `id` may have made its file with: /// The SHA-1s of the bytes queued create `id` may have made its file with:
@@ -1529,6 +1525,22 @@ impl Core {
sent sent
} }
/// When create `op` first went out, which is the earliest a node its lost
/// answer left can have been made. An op queued offline may wait for days
/// before that; measured from when it was queued, the adoption window let
/// it take a node another device made in the meantime. Ops sent before the
/// time was recorded fall back to when they were queued.
fn first_sent(&self, op: &PendingOp) -> i64 {
match self.db.create_first_sent(op.id) {
Ok(Some(at)) => at.max(op.created_at),
Ok(None) => op.created_at,
Err(e) => {
warn!(id = op.id, error = %e, "reading when a queued create was sent failed");
op.created_at
}
}
}
/// Make one queued `create`/`mkdir` real under a given name, and hand back /// Make one queued `create`/`mkdir` real under a given name, and hand back
/// the API's own error so the caller can tell a name clash from a failure. /// the API's own error so the caller can tell a name clash from a failure.
/// ///
@@ -1541,7 +1553,7 @@ impl Core {
name: &str, name: &str,
) -> Result<NodeUid, Box<dyn std::error::Error>> { ) -> Result<NodeUid, Box<dyn std::error::Error>> {
self.db self.db
.note_create_target(op.id, &parent.to_string(), name)?; .note_create_target(op.id, &parent.to_string(), name, now_millis())?;
match op.kind == OP_MKDIR { match op.kind == OP_MKDIR {
true => Ok(self.block_on_bounded(self.drive.create_folder( true => Ok(self.block_on_bounded(self.drive.create_folder(
parent, parent,
@@ -1586,7 +1598,7 @@ impl Core {
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len); .begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
let reader = CountingReader::new(File::open(blob)?, &guard); let reader = CountingReader::new(File::open(blob)?, &guard);
let uid = self.block_on_within( let uid = self.block_on_within(
link::upload_deadline(meta.len), link::upload_deadline(meta.len, self.transfers.upload_cap()),
self.drive.upload_file_from( self.drive.upload_file_from(
parent, parent,
name, name,
@@ -1983,7 +1995,7 @@ impl Core {
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len); .begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
let reader = CountingReader::new(File::open(blob)?, &guard); let reader = CountingReader::new(File::open(blob)?, &guard);
self.block_on_within( self.block_on_within(
link::upload_deadline(meta.len), link::upload_deadline(meta.len, self.transfers.upload_cap()),
self.drive.upload_file_from( self.drive.upload_file_from(
&parent, &parent,
&alt, &alt,
@@ -2236,7 +2248,7 @@ impl Core {
.clone() .clone()
.ok_or("pending op has no staged blob")?; .ok_or("pending op has no staged blob")?;
let blob = PathBuf::from(blob); let blob = PathBuf::from(blob);
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?; let mut meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?; let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? { if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? {
@@ -2275,6 +2287,22 @@ impl Core {
&written, &written,
) )
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?; .map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
// The blob is whole now. Recorded, so that a retry after a lost
// answer can match these bytes against the server's revision
// (`revision_conflict`) instead of forking a conflict copy of the
// user's own write.
meta.complete = true;
match serde_json::to_string(&meta) {
Ok(json) => {
let path = op.blob_path.as_deref().unwrap_or_default();
if let Err(e) = self.db.update_op_meta_for_blob(op.id, path, &json) {
warn!(%uid, error = %e,
"recording a gap-filled blob as whole failed; \
a lost answer may land it as a conflict copy");
}
}
Err(e) => warn!(%uid, error = %e, "serializing a gap-filled sidecar failed"),
}
} }
let name = self.node_name(&uid); let name = self.node_name(&uid);
@@ -2295,7 +2323,7 @@ impl Core {
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag()); let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
let started = Instant::now(); let started = Instant::now();
let sent = self.block_on_within( let sent = self.block_on_within(
link::upload_deadline(meta.len), link::upload_deadline(meta.len, self.transfers.upload_cap()),
self.drive self.drive
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None), .upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
); );
@@ -2753,24 +2781,43 @@ fn waits_for_name(e: &(dyn std::error::Error + 'static)) -> bool {
std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::<NameHeld>()) std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::<NameHeld>())
} }
/// How long an op waits after its `attempts`-th failure: doubling from
/// [`DRAIN_BACKOFF_MIN`] up to [`DRAIN_BACKOFF_MAX`], plus up to a fifth more,
/// different for each op (`seed`), so that ops that failed together are not
/// all retried in the same second. One that waits for a name (`held`) waits
/// the minimum.
///
/// The doubling stopped at 2^6, 128 s, so the maximum was never reached.
fn drain_backoff(attempts: i64, held: bool, seed: i64) -> Duration {
if held {
return DRAIN_BACKOFF_MIN;
}
let base = DRAIN_BACKOFF_MIN
.saturating_mul(1u32 << attempts.clamp(0, 8))
.min(DRAIN_BACKOFF_MAX);
// A cheap, stable spread: the same op and attempt always wait the same.
let spread = (seed.wrapping_mul(0x9E37_79B9).wrapping_add(attempts) as u64 % 21) as u32;
base + base * spread / 100
}
/// How many attempts an op waits for a rename or a create on the wire to free /// How many attempts an op waits for a rename or a create on the wire to free
/// the name it wants. Two files swapping names each wait for the other, so the /// the name it wants. Two files swapping names each wait for the other, so the
/// wait has to end; a conflict name ends it. /// wait has to end; a conflict name ends it.
const NAME_HOLD_ATTEMPTS: i64 = 4; const NAME_HOLD_ATTEMPTS: i64 = 4;
/// How much older than its op a remote node may be and still be the one an /// How much older than its op's first send a remote node may be and still be
/// unanswered call made. The op is queued the moment the call gives up, so the /// the one an unanswered call made. The node is made during that call, so it
/// node is at most one call's deadline older; the rest is clock skew between /// is at most one call's deadline older; the rest is clock skew between this
/// this machine and Proton. /// machine and Proton.
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000; const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
/// Whether `twin`, found holding the name a queued create wants, is the node an /// Whether `twin`, found holding the name a queued create wants, is the node an
/// earlier, unanswered attempt at that create made: the same kind, created /// earlier, unanswered attempt at that create made: the same kind, created no
/// around when the op was queued, and for a file still empty, as the mount's /// earlier than around when the op was first sent (`first_sent_ms`), and for a file still empty, as the mount's
/// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is /// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is
/// one of `sent`. /// one of `sent`.
fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64, sent: &[String]) -> bool { fn adoptable(is_dir: bool, twin: &Node, first_sent_ms: i64, sent: &[String]) -> bool {
if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS { if twin.creation_time.saturating_mul(1000) < first_sent_ms - ADOPT_WINDOW_MS {
return false; return false;
} }
match &twin.kind { match &twin.kind {
@@ -3352,6 +3399,22 @@ mod tests {
} }
} }
#[test]
fn a_failing_op_backs_off_to_the_maximum_with_a_spread() {
let at = |attempts| drain_backoff(attempts, false, 7);
assert!(at(1) >= DRAIN_BACKOFF_MIN * 2);
assert!(at(2) > at(1));
// Reached, which the doubling capped at 2^6 never did.
assert!(at(8) >= DRAIN_BACKOFF_MAX);
assert!(at(30) <= DRAIN_BACKOFF_MAX + DRAIN_BACKOFF_MAX / 5);
// Ops that failed together do not all come back together.
let waits: std::collections::HashSet<_> =
(1..=20).map(|id| drain_backoff(8, false, id)).collect();
assert!(waits.len() > 5, "{waits:?}");
// A wait for a name stays short however often it repeats.
assert_eq!(drain_backoff(30, true, 7), DRAIN_BACKOFF_MIN);
}
#[test] #[test]
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() { fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
// The op was queued at t=1000 s, after a create that got no answer. // The op was queued at t=1000 s, after a create that got no answer.
+54 -3
View File
@@ -8,8 +8,10 @@
//! real client implements it by forwarding, and the simulation tests implement //! real client implements it by forwarding, and the simulation tests implement
//! it with an in-memory Drive that injects Drive's known faults. //! it with an in-memory Drive that injects Drive's known faults.
//! //!
//! Sharing, Photos, devices, invitations and public links stay on the concrete //! Sharing, Photos, invitations, public links and the rest of the device calls
//! client (`Core::client`) until a test needs them faked. //! stay on the concrete client (`Core::client`) until a test needs them faked.
//! Listing and registering devices are here, so a simulated daemon can sync a
//! local folder.
//! //!
//! The method names match the client's own, so a call site reads the same //! The method names match the client's own, so a call site reads the same
//! whichever it goes through. The client's generic methods (a reader or writer //! whichever it goes through. The client's generic methods (a reader or writer
@@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::error::Result; 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};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail, AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
ProtonDriveClient, RevisionReader, Thumbnail,
}; };
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`]. /// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
@@ -135,6 +138,22 @@ pub(crate) trait DriveApi: Send + Sync {
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>; async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
/// See [`ProtonDriveClient::copy_node`].
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid>;
/// See [`ProtonDriveClient::get_available_name`].
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String>;
fn move_nodes_streaming<'a>( fn move_nodes_streaming<'a>(
&'a self, &'a self,
items: Vec<NodeMoveItem>, items: Vec<NodeMoveItem>,
@@ -154,6 +173,12 @@ pub(crate) trait DriveApi: Send + Sync {
/// The account's storage, across every Proton product. /// The account's storage, across every Proton product.
async fn quota(&self) -> Result<Quota>; async fn quota(&self) -> Result<Quota>;
/// Every computer that backs up to the account.
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
/// Register a computer, with a root folder of its own named `name`.
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
/// Called by the drain once it has let go of the blob of an upload to /// Called by the drain once it has let go of the blob of an upload to
/// `uid` that landed, or retired the create that made `uid`, before the /// `uid` that landed, or retired the create that made `uid`, before the
/// tree has the node, so a simulation can hold it there. Blocks the drain /// tree has the node, so a simulation can hold it there. Blocks the drain
@@ -439,6 +464,24 @@ impl DriveApi for ProtonDriveClient {
ProtonDriveClient::move_node(self, uid, new_parent).await ProtonDriveClient::move_node(self, uid, new_parent).await
} }
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
}
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String> {
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
}
fn move_nodes_streaming<'a>( fn move_nodes_streaming<'a>(
&'a self, &'a self,
items: Vec<NodeMoveItem>, items: Vec<NodeMoveItem>,
@@ -470,4 +513,12 @@ impl DriveApi for ProtonDriveClient {
async fn quota(&self) -> Result<Quota> { async fn quota(&self) -> Result<Quota> {
ProtonDriveClient::quota(self).await ProtonDriveClient::quota(self).await
} }
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
ProtonDriveClient::enumerate_devices(self).await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
ProtonDriveClient::create_device(self, name, device_type).await
}
} }
+1
View File
@@ -329,6 +329,7 @@ mod tests {
online: true, online: true,
session_expired: false, session_expired: false,
tokens_unsaved: false, tokens_unsaved: false,
version: "3.3.0".into(),
pending_uploads: 1, pending_uploads: 1,
pending_changes: 0, pending_changes: 0,
}; };
+340 -120
View File
@@ -261,10 +261,13 @@ impl Filesystem for ProtonFs {
// in-flight (possibly unsaved) content: authored bytes come from the // in-flight (possibly unsaved) content: authored bytes come from the
// scratch file, untouched bytes from the base. // scratch file, untouched bytes from the base.
// //
// This path stays on the dispatch loop. `write` runs there too, so // Answered on the dispatch loop when everything it needs is on this
// keeping its reads there as well preserves the ordering the write path // machine, which keeps the order `write` has with reads of the same
// has always had between a write and a read of the same handle; a mostly // handle. A base range that has to be downloaded goes to a worker
// local read is not worth reasoning about that for. // instead, with the handle as it was when the read came in: the kernel
// issued the read before any write that comes after it, so that is
// the content it asked for. On the loop, the download held every
// request of the mount (`docs/BUGS.md` B227).
let handle = { let handle = {
let st = self.core.state(); let st = self.core.state();
st.active_writes.get(&ino.0).map(|h| { st.active_writes.get(&ino.0).map(|h| {
@@ -279,7 +282,7 @@ impl Filesystem for ProtonFs {
}) })
}; };
if let Some((file, len, uid, base_mtime, base_size, written)) = handle { if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
match self.core.serve_open_read( match self.core.serve_open_read_local(
&file, &file,
len, len,
&uid, &uid,
@@ -289,7 +292,25 @@ impl Filesystem for ProtonFs {
offset, offset,
size as u64, size as u64,
) { ) {
Ok(bytes) => reply.data(&bytes), Ok(Some(bytes)) => reply.data(&bytes),
Ok(None) => {
let fs = self.clone();
self.core.workers.run(Lane::Transfer, "read", move || {
match fs.core.serve_open_read(
&file,
len,
&uid,
base_mtime,
base_size,
&written,
offset,
size as u64,
) {
Ok(bytes) => reply.data(&bytes),
Err(e) => reply.error(e),
}
});
}
Err(e) => reply.error(e), Err(e) => reply.error(e),
} }
return; return;
@@ -409,58 +430,10 @@ impl Filesystem for ProtonFs {
reply.error(error); reply.error(error);
return; return;
} }
let fh = fh.0; match self.stage_write(ino.0, fh.0, offset, data) {
// Stage the bytes straight into the scratch file (no base download): only Ok(()) => reply.written(data.len() as u32),
// the untouched remainder is pulled from the remote, and only at commit. Err(error) => reply.error(error),
let file = {
let st = self.core.state();
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
Some(aw) => aw.file.clone(),
None => {
reply.error(Errno::EBADF);
return;
}
}
};
if let Err(e) = file.write_all_at(data, offset) {
error!(ino = ino.0, fh, error = %e, "scratch write failed");
// A full local disk is not a broken file: answer `ENOSPC` so the
// application can say so (and so `cp`/editors take their own
// out-of-space path), and free what the cache is holding — the
// scratch file the next write goes to shares that filesystem.
if e.raw_os_error() == Some(libc::ENOSPC) {
self.core.cache.emergency_evict();
reply.error(Errno::ENOSPC);
} else {
reply.error(Errno::EIO);
}
return;
} }
let new_len = {
let mut st = self.core.state();
let Some(aw) = st
.handles
.get(&fh)
.copied()
.and_then(|i| st.active_writes.get_mut(&i))
else {
reply.error(Errno::EBADF);
return;
};
aw.record_write(offset, offset + data.len() as u64);
let len = aw.len;
st.set_size(ino.0, len);
len
};
debug!(
ino = ino.0,
fh,
offset,
len = data.len(),
new_len,
"staged write"
);
reply.written(data.len() as u32);
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -556,48 +529,62 @@ impl Filesystem for ProtonFs {
return; return;
} }
let new_end = offset.saturating_add(length); let new_end = offset.saturating_add(length);
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE // Only the modes whose effect on the scratch file this handle can
// record. Anything else went through to the scratch file untracked:
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE // a zeroed range was refilled from the base at commit, and a collapse
// or insert shifted bytes that `len` and `written` did not follow
let res = { // (`docs/BUGS.md` B224). `EOPNOTSUPP` is also what makes glibc's
// `posix_fallocate` fall back to writing zeros.
let Some(kind) = falloc_kind(mode) else {
reply.error(Errno::EOPNOTSUPP);
return;
};
let file = {
let st = self.core.state();
match st.handles.get(&_fh.0).and_then(|i| st.active_writes.get(i)) {
Some(aw) => aw.file.clone(),
None => {
reply.error(if st.entries.contains_key(&ino.0) {
Errno::EBADF
} else {
Errno::ENOENT
});
return;
}
}
};
// The syscall can take a while on a large range; the state lock is
// not held across it, as `write` does not hold it across its I/O.
let ret = {
use std::os::unix::io::AsRawFd;
unsafe { libc::fallocate(file.as_raw_fd(), mode, offset as i64, length as i64) }
};
let res = if ret != 0 {
Err(Errno::from(std::io::Error::last_os_error()))
} else {
let mut st = self.core.state(); let mut st = self.core.state();
match st if let Some(aw) = st
.handles .handles
.get(&_fh.0) .get(&_fh.0)
.copied() .copied()
.and_then(|i| st.active_writes.get_mut(&i)) .and_then(|i| st.active_writes.get_mut(&i))
{ {
Some(aw) => { let len = aw.len;
use std::os::unix::io::AsRawFd; let (authored, grown) = kind.effect(offset, new_end, len);
let fd = aw.file.as_raw_fd(); if let Some((from, to)) = authored {
let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) }; // Zeroed or punched bytes read as zero. Marked authored so
if ret == 0 { // queue_revision does not refill them from the remote
if is_punch_hole { // baseline and silently undo them at commit.
// A punched range reads as zero. Mark it authored so aw.written.add(from, to);
// queue_revision does not refill it from the remote aw.dirty = true;
// baseline and silently undo the hole at commit.
aw.written.add(offset, new_end.min(aw.len));
aw.dirty = true;
} else if !keep_size && new_end > aw.len {
aw.written.add(aw.len, new_end);
aw.len = new_end;
aw.dirty = true;
st.set_size(ino.0, new_end);
}
Ok(())
} else {
Err(Errno::EIO)
}
} }
None => { if let Some(end) = grown {
if st.entries.contains_key(&ino.0) { aw.len = end;
Err(Errno::EBADF) aw.dirty = true;
} else { st.set_size(ino.0, end);
Err(Errno::ENOENT)
}
} }
} }
Ok(())
}; };
match res { match res {
@@ -606,18 +593,47 @@ impl Filesystem for ProtonFs {
} }
} }
/// `close(2)` calls flush before release. The upload is queued in `release` /// `close(2)` calls flush before release, and flush is the only one of the
/// and performed in the background, so there is nothing /// two whose answer reaches the program: the kernel drops `release`'s. So
/// to push here — the written bytes are already in the scratch file. /// the scratch file of a handle with writes on it is synced here, on a
/// worker, and a full disk or a failing one is what `close` returns.
///
/// Before, flush answered `ok` at once and `release` synced the file, so an
/// `ENOSPC` that delayed allocation held back until then was reported to
/// nobody, and the program took the save as done (`docs/BUGS.md` B222).
/// It also means `release`, which stays on the dispatch loop for ordering,
/// finds the pages already on disk instead of writing back a whole file
/// while every other request on the mount waits.
fn flush( fn flush(
&self, &self,
_req: &Request, _req: &Request,
_ino: INodeNo, _ino: INodeNo,
_fh: FileHandle, fh: FileHandle,
_lock_owner: LockOwner, _lock_owner: LockOwner,
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
reply.ok(); let file = {
let st = self.core.state();
st.handles
.get(&fh.0)
.and_then(|ino| st.active_writes.get(ino))
.filter(|aw| aw.dirty)
.map(|aw| aw.file.clone())
};
let Some(file) = file else {
reply.ok();
return;
};
let fs = self.clone();
self.core
.workers
.run(Lane::Transfer, "flush", move || match file.sync_data() {
Ok(()) => reply.ok(),
Err(e) => {
error!(fh = fh.0, error = %e, "flush of scratch file failed");
reply.error(fs.scratch_sync_errno(&e));
}
});
} }
/// Durability here means the scratch file, not the remote: a caller asking /// Durability here means the scratch file, not the remote: a caller asking
@@ -655,7 +671,7 @@ impl Filesystem for ProtonFs {
reply: ReplyEmpty, reply: ReplyEmpty,
) { ) {
let ino = self.tree(ino); let ino = self.tree(ino);
let (handle, unlinked_uid) = { let (handle, unlinked_uid, releasing) = {
let mut st = self.core.state(); let mut st = self.core.state();
let unlinked_uid = release_unlinked_entry(&mut st, ino.0); let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
let ino = st.handles.remove(&fh.0); let ino = st.handles.remove(&fh.0);
@@ -668,7 +684,11 @@ impl Filesystem for ProtonFs {
None None
} }
}); });
(h, unlinked_uid) let releasing = ino.filter(|_| h.is_some());
if let Some(ino) = releasing {
st.releasing.insert(ino);
}
(h, unlinked_uid, releasing)
}; };
// Deliberately **not** handed to a worker, unlike the other slow // Deliberately **not** handed to a worker, unlike the other slow
// handlers here. The kernel does not wait for a `release` reply before // handlers here. The kernel does not wait for a `release` reply before
@@ -685,6 +705,9 @@ impl Filesystem for ProtonFs {
// finish it (audit F1). What is left is a rename, two fsyncs, a database // finish it (audit F1). What is left is a rename, two fsyncs, a database
// insert, and at most a bounded copy out of the local cache. // insert, and at most a bounded copy out of the local cache.
self.finish_release(handle, unlinked_uid, reply); self.finish_release(handle, unlinked_uid, reply);
if let Some(ino) = releasing {
self.core.state().releasing.remove(&ino);
}
} }
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) { fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
@@ -952,20 +975,59 @@ impl Filesystem for ProtonFs {
fn copy_file_range( fn copy_file_range(
&self, &self,
_req: &Request, _req: &Request,
_ino_in: INodeNo, ino_in: INodeNo,
_fh_in: FileHandle, _fh_in: FileHandle,
_offset_in: u64, offset_in: u64,
_ino_out: INodeNo, ino_out: INodeNo,
_fh_out: FileHandle, fh_out: FileHandle,
_offset_out: u64, offset_out: u64,
_len: u64, len: u64,
_flags: CopyFileRangeFlags, flags: CopyFileRangeFlags,
reply: ReplyWrite, reply: ReplyWrite,
) { ) {
// Also must stay ENOSYS: there is no server-side copy, and this is the // Copied here when the source bytes are on this machine, as `write`
// errno that sends coreutils (and the kernel) down the read/write path // would stage them, minus a FUSE round trip per page: `cp` of a cached
// instead of failing the copy outright. // 256 MiB file ran at about 100 MiB/s through read and write. Anything
reply.error(Errno::ENOSYS); // that would have to be downloaded gets `EOPNOTSUPP`, which makes the
// kernel copy that one call through read and write itself; `ENOSYS`
// would turn the op off for the whole mount. There is no server-side
// copy into a file `cp` has already created, so the copy still uploads.
//
// On the dispatch loop, like `write`, so it keeps its order with the
// reads and writes of the same handle. [`COPY_RANGE_MAX`] bounds how
// long one call holds the loop.
let (ino_in, ino_out) = (self.tree(ino_in), self.tree(ino_out));
if !flags.is_empty() || ino_in == ino_out {
reply.error(Errno::EOPNOTSUPP);
return;
}
if let Err(error) = self.core.require_writable(ino_out.0) {
reply.error(error);
return;
}
let bytes = match self
.core
.read_local(ino_in.0, offset_in, len.min(COPY_RANGE_MAX))
{
Ok(Some(bytes)) => bytes,
Ok(None) => {
debug!(ino_in = ino_in.0, offset_in, "copy range not local");
reply.error(Errno::EOPNOTSUPP);
return;
}
Err(error) => {
reply.error(error);
return;
}
};
if bytes.is_empty() {
reply.written(0);
return;
}
match self.stage_write(ino_out.0, fh_out.0, offset_out, &bytes) {
Ok(()) => reply.written(bytes.len() as u32),
Err(error) => reply.error(error),
}
} }
} }
@@ -993,6 +1055,11 @@ pub struct ProtonFs {
/// worker busy indefinitely. /// worker busy indefinitely.
const OPEN_BASE_ATTEMPTS: u32 = 3; const OPEN_BASE_ATTEMPTS: u32 = 3;
/// The most one `copy_file_range` call copies; the caller loops for the rest.
/// A local copy of this much takes milliseconds, which is how long it holds
/// the dispatch loop.
const COPY_RANGE_MAX: u64 = 16 * 1024 * 1024;
/// One directory's entries as `readdir` reports them: `.` and `..` first, then /// One directory's entries as `readdir` reports them: `.` and `..` first, then
/// the interned children in `children` order. /// the interned children in `children` order.
/// ///
@@ -1022,6 +1089,52 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
/// The cookie handed back is the index of the *next* entry, so a resumed call /// The cookie handed back is the index of the *next* entry, so a resumed call
/// continues exactly where the last one stopped — which is only true because /// continues exactly where the last one stopped — which is only true because
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43). /// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
/// What an `fallocate` mode does to a write handle, for the modes it can
/// record (`docs/BUGS.md` B224).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FallocKind {
/// Reserve space; past the end it grows the file unless `keep_size`.
Allocate { keep_size: bool },
/// Deallocate a range inside the file, which then reads as zero.
Punch,
/// Zero a range; past the end it grows the file unless `keep_size`.
Zero { keep_size: bool },
}
fn falloc_kind(mode: i32) -> Option<FallocKind> {
const KEEP: i32 = libc::FALLOC_FL_KEEP_SIZE;
const PUNCH: i32 = libc::FALLOC_FL_PUNCH_HOLE;
const ZERO: i32 = libc::FALLOC_FL_ZERO_RANGE;
match mode {
0 => Some(FallocKind::Allocate { keep_size: false }),
KEEP => Some(FallocKind::Allocate { keep_size: true }),
m if m == PUNCH | KEEP => Some(FallocKind::Punch),
ZERO => Some(FallocKind::Zero { keep_size: false }),
m if m == ZERO | KEEP => Some(FallocKind::Zero { keep_size: true }),
_ => None,
}
}
impl FallocKind {
/// For `[offset, end)` on a file `len` bytes long: the range that now
/// reads as zero and must count as written, and the new length if it grew.
fn effect(self, offset: u64, end: u64, len: u64) -> (Option<(u64, u64)>, Option<u64>) {
let grow = |keep_size: bool| (!keep_size && end > len).then_some(end);
let zeroed = |to: u64| (offset < to).then_some((offset, to));
match self {
// Space past the old end reads as zero, and nothing before it moved.
Self::Allocate { keep_size } => {
(grow(keep_size).map(|end| (len, end)), grow(keep_size))
}
Self::Punch => (zeroed(end.min(len)), None),
Self::Zero { keep_size } => {
let to = if keep_size { end.min(len) } else { end };
(zeroed(to), grow(keep_size))
}
}
}
}
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) { fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) { for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
let ft = if *is_dir { let ft = if *is_dir {
@@ -1435,6 +1548,20 @@ impl ProtonFs {
} }
/// The body of [`Filesystem::fsync`], run off the dispatch loop. /// The body of [`Filesystem::fsync`], run off the dispatch loop.
/// The errno to give a program for a failed sync of a scratch file.
/// Delayed allocation means a full disk usually surfaces at the sync
/// rather than at `write(2)`, and `ENOSPC` is what a program checks for.
fn scratch_sync_errno(&self, e: &std::io::Error) -> Errno {
match e.raw_os_error() {
Some(libc::ENOSPC) => {
self.core.cache.emergency_evict();
Errno::ENOSPC
}
Some(libc::EDQUOT) => Errno::EDQUOT,
_ => Errno::EIO,
}
}
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) { fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
// Everything the durability sidecar needs, copied out so no lock is held // Everything the durability sidecar needs, copied out so no lock is held
// across the sync. // across the sync.
@@ -1455,22 +1582,18 @@ impl ProtonFs {
) )
}) })
}; };
// A handle opened for reading has nothing of its own to make durable,
// and POSIX allows `fsync` on it; `EBADF` failed `sync FILE` and
// backup tools that sync what they read (`docs/BUGS.md` B222).
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) = let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
handle handle
else { else {
reply.error(Errno::EBADF); reply.ok();
return; return;
}; };
if let Err(e) = f.sync_all() { if let Err(e) = f.sync_all() {
error!(fh = fh.0, error = %e, "fsync of scratch file failed"); error!(fh = fh.0, error = %e, "fsync of scratch file failed");
// Delayed allocation means a full disk usually surfaces here rather reply.error(self.scratch_sync_errno(&e));
// than at `write(2)`; `ENOSPC` is what an application checks for.
if e.raw_os_error() == Some(libc::ENOSPC) {
self.core.cache.emergency_evict();
reply.error(Errno::ENOSPC);
} else {
reply.error(Errno::EIO);
}
return; return;
} }
// A clean handle has nothing on it that the remote does not already // A clean handle has nothing on it that the remote does not already
@@ -1521,6 +1644,48 @@ impl ProtonFs {
} }
/// The body of [`Filesystem::create`], run off the dispatch loop. /// The body of [`Filesystem::create`], run off the dispatch loop.
/// Stage `data` at `offset` of the write handle `fh` on `ino`, straight into
/// its scratch file (no base download): only the untouched remainder is
/// pulled from the remote, and only at commit.
fn stage_write(&self, ino: u64, fh: u64, offset: u64, data: &[u8]) -> Result<(), Errno> {
let file = {
let st = self.core.state();
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
Some(aw) => aw.file.clone(),
None => return Err(Errno::EBADF),
}
};
if let Err(e) = file.write_all_at(data, offset) {
error!(ino, fh, error = %e, "scratch write failed");
// A full local disk is not a broken file: answer `ENOSPC` so the
// application can say so (and so `cp`/editors take their own
// out-of-space path), and free what the cache is holding — the
// scratch file the next write goes to shares that filesystem.
if e.raw_os_error() == Some(libc::ENOSPC) {
self.core.cache.emergency_evict();
return Err(Errno::ENOSPC);
}
return Err(Errno::EIO);
}
let new_len = {
let mut st = self.core.state();
let Some(aw) = st
.handles
.get(&fh)
.copied()
.and_then(|i| st.active_writes.get_mut(&i))
else {
return Err(Errno::EBADF);
};
aw.record_write(offset, offset + data.len() as u64);
let len = aw.len;
st.set_size(ino, len);
len
};
debug!(ino, fh, offset, len = data.len(), new_len, "staged write");
Ok(())
}
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) { fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
if let Err(error) = self.core.require_writable(parent) { if let Err(error) = self.core.require_writable(parent) {
reply.error(error); reply.error(error);
@@ -1717,9 +1882,7 @@ impl ProtonFs {
// empty, which it is, so reads stay offline-clean across a restart too. // empty, which it is, so reads stay offline-clean across a restart too.
if local { if local {
st.children.insert(ino, Vec::new()); st.children.insert(ino, Vec::new());
if let Err(e) = self.core.db.set_listed(&uid, true) { st.record_listed(uid);
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
}
} }
let entry = st.entries.get(&ino).unwrap(); let entry = st.entries.get(&ino).unwrap();
let attr = self.attr(ino, &entry.node, entry.access); let attr = self.attr(ino, &entry.node, entry.access);
@@ -2337,3 +2500,60 @@ impl ProtonFs {
} }
} }
} }
#[cfg(test)]
mod falloc_tests {
use super::*;
#[test]
fn only_the_modes_a_handle_can_record_are_accepted() {
let keep = libc::FALLOC_FL_KEEP_SIZE;
assert_eq!(
falloc_kind(0),
Some(FallocKind::Allocate { keep_size: false })
);
assert_eq!(
falloc_kind(keep),
Some(FallocKind::Allocate { keep_size: true })
);
assert_eq!(
falloc_kind(libc::FALLOC_FL_PUNCH_HOLE | keep),
Some(FallocKind::Punch)
);
assert_eq!(
falloc_kind(libc::FALLOC_FL_ZERO_RANGE),
Some(FallocKind::Zero { keep_size: false })
);
for mode in [
libc::FALLOC_FL_PUNCH_HOLE,
libc::FALLOC_FL_COLLAPSE_RANGE,
libc::FALLOC_FL_INSERT_RANGE,
libc::FALLOC_FL_UNSHARE_RANGE,
] {
assert_eq!(falloc_kind(mode), None, "{mode:#x}");
}
}
/// A zeroed range was left unwritten, so the commit filled it back in
/// from the remote base (`docs/BUGS.md` B224).
#[test]
fn a_zeroed_or_punched_range_counts_as_written() {
let zero = FallocKind::Zero { keep_size: false };
assert_eq!(zero.effect(10, 20, 100), (Some((10, 20)), None));
assert_eq!(zero.effect(90, 120, 100), (Some((90, 120)), Some(120)));
let zero_keep = FallocKind::Zero { keep_size: true };
assert_eq!(zero_keep.effect(90, 120, 100), (Some((90, 100)), None));
assert_eq!(
FallocKind::Punch.effect(90, 120, 100),
(Some((90, 100)), None)
);
assert_eq!(FallocKind::Punch.effect(120, 130, 100), (None, None));
let alloc = FallocKind::Allocate { keep_size: false };
assert_eq!(alloc.effect(0, 50, 100), (None, None));
assert_eq!(alloc.effect(90, 120, 100), (Some((100, 120)), Some(120)));
assert_eq!(
FallocKind::Allocate { keep_size: true }.effect(90, 120, 100),
(None, None)
);
}
}
+457 -73
View File
@@ -74,8 +74,8 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::ProtonError; use proton_drive_rs::proton_sdk::error::ProtonError;
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId}; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem, ProtonDriveClient, AvailableNameTarget, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem,
ProtonPhotosClient, SharedWithMeItem, ThumbnailType, ProtonDriveClient, ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
}; };
mod albums; mod albums;
@@ -116,6 +116,7 @@ mod upload;
mod r#virtual; mod r#virtual;
mod workers; mod workers;
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync}; 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(crate) use mount::is_stale_mount;
pub use mount::{MountOptions, MountOutcome, mount}; pub use mount::{MountOptions, MountOutcome, mount};
use mount::{ use mount::{
@@ -2120,12 +2121,39 @@ impl Core {
} }
} }
/// Whether `ino`'s listing is already in memory, i.e. whether /// Whether [`Core::ensure_children`] would answer for `ino` without
/// [`Core::ensure_children`] would return without touching the network. /// touching the network. Lets a handler decide between answering inline
/// Lets a handler decide between answering inline and handing off to a /// and handing off to a worker.
/// worker, at the cost of one uncontended map lookup. ///
/// A resident listing is not enough on its own. A shared folder's, or the
/// Shared with me root's, is fetched again once it is a minute old, and
/// that fetch ran on the dispatch loop, so every request on the mount
/// waited for Drive to answer (`docs/BUGS.md` B219).
fn children_cached(&self, ino: u64) -> bool { fn children_cached(&self, ino: u64) -> bool {
self.state().children.contains_key(&ino) let (uid, resident) = {
let st = self.state();
match st.entries.get(&ino) {
Some(e) => (e.uid.clone(), st.children.contains_key(&ino)),
// `list_children` answers ENOENT without the network.
None => return true,
}
};
if !resident {
return false;
}
let freshness = if is_virtual_uid(&uid) {
Some(SHARED_WITH_ME_SYNCED_MS.to_string())
} else if !self.is_own_or_virtual(&uid) && !is_local_uid(&uid) {
Some(Self::shared_folder_freshness_key(&uid))
} else {
None
};
freshness.is_none_or(|key| {
!listing_needs_refresh(
self.online.load(Ordering::Relaxed),
self.shared_listing_stale(&key),
)
})
} }
/// Re-apply the optimistic size of any queued write to `nodes`. /// Re-apply the optimistic size of any queued write to `nodes`.
@@ -2293,8 +2321,7 @@ impl Core {
let generation = self.shared_generation.load(Ordering::SeqCst); let generation = self.shared_generation.load(Ordering::SeqCst);
let accepted = accepted_share_provenance( let accepted = accepted_share_provenance(
self.rt self.block_on_within(BATCH_CALL_TIMEOUT, self.client.enumerate_shared_with_me())
.block_on(self.client.enumerate_shared_with_me())
.map_err(|error| { .map_err(|error| {
error!(%error, "enumerating shared roots failed"); error!(%error, "enumerating shared roots failed");
Errno::EIO Errno::EIO
@@ -2303,12 +2330,14 @@ impl Core {
let nodes = if accepted.uids.is_empty() { let nodes = if accepted.uids.is_empty() {
Vec::new() Vec::new()
} else { } else {
self.rt self.block_on_within(
.block_on(self.drive.enumerate_nodes_light(&accepted.uids)) BATCH_CALL_TIMEOUT,
.map_err(|error| { self.drive.enumerate_nodes_light(&accepted.uids),
error!(%error, "materializing shared roots failed"); )
Errno::EIO .map_err(|error| {
})? error!(%error, "materializing shared roots failed");
Errno::EIO
})?
}; };
let virtual_uid = shared_with_me_uid(); let virtual_uid = shared_with_me_uid();
let published = prepare_shared_roots(&accepted, nodes, &virtual_uid); let published = prepare_shared_roots(&accepted, nodes, &virtual_uid);
@@ -2687,6 +2716,7 @@ impl Core {
return Ok(()); return Ok(());
} }
let preassigned = state::inos_for(&self.db, &filtered_nodes);
let mut st = self.state(); let mut st = self.state();
// Lost the race? Another thread already populated it. // Lost the race? Another thread already populated it.
if st.children.contains_key(&ino) { if st.children.contains_key(&ino) {
@@ -2708,15 +2738,14 @@ impl Core {
}) })
.map(|n| n.uid.clone()) .map(|n| n.uid.clone())
.collect(); .collect();
let inos = st.intern_batch(ino, filtered_nodes); let inos = st.intern_batch_with_inos(ino, filtered_nodes, preassigned);
child_inos.extend(inos); child_inos.extend(inos);
st.children.insert(ino, child_inos); st.children.insert(ino, child_inos);
let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap()); let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap());
// Record the listing as complete so a later restart (or a trimmed hot // Record the listing as complete so a later restart (or a trimmed hot
// cache) can rebuild it from the DB without the API. // cache) can rebuild it from the DB without the API. Written with the
if let Err(e) = self.db.set_listed(&folder_uid, true) { // children's rows when the lock goes, not under it.
warn!(%folder_uid, error = %e, "db set_listed(true) failed"); st.record_listed(folder_uid.clone());
}
drop(st); drop(st);
self.flush_access_changes(); self.flush_access_changes();
if let Some(snapshot) = root_snapshot { if let Some(snapshot) = root_snapshot {
@@ -2879,7 +2908,8 @@ impl Core {
let (mut key, mut uids, mut slot) = (key, uids, slot); let (mut key, mut uids, mut slot) = (key, uids, slot);
loop { loop {
for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) { for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) {
let result = self.rt.block_on(self.drive.enumerate_nodes(chunk)); let result =
self.block_on_within(BATCH_CALL_TIMEOUT, self.drive.enumerate_nodes(chunk));
self.apply_size_upgrade(key, result); self.apply_size_upgrade(key, result);
slot.chunk_done(); slot.chunk_done();
} }
@@ -4227,8 +4257,10 @@ impl Core {
} }
/// Pin the node at mountpoint-relative `rel`. A file downloads its full /// Pin the node at mountpoint-relative `rel`. A file downloads its full
/// plaintext into the content cache; a folder records a recursive pin and /// plaintext into the content cache before this returns. A folder records a
/// downloads every descendant file (selective sync). Returns a human message. /// 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> { fn pin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder, mtime, size) = { let (name, is_folder, mtime, size) = {
@@ -4245,34 +4277,93 @@ impl Core {
) )
}; };
if is_folder { if is_folder {
// Offline, every download would fail and the folder would only look
// kept. Refuse instead, as a file pin does.
if !self.is_online() {
return Err(CoreError::offline());
}
// Record the recursive pin first so every descendant is eviction- // Record the recursive pin first so every descendant is eviction-
// exempt before we start filling the cache with the subtree. // exempt before we start filling the cache with the subtree.
self.cache self.cache
.add_pin(&uid, rel, true) .add_pin(&uid, rel, true)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
let n = self.pin_subtree(ino)?; let core = self.clone();
return Ok(format!("{name} ({n} files)")); let folder = name.clone();
std::thread::Builder::new()
.name("pin-folder".into())
.spawn(move || core.pin_subtree(ino, &uid, &folder))
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
return Ok(format!("{name}: downloading its files in the background"));
} }
let bytes = self self.fetch_content(&uid, &name, mtime, size)?;
.download_file_tracked(&uid, &name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
self.cache self.cache
.add_pin(&uid, rel, false) .add_pin(&uid, rel, false)
.map_err(|e| CoreError::from_api(&e, "pin"))?; .map_err(|e| CoreError::from_api(&e, "pin"))?;
Ok(name) Ok(name)
} }
/// Download and cache every file in the subtree rooted at folder `ino`, /// Download and cache every file in the subtree rooted at the pinned folder
/// returning the count cached (already-fresh blobs counted, not re-fetched). /// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
/// Walks the tree depth-first, enumerating each folder so a cold subtree is /// tree depth-first, enumerating each folder so a cold subtree is fully
/// fully discovered; the lock is dropped before each network download. /// discovered; the lock is dropped before each network download. Stops when
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> { /// the folder is unpinned or the daemon stops.
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
let job = self
.transfers
.begin_job(format!("Making {name} available offline"));
match self.pin_subtree_files(ino) {
Ok(files) => {
job.set_total(files.len() as u64);
let mut failed = 0usize;
for (uid, file, mtime, size) 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;
}
job.step();
}
info!(folder = %name, failed, "pin folder: done");
}
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
}
}
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
/// before the download, and a create landing or a remote edit can change
/// them while it runs. The blob is then stored under the old ones and reads
/// as stale, so the file is listed as kept but not available offline. Fetch
/// it again under the new ones, a few times at most.
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
let mut fetched = (mtime, size);
for _ in 0..3 {
self.fetch_content(uid, name, fetched.0, fetched.1)?;
let current = {
let st = self.state();
st.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| (e.node.modification_time, node_size(&e.node)))
};
match current {
Some(now) if now != fetched => fetched = now,
_ => break,
}
}
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(); let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
let mut stack = vec![ino]; let mut stack = vec![ino];
while let Some(dir) = stack.pop() { while let Some(dir) = stack.pop() {
if self.shutdown.is_stopping() {
break;
}
self.ensure_children(dir) self.ensure_children(dir)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
let st = self.state(); let st = self.state();
@@ -4293,22 +4384,7 @@ impl Core {
} }
} }
} }
let mut count = 0; Ok(files)
for (uid, name, mtime, size) in files {
if self.cache.is_cached(&uid, mtime, size) {
count += 1;
continue;
}
match self.download_file_tracked(&uid, &name, size) {
Ok(bytes) => {
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
count += 1;
}
}
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
}
}
Ok(count)
} }
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache /// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
@@ -4332,9 +4408,15 @@ impl Core {
if self.thumbnail_misses.lock().remote_contains(&key, mtime) { if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
return Ok(None); return Ok(None);
} }
// A file manager asks for one per file in a folder, on the metadata
// workers. Offline, or on a stalled link, each one held a worker for
// the SDK's whole retry budget, and every cold lookup, create and
// rename on the mount queued behind them (`docs/BUGS.md` B220).
if !self.is_online() {
return Ok(None);
}
let bytes = self let bytes = self
.rt .block_on_bounded(self.client.download_thumbnail(&uid, ttype))
.block_on(self.client.download_thumbnail(&uid, ttype))
.map_err(|e| { .map_err(|e| {
warn!(%uid, error = %e, "download thumbnail failed"); warn!(%uid, error = %e, "download thumbnail failed");
Errno::EIO Errno::EIO
@@ -4354,6 +4436,8 @@ impl Core {
/// Unpin the node at `rel`, evicting its cached content. For a folder, also /// Unpin the node at `rel`, evicting its cached content. For a folder, also
/// evicts every descendant's cached blob (the subtree is no longer kept). /// evicts every descendant's cached blob (the subtree is no longer kept).
/// A node kept only because a folder above it is pinned is refused: there
/// is no pin of its own to remove, and it would stay kept all the same.
fn unpin(&self, rel: &Path) -> CoreResult<String> { fn unpin(&self, rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
let (name, is_folder) = { let (name, is_folder) = {
@@ -4363,14 +4447,24 @@ impl Core {
.map(|e| (e.node.name.clone(), e.node.is_folder())) .map(|e| (e.node.name.clone(), e.node.is_folder()))
.unwrap_or_default() .unwrap_or_default()
}; };
if self.pinned_by_folder(ino) {
return Err(CoreError::conflict(format!(
"{name} is kept offline because a folder above it is; unpin that folder instead"
)));
}
self.cache self.cache
.remove_pin(&uid) .remove_pin(&uid)
.map_err(|e| CoreError::from_api(&e, "unpin"))?; .map_err(|e| CoreError::from_api(&e, "unpin"))?;
// A recursively-pinned folder's descendants were eviction-exempt; now // A recursively-pinned folder's descendants were eviction-exempt; now
// that the pin is gone, reclaim their blobs eagerly instead of waiting // that the pin is gone, reclaim their blobs eagerly instead of waiting
// for budget pressure. Descendants come from the DB node tree. // for budget pressure. Descendants come from the DB node tree. One
// pinned on its own, or under a pinned subfolder, is still kept.
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) { if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
let still_pinned = self.pinned_set();
for s in uids { for s in uids {
if still_pinned.contains(&s) {
continue;
}
if let Some(u) = parse_uid(&s) { if let Some(u) = parse_uid(&s) {
self.cache.evict(&u); self.cache.evict(&u);
self.evict_reader(&u); self.evict_reader(&u);
@@ -4380,6 +4474,20 @@ impl Core {
Ok(name) Ok(name)
} }
/// Whether the node at `ino` is kept offline because a folder above it is
/// pinned, whether or not it is also pinned on its own.
fn pinned_by_folder(&self, ino: u64) -> bool {
let parent = {
let st = self.state();
st.entries
.get(&ino)
.filter(|e| e.parent != ino)
.and_then(|e| st.entries.get(&e.parent))
.map(|p| p.uid.clone())
};
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
}
/// A Photos API handle sharing this Core's Drive client and session, so it /// A Photos API handle sharing this Core's Drive client and session, so it
/// reuses the daemon's single authenticated session rather than logging in /// reuses the daemon's single authenticated session rather than logging in
/// again (Proton refresh tokens are single-use). Cheap — the Drive client /// again (Proton refresh tokens are single-use). Cheap — the Drive client
@@ -4392,10 +4500,13 @@ impl Core {
/// the same lazy remote enumeration `readdir` uses, projected into /// the same lazy remote enumeration `readdir` uses, projected into
/// serializable [`DirEntry`]s (with per-file pin state). /// serializable [`DirEntry`]s (with per-file pin state).
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> { fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
let (ino, _uid) = self.resolve(rel)?; let (ino, uid) = self.resolve(rel)?;
self.ensure_children(ino) self.ensure_children(ino)
.map_err(|e| self.errno_error(e, "enumerate"))?; .map_err(|e| self.errno_error(e, "enumerate"))?;
self.settle_listing_sizes(ino); self.settle_listing_sizes(ino);
// Every entry shares this folder as its parent, so one lookup says
// whether they are all kept through it.
let pinned_by_folder = self.cache.is_pinned(&uid);
// Snapshot the listing, then drop the lock before touching the on-disk // Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops. // pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = { let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
@@ -4427,7 +4538,8 @@ impl Core {
is_dir, is_dir,
size, size,
modified, modified,
pinned: self.cache.is_pinned(&uid), pinned: pinned_by_folder || self.cache.is_pinned(&uid),
pinned_by_folder,
cached: !is_dir && self.cache.is_cached(&uid, modified, size), cached: !is_dir && self.cache.is_cached(&uid, modified, size),
uid: uid.to_string(), uid: uid.to_string(),
// Listing entries live in the requested dir; the caller derives // Listing entries live in the requested dir; the caller derives
@@ -5094,6 +5206,133 @@ impl Core {
Ok(name) Ok(name)
} }
/// Copy a file or folder into `new_parent_rel` on Proton Drive, which copies
/// the content there: nothing is downloaded or uploaded. Both paths are
/// mountpoint-relative. A name already taken in the folder gets the next
/// free ` (n)` suffix. Returns the copy's name.
///
/// Drive copies what it holds, so the copy is refused offline and while the
/// mount still owes Drive part of it: a node or folder only queued locally,
/// a file with a queued upload or unsaved writes, or — for a folder, whose
/// queued changes could sit anywhere below it — any queued op at all.
fn copy_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
let (ino, uid) = self.resolve(rel)?;
let (pino, new_parent_uid) = self
.resolve_path(new_parent_rel)
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
self.require_uid_writable(&new_parent_uid)
.map_err(|error| self.errno_error(error, "copy access"))?;
let (name, is_folder, unsaved) = {
let st = self.state();
let entry = st
.entries
.get(&ino)
.ok_or_else(|| CoreError::not_found(format!("{} is gone", rel.display())))?;
// Unsaved writes, or a release still queueing them, to the file or,
// for a folder, to any file below it.
let unsaved = st
.active_writes
.iter()
.filter(|(_, w)| w.dirty)
.map(|(&open, _)| open)
.chain(st.releasing.iter().copied())
.any(|open| relocate::ino_is_under(&st, open, ino));
(entry.node.name.clone(), entry.node.is_folder(), unsaved)
};
// Unlike a move, a copy cannot wait in the queue: it needs the node as
// Drive has it, so it goes now or not at all, local-first or not.
if !self.is_online() {
return Err(CoreError::offline());
}
let owed = unsaved
|| is_local_uid(&uid)
|| is_local_uid(&new_parent_uid)
|| self.db.has_any_op(&uid.to_string()).unwrap_or(true)
|| (is_folder
&& self
.db
.pending_op_counts()
.map(|counts| counts.uploads + counts.changes > 0)
.unwrap_or(true));
if owed {
return Err(CoreError::conflict(format!(
"{name} has changes that have not reached Proton Drive yet; \
copy it once they have synced"
)));
}
let target = if is_folder {
AvailableNameTarget::Folder
} else {
AvailableNameTarget::File
};
let copy_name = self
.rt
.block_on(
self.drive
.get_available_name(&new_parent_uid, &name, target),
)
.map_err(|e| CoreError::from_api(&e, "copy"))?;
let new_uid = self
.rt
.block_on(
self.drive
.copy_node(&uid, &new_parent_uid, Some(&copy_name)),
)
.map_err(|e| CoreError::from_api(&e, "copy"))?;
let node = self
.fetch_minted_node(&new_uid, &copy_name)
.ok_or_else(|| minted_but_unlisted(&copy_name))?;
let mut st = self.state();
let copy_ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&copy_ino)
{
kids.push(copy_ino);
}
drop(st);
self.flush_access_changes();
Ok(copy_name)
}
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
/// number of items below it. Drive adds it up, so nothing is listed here;
/// trashed items count, and the bytes are encrypted ones.
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
let (ino, uid) = self.resolve(rel)?;
let is_folder = self
.state()
.entries
.get(&ino)
.is_some_and(|entry| entry.node.is_folder());
if !is_folder {
return Err(CoreError::invalid(format!(
"{} is not a folder",
rel.display()
)));
}
self.folder_size_for_uid(&uid)
}
/// [`Core::folder_size`] for a folder addressed through any daemon location.
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
let uid = self.resolve_anywhere(uid)?;
self.folder_size_for_uid(&uid)
}
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
if is_local_uid(uid) {
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
}
if !self.is_online() {
return Err(CoreError::offline());
}
self.rt
.block_on(self.client.get_folder_size(uid))
.map_err(|e| CoreError::from_api(&e, "folder size"))
}
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node, /// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
/// evicts any cached content, and invalidates the parent listing. /// evicts any cached content, and invalidates the parent listing.
fn delete(&self, rel: &Path) -> CoreResult<String> { fn delete(&self, rel: &Path) -> CoreResult<String> {
@@ -5319,6 +5558,7 @@ impl Core {
// A trashed node can't be pinned or served from the mount, so its // A trashed node can't be pinned or served from the mount, so its
// content is never current cache: report neither. // content is never current cache: report neither.
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid, uid: item.uid,
path: String::new(), path: String::new(),
@@ -6052,11 +6292,61 @@ fn is_gone(e: &(dyn std::error::Error + 'static)) -> bool {
/// (`sync.rs`, `conflict_path`), so the two halves of the product name the same /// (`sync.rs`, `conflict_path`), so the two halves of the product name the same
/// situation the same way and a user only has to learn it once. /// situation the same way and a user only has to learn it once.
fn conflict_name(name: &str, stamp: i64) -> String { fn conflict_name(name: &str, stamp: i64) -> String {
conflict_name_as(name, conflict_label(), stamp)
}
/// [`conflict_name`] with the machine's label given.
fn conflict_name_as(name: &str, label: &str, stamp: i64) -> String {
let path = Path::new(name); let path = Path::new(name);
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
let tag = conflict_tag(label, stamp, 0);
match path.extension().and_then(|s| s.to_str()) { match path.extension().and_then(|s| s.to_str()) {
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"), Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
None => format!("{stem} (sync-conflict {stamp})"), None => format!("{stem} (sync-conflict {tag})"),
}
}
/// This machine's name as its conflict copies carry it (`docs/BUGS.md` B251).
///
/// Two computers syncing one folder that each keep a copy of the same file in
/// the same second would otherwise make the same name on Drive, and nobody
/// could tell which computer a copy came from. Read once, from the hostname.
fn conflict_label() -> &'static str {
static LABEL: OnceLock<String> = OnceLock::new();
LABEL.get_or_init(|| conflict_label_of(&this_hostname()))
}
/// `host` as one token: anything but ASCII letters, digits, `-`, `_` and `.`
/// becomes `-`, so the label holds no space for `conflict_base_name` to trip
/// on and no `/` or `)` to break the name.
fn conflict_label_of(host: &str) -> String {
let label: String = host
.chars()
.take(32)
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'-'
}
})
.collect();
label.trim_matches('-').to_string()
}
/// The inside of a conflict copy's marker: `<label> <stamp>`, with `-<n>`
/// when an earlier copy already holds that name. Without a label it is the
/// bare stamp that copies had before B251.
fn conflict_tag(label: &str, stamp: i64, suffix: u32) -> String {
let suffix = if suffix == 0 {
String::new()
} else {
format!("-{suffix}")
};
if label.is_empty() {
format!("{stamp}{suffix}")
} else {
format!("{label} {stamp}{suffix}")
} }
} }
@@ -6666,7 +6956,27 @@ fn fuse_name(name: &OsStr) -> Result<String, Errno> {
name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ) name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ)
} }
/// Whether a decrypted remote name can stand as one local path component.
///
/// Drive checks names only when a client creates or renames a node, and only
/// for length, so a name made by another client, or by a server that should
/// not be trusted, can hold anything. Joined onto a local path, a `/` or a `..`
/// in it walks out of the folder it belongs to, and the mirror engine would
/// then write or delete outside the sync root. In the mount, the kernel fails
/// the whole listing with EIO on a single such name (`docs/BUGS.md` B215).
pub(crate) fn remote_name_is_safe(name: &str) -> bool {
!name.is_empty()
&& name != "."
&& name != ".."
&& name.len() <= 255
&& !name.contains(['/', '\0'])
}
fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool { fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool {
if !remote_name_is_safe(&node.name) {
warn!(uid = %node.uid, "a remote name cannot be a local file name; leaving it out");
return false;
}
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid) !node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
} }
@@ -7002,13 +7312,15 @@ mod size_upgrade_tests {
fn a_listing_waits_for_the_whole_size_batch_but_not_forever() { fn a_listing_waits_for_the_whole_size_batch_but_not_forever() {
let slot = Arc::new(SizeUpgrade::default()); let slot = Arc::new(SizeUpgrade::default());
let worker = slot.clone(); let worker = slot.clone();
// Read before the spawn: the worker's sleeps can start before this
// thread runs again, which made the bound below fail on a busy runner.
let started = Instant::now();
let t = std::thread::spawn(move || { let t = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(30)); std::thread::sleep(Duration::from_millis(30));
worker.chunk_done(); worker.chunk_done();
std::thread::sleep(Duration::from_millis(60)); std::thread::sleep(Duration::from_millis(60));
worker.finish(); worker.finish();
}); });
let started = Instant::now();
slot.wait_finished(started + Duration::from_secs(5)); slot.wait_finished(started + Duration::from_secs(5));
// Past the first chunk, up to the end of the batch. // Past the first chunk, up to the end of the batch.
assert!(started.elapsed() >= Duration::from_millis(90)); assert!(started.elapsed() >= Duration::from_millis(90));
@@ -7415,14 +7727,15 @@ mod tests {
use super::{ use super::{
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot, Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan, SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore, accepted_share_provenance, conflict_label, conflict_label_of, conflict_name,
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change, conflict_name_as, copy_pending_for_truncate, expand_restore, first_seen_in_trash,
off_the_runtime, orphan_past_retention, parse_node_uid, prepare_shared_roots, fuse_name, is_stale_mount, node_visible, note_self_change, off_the_runtime,
preserve_on_access_denied, publish_virtual_root_in_listing, orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
reconcile_virtual_root_in_listing, release_can_discard_unlinked, publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue, release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
require_node_parent_access, require_rename_access, resolve_anywhere_with, rename_needs_queue, require_node_parent_access, require_rename_access,
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node, resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
virtual_node,
}; };
use super::{Db, WriteAuthority}; use super::{Db, WriteAuthority};
use std::time::Duration; use std::time::Duration;
@@ -8377,15 +8690,38 @@ mod tests {
#[test] #[test]
fn conflict_name_keeps_the_extension() { fn conflict_name_keeps_the_extension() {
assert_eq!( assert_eq!(
conflict_name("notes.txt", 1700), conflict_name_as("notes.txt", "laptop", 1700),
"notes (sync-conflict 1700).txt" "notes (sync-conflict laptop 1700).txt"
); );
assert_eq!(conflict_name("README", 42), "README (sync-conflict 42)");
assert_eq!( assert_eq!(
conflict_name("archive.tar.gz", 7), conflict_name_as("README", "laptop", 42),
"archive.tar (sync-conflict 7).gz", "README (sync-conflict laptop 42)"
);
assert_eq!(
conflict_name_as("archive.tar.gz", "laptop", 7),
"archive.tar (sync-conflict laptop 7).gz",
"only the last extension is one, as everywhere else" "only the last extension is one, as everywhere else"
); );
assert_eq!(
conflict_name_as("notes.txt", "", 1700),
"notes (sync-conflict 1700).txt"
);
assert_eq!(
conflict_name("notes.txt", 1700),
conflict_name_as("notes.txt", conflict_label(), 1700)
);
}
/// A hostname goes into a file name, so it is cut down to one safe token
/// (B251).
#[test]
fn a_hostname_becomes_one_safe_token() {
assert_eq!(conflict_label_of("laptop"), "laptop");
assert_eq!(conflict_label_of("my-pc.local"), "my-pc.local");
assert_eq!(conflict_label_of("Linux device"), "Linux-device");
assert_eq!(conflict_label_of("a/b) c"), "a-b--c");
assert_eq!(conflict_label_of(" "), "");
assert_eq!(conflict_label_of(&"x".repeat(40)).len(), 32);
} }
#[test] #[test]
@@ -8577,6 +8913,28 @@ mod tests {
)); ));
} }
#[test]
fn a_remote_name_that_is_not_one_path_component_is_left_out() {
let folder_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("folder"));
let none = std::collections::HashSet::new();
for name in [
"a/b",
"../../.bashrc",
"..",
".",
"",
"a\0b",
&"x".repeat(256),
] {
let node = node_helper("child", "folder", name, false);
assert!(!node_visible(&node, &folder_uid, &none), "{name:?}");
}
for name in [".hidden", "a..b", "unicodé-文件", &"x".repeat(255)] {
let node = node_helper("child", "folder", name, false);
assert!(node_visible(&node, &folder_uid, &none), "{name:?}");
}
}
#[test] #[test]
fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() { fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() {
use std::cell::Cell; use std::cell::Cell;
@@ -9082,6 +9440,32 @@ mod tests {
assert!(online.contains("self.relist_offline_listings();")); assert!(online.contains("self.relist_offline_listings();"));
} }
#[test]
fn a_stale_shared_listing_is_fetched_off_the_dispatch_loop() {
// A resident listing that `list_children` would fetch again anyway
// must not be answered inline (B219).
let cached = function_source(include_str!("lib.rs"), "fn children_cached(");
assert!(cached.contains("SHARED_WITH_ME_SYNCED_MS"));
assert!(cached.contains("Self::shared_folder_freshness_key(&uid)"));
assert!(cached.contains("listing_needs_refresh("));
assert!(cached.contains("self.shared_listing_stale(&key)"));
}
#[test]
fn a_handler_does_not_wait_out_the_sdk_retry_budget() {
// Thumbnails, size upgrades and the share list held mount threads for
// minutes on a stalled link (B220).
let lib = include_str!("lib.rs");
let thumbnail = function_source(lib, "fn thumbnail(");
assert_before(thumbnail, "if !self.is_online()", "download_thumbnail");
assert!(thumbnail.contains(".block_on_bounded(self.client.download_thumbnail("));
let upgrade = function_source(lib, "fn run_size_upgrade(");
assert!(upgrade.contains("block_on_within(BATCH_CALL_TIMEOUT"));
let shared = function_source(lib, "fn ensure_shared_children(");
assert!(shared.contains("block_on_within(BATCH_CALL_TIMEOUT"));
assert!(!shared.contains("self.rt"));
}
#[test] #[test]
fn a_remote_read_gives_up_at_its_deadline() { fn a_remote_read_gives_up_at_its_deadline() {
// The SDK bounds requests, not reads; a stalled link held one `read` // The SDK bounds requests, not reads; a stalled link held one `read`
+47 -6
View File
@@ -29,6 +29,13 @@ use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
/// keep waiting for; queueing the change is the better answer. /// keep waiting for; queueing the change is the better answer.
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20); pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
/// How long a mount thread waits on a remote read that answers a batch: a
/// chunk of file sizes, or the list of shares. Longer than an interactive call,
/// since its answer grows with the batch, but well short of the SDK's own retry
/// budget, which held a worker for minutes on a stalled link
/// (`docs/BUGS.md` B220).
pub(crate) const BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60);
/// How long one probe may wait for an answer. A probe is a single small request, /// How long one probe may wait for an answer. A probe is a single small request,
/// and a probe stuck in the SDK's retry budget would delay noticing the link is /// and a probe stuck in the SDK's retry budget would delay noticing the link is
/// back by as long as it waits. /// back by as long as it waits.
@@ -112,10 +119,25 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError {
/// transfer that has stopped moving runs into it. /// transfer that has stopped moving runs into it.
const SLOWEST_UPLOAD: u64 = 32 * 1024; const SLOWEST_UPLOAD: u64 = 32 * 1024;
/// How many uploads can share the upload cap at once: one per drain worker
/// and one per mirror sync task.
const UPLOAD_SLOTS: u64 = (crate::DRAIN_WORKERS + crate::sync::SYNC_CONCURRENCY) as u64;
/// How long an upload of `len` bytes may take before the link is treated as /// How long an upload of `len` bytes may take before the link is treated as
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`]. /// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
pub(crate) fn upload_deadline(len: u64) -> Duration { ///
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD) /// An upload cap of `cap` bytes per second (`0` for none) is shared by every
/// upload running, so each may get as little as its share of it. Below
/// [`SLOWEST_UPLOAD`] a share counts as the slowest rate instead: measured
/// against the fixed rate, a large file under a low cap ran out of time on
/// every attempt, took the mount offline each time, and started over from
/// its first byte.
pub(crate) fn upload_deadline(len: u64, cap: u64) -> Duration {
let slowest = match cap {
0 => SLOWEST_UPLOAD,
cap => SLOWEST_UPLOAD.min((cap / UPLOAD_SLOTS).max(1)),
};
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / slowest)
} }
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`]. /// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
@@ -258,8 +280,9 @@ impl Core {
self.relist_offline_listings(); self.relist_offline_listings();
// Anything written while offline is queued and waiting on exactly this, // Anything written while offline is queued and waiting on exactly this,
// including ops that failed on the way down and are sitting out a // including ops that failed on the way down and are sitting out a
// backoff sized for a server that keeps refusing. // backoff sized for a server that keeps refusing. Ops Drive refused
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) { // keep theirs: the link was not why.
if let Err(e) = self.db.retry_unrefused_ops_now(crate::now_millis()) {
warn!(error = %e, "moving backed-off ops up after a reconnect failed"); warn!(error = %e, "moving backed-off ops up after a reconnect failed");
} }
self.wake_drain(); self.wake_drain();
@@ -403,13 +426,31 @@ mod tests {
#[test] #[test]
fn an_upload_gets_longer_the_bigger_it_is() { fn an_upload_gets_longer_the_bigger_it_is() {
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT); assert_eq!(upload_deadline(0, 0), INTERACTIVE_CALL_TIMEOUT);
assert_eq!( assert_eq!(
upload_deadline(1 << 30), upload_deadline(1 << 30, 0),
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768) INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
); );
} }
#[test]
fn an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs() {
// A generous cap leaves the deadline as it was.
assert_eq!(
upload_deadline(1 << 30, 100 << 20),
upload_deadline(1 << 30, 0)
);
// 512 KiB/s shared by every upload slot is below the slowest rate.
let share = (512 << 10) / UPLOAD_SLOTS;
assert!(share < SLOWEST_UPLOAD);
assert_eq!(
upload_deadline(1 << 30, 512 << 10),
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs((1 << 30) / share)
);
// A cap below one byte per slot still gives a finite deadline.
assert!(upload_deadline(1 << 20, 1) > upload_deadline(1 << 20, 512 << 10));
}
#[test] #[test]
fn a_call_past_its_deadline_reads_as_a_lost_link() { fn a_call_past_its_deadline_reads_as_a_lost_link() {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
+94 -8
View File
@@ -226,11 +226,7 @@ fn scale_thumbnail_with_exiftool(
exiftool: &OsStr, exiftool: &OsStr,
timeout: Duration, timeout: Duration,
) -> ScaleAttempt { ) -> ScaleAttempt {
let direct = image::ImageReader::new(std::io::Cursor::new(bytes)) let image = match decode_upright(bytes) {
.with_guessed_format()
.ok()
.and_then(|reader| reader.decode().ok());
let image = match direct {
Some(image) => image, Some(image) => image,
None if is_raw_image_name(name) => { None if is_raw_image_name(name) => {
let (preview, orientation) = let (preview, orientation) =
@@ -256,6 +252,27 @@ fn scale_thumbnail_with_exiftool(
encode_thumb(&image) encode_thumb(&image)
} }
/// Decode `bytes` and turn the picture the way its EXIF orientation says.
///
/// `ImageReader::decode` does not: a phone stores a portrait photo as landscape
/// pixels plus an orientation tag, so every such thumbnail came out on its
/// side, and the drain uploaded it to Drive that way for every client to show
/// (`docs/BUGS.md` B226).
fn decode_upright(bytes: &[u8]) -> Option<image::DynamicImage> {
use image::ImageDecoder as _;
let mut decoder = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_decoder()
.ok()?;
let orientation = decoder
.orientation()
.unwrap_or(image::metadata::Orientation::NoTransforms);
let mut image = image::DynamicImage::from_decoder(decoder).ok()?;
image.apply_orientation(orientation);
Some(image)
}
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio. /// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt { fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
let (width, height) = (image.width(), image.height()); let (width, height) = (image.width(), image.height());
@@ -417,6 +434,16 @@ fn exiftool_query(
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable) serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
} }
/// Whether [`upload_thumbnails`] can make a thumbnail for a file `name` of
/// `size` bytes, asked before the file is on disk: an image small enough to
/// decode, or a video while ffmpeg is installed.
pub(crate) fn can_make_thumbnail(name: &str, size: u64) -> bool {
if is_video_name(name) {
return ffmpeg_available();
}
is_thumbnail_image_name(name) && size <= UPLOAD_IMAGE_MAX_BYTES
}
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather /// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
/// than a picture the image decoder can scale. /// than a picture the image decoder can scale.
fn is_video_name(name: &str) -> bool { fn is_video_name(name: &str) -> bool {
@@ -2092,9 +2119,10 @@ impl Core {
#[cfg(test)] #[cfg(test)]
mod thumb_tests { mod thumb_tests {
use super::{ use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary, RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
}; };
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name}; use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType; use proton_drive_rs::ThumbnailType;
@@ -2149,6 +2177,64 @@ mod thumb_tests {
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true)); assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
} }
/// `jpeg` with an EXIF APP1 segment that carries only `orientation`.
fn jpeg_with_orientation(width: u32, height: u32, orientation: u16) -> Vec<u8> {
let plain = jpeg(width, height);
let mut tiff = Vec::new();
tiff.extend_from_slice(b"II*\0");
tiff.extend_from_slice(&8u32.to_le_bytes());
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x0112u16.to_le_bytes());
tiff.extend_from_slice(&3u16.to_le_bytes());
tiff.extend_from_slice(&1u32.to_le_bytes());
tiff.extend_from_slice(&orientation.to_le_bytes());
tiff.extend_from_slice(&[0, 0]);
tiff.extend_from_slice(&0u32.to_le_bytes());
let mut app1 = b"Exif\0\0".to_vec();
app1.extend_from_slice(&tiff);
let mut out = plain[..2].to_vec();
out.extend_from_slice(&[0xFF, 0xE1]);
out.extend_from_slice(&u16::try_from(app1.len() + 2).unwrap().to_be_bytes());
out.extend_from_slice(&app1);
out.extend_from_slice(&plain[2..]);
out
}
/// A phone stores a portrait photo as landscape pixels and an EXIF
/// orientation; its thumbnail came out on its side (`docs/BUGS.md` B226).
/// A Takeout photo is copied out of its archive only when a thumbnail can
/// be made from the copy.
#[test]
fn only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out() {
assert!(can_make_thumbnail("IMG_1.jpg", 4 << 20));
assert!(can_make_thumbnail("Screenshot.png", 4 << 20));
assert!(!can_make_thumbnail("IMG_1.jpg", UPLOAD_IMAGE_MAX_BYTES + 1));
assert!(!can_make_thumbnail("notes.txt", 10));
assert_eq!(can_make_thumbnail("clip.mp4", 1 << 30), ffmpeg_available());
}
#[test]
fn a_thumbnail_is_turned_the_way_its_exif_says() {
let ScaleAttempt::Made(thumb) = scale_thumbnail(
&jpeg_with_orientation(2000, 1000, 6),
"portrait.jpg",
&std::env::temp_dir(),
) else {
panic!("a JPEG scales");
};
assert!(thumb.ratio < 1.0, "turned upright: {}", thumb.ratio);
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
let ScaleAttempt::Made(level) = scale_thumbnail(
&jpeg_with_orientation(2000, 1000, 1),
"landscape.jpg",
&std::env::temp_dir(),
) else {
panic!("a JPEG scales");
};
assert!(level.ratio > 1.0);
}
#[test] #[test]
fn undecodable_bytes_are_not_a_thumbnail() { fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the // What a photo in a format this build has no decoder for looks like: the
+117 -47
View File
@@ -15,6 +15,7 @@ use pdfs_core::control::RestorableFolder;
use pdfs_core::mounts::MountMode; use pdfs_core::mounts::MountMode;
use pdfs_core::profile::{ use pdfs_core::profile::{
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin, PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
is_profile_file_name, profile_file_name, saved_folder,
}; };
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -66,9 +67,14 @@ impl Core {
}); });
} }
/// Serialize this machine's arrangement and upload it to `profile.json` /// Serialize this machine's arrangement and upload it to its own
/// inside the device's `.proton-drive-linux` folder, replacing any existing /// `profile-<machine id>.json` inside the device's `.proton-drive-linux`
/// one. /// folder, replacing any existing one.
///
/// Each machine writes its own file because several computers may sync
/// into one device. They used to share `profile.json`, and each save
/// replaced the other computers' folders with its own (B252). That file is
/// left as it is, so a client before 3.5.0 still finds it.
/// ///
/// Does nothing when no device is registered: a machine that has never /// Does nothing when no device is registered: a machine that has never
/// synced a folder has nothing to describe, and registering a device just to /// synced a folder has nothing to describe, and registering a device just to
@@ -85,7 +91,11 @@ impl Core {
CoreError::internal(format!("bad device root uid: {}", device.root_uid)) CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?; })?;
let profile = self.build_profile(&device.uid)?; let machine_id = pdfs_core::config::AppDirs::new()
.and_then(|dirs| dirs.machine_id())
.map_err(|e| CoreError::internal(format!("machine id: {e}")))?;
let file_name = profile_file_name(&machine_id);
let profile = self.build_profile(&device.uid, &machine_id)?;
let bytes = profile.to_bytes().map_err(CoreError::internal)?; let bytes = profile.to_bytes().map_err(CoreError::internal)?;
let len = bytes.len() as i64; let len = bytes.len() as i64;
@@ -97,7 +107,7 @@ impl Core {
// same name: the profile is a single mutable record, and a folder with // same name: the profile is a single mutable record, and a folder with
// `profile.json` and `profile (1).json` in it teaches the restore // `profile.json` and `profile (1).json` in it teaches the restore
// nothing about which to trust. // nothing about which to trust.
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?; let existing = self.find_device_child_file(&dir_uid, &file_name)?;
match existing { match existing {
Some(uid) => self Some(uid) => self
.rt .rt
@@ -114,7 +124,7 @@ impl Core {
self.rt self.rt
.block_on(self.drive.upload_file_replacing_draft_from( .block_on(self.drive.upload_file_replacing_draft_from(
&dir_uid, &dir_uid,
PROFILE_FILE_NAME, &file_name,
"application/json", "application/json",
Cursor::new(bytes), Cursor::new(bytes),
len, len,
@@ -130,7 +140,7 @@ impl Core {
} }
/// Snapshot the local arrangement into a [`Profile`]. /// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> { fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
let folders = self let folders = self
.db .db
.sync_folder_list() .sync_folder_list()
@@ -162,6 +172,7 @@ impl Core {
version: PROFILE_VERSION, version: PROFILE_VERSION,
device_uid: device_uid.to_string(), device_uid: device_uid.to_string(),
hostname: this_hostname(), hostname: this_hostname(),
machine_id: Some(machine_id.to_string()),
saved_at: now_secs(), saved_at: now_secs(),
folders, folders,
pins, pins,
@@ -197,6 +208,31 @@ impl Core {
.map(|n| n.uid)) .map(|n| n.uid))
} }
/// The untrashed files directly under `parent_uid` whose name passes
/// `keep`.
fn device_child_files(
&self,
parent_uid: &NodeUid,
keep: impl Fn(&str) -> bool,
) -> CoreResult<Vec<NodeUid>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
Ok(nodes
.into_iter()
.filter(|n| !n.is_folder() && !n.trashed && keep(&n.name))
.map(|n| n.uid)
.collect())
}
/// The device's `.proton-drive-linux` folder, creating it if this is the /// The device's `.proton-drive-linux` folder, creating it if this is the
/// first save on this device. /// first save on this device.
/// ///
@@ -218,32 +254,43 @@ impl Core {
Ok(uid) Ok(uid)
} }
/// Download and parse this device's profile, if there is one. /// Download and parse the profiles of every computer that syncs this
/// device.
/// ///
/// Looks in `.proton-drive-linux/` first and falls back to the device root, /// Reads every profile in `.proton-drive-linux/`: one per machine, and the
/// where clients before the B78 fix wrote it. The fallback is read-only: the /// shared `profile.json` of clients before 3.5.0. Falls back to the device
/// next save writes to the folder, and the legacy document is left alone /// root, where clients before the B78 fix wrote it. The fallback is
/// rather than trashed, so an older client on another machine keeps working. /// read-only: the next save writes to the folder, and the legacy document
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> { /// is left alone rather than trashed, so an older client on another
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? { /// machine keeps working. A profile that cannot be read is left out and
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?, /// logged; the others still count.
None => None, fn load_profiles(&self, root_uid: &NodeUid) -> CoreResult<Vec<Profile>> {
let mut uids = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
Some(dir_uid) => self.device_child_files(&dir_uid, is_profile_file_name)?,
None => Vec::new(),
}; };
let uid = match current { if uids.is_empty() {
Some(uid) => uid, uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?;
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? { if !uids.is_empty() {
Some(uid) => { info!("reading legacy profile from the device root");
info!("reading legacy profile from the device root"); }
uid }
let mut profiles = Vec::new();
for uid in uids {
let mut buf: Vec<u8> = Vec::new();
let parsed = self
.rt
.block_on(self.drive.download_file_to(&uid, &mut buf))
.map_err(|e| CoreError::from_api(&e, "download profile"))
.and_then(|()| Profile::parse(&buf).map_err(CoreError::invalid));
match parsed {
Ok(profile) => profiles.push(profile),
Err(e) => {
warn!(%uid, error = %e.message, "could not read a profile; leaving it out")
} }
None => return Ok(None), }
}, }
}; Ok(profiles)
let mut buf: Vec<u8> = Vec::new();
self.rt
.block_on(self.drive.download_file_to(&uid, &mut buf))
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
} }
// ---- restore ---------------------------------------- // ---- restore ----------------------------------------
@@ -265,15 +312,16 @@ impl Core {
CoreError::internal(format!("bad device root uid: {}", device.root_uid)) CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?; })?;
let profile = match self.load_profile(&root_uid) { let profiles = match self.load_profiles(&root_uid) {
Ok(p) => p, Ok(p) => p,
// A missing or unreadable profile is not fatal: the remote tree // A missing or unreadable profile is not fatal: the remote tree
// alone is enough to offer a restore, just with plainer defaults. // alone is enough to offer a restore, just with plainer defaults.
Err(e) => { Err(e) => {
warn!(error = %e.message, "could not read profile; proposing defaults"); warn!(error = %e.message, "could not read profiles; proposing defaults");
None Vec::new()
} }
}; };
let hostname = this_hostname();
let uids = self let uids = self
.rt .rt
@@ -304,11 +352,12 @@ impl Core {
// offering it as a restorable folder would sync the bookkeeping // offering it as a restorable folder would sync the bookkeeping
// back onto the machine it describes. // back onto the machine it describes.
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME) .filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
// The name becomes a folder under the home folder: one with a `/`
// or a `..` in it would put the restore somewhere else.
.filter(|n| crate::remote_name_is_safe(&n.name))
.map(|n| { .map(|n| {
let uid = n.uid.to_string(); let uid = n.uid.to_string();
let saved = profile let saved = saved_folder(&profiles, &uid, &hostname);
.as_ref()
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
let proposed = saved let proposed = saved
.map(|f| PathBuf::from(&f.local_path)) .map(|f| PathBuf::from(&f.local_path))
.filter(|p| path_is_usable_here(p)) .filter(|p| path_is_usable_here(p))
@@ -349,23 +398,29 @@ impl Core {
.sync_folder_list() .sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?; .map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let reserved = crate::devices::sync_root_reserved();
let mut roots: Vec<PathBuf> = existing
.iter()
.map(|f| PathBuf::from(&f.local_path))
.collect();
let mut restored = 0usize; let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new(); let mut skipped: Vec<String> = Vec::new();
for item in items { for item in items {
let local = PathBuf::from(&item.local_path); // The path is proposed from the profile another machine wrote, and
if !local.is_absolute() { // may hold a folder name from Drive: checked like any new root.
skipped.push(format!("{}: local path must be absolute", item.local_path)); let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
continue; Ok(local) => local,
} Err(e) => {
skipped.push(format!("{}: {e}", item.local_path));
continue;
}
};
if existing.iter().any(|f| f.remote_uid == item.remote_uid) { if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
skipped.push(format!("{}: already synced", item.local_path)); skipped.push(format!("{}: already synced", item.local_path));
continue; continue;
} }
if existing if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) {
.iter() skipped.push(format!("{}: {problem}", item.local_path));
.any(|f| Path::new(&f.local_path) == local.as_path())
{
skipped.push(format!("{}: path already in use", item.local_path));
continue; continue;
} }
if let Err(e) = std::fs::create_dir_all(&local) { if let Err(e) = std::fs::create_dir_all(&local) {
@@ -390,10 +445,15 @@ impl Core {
let _ = self.request_sync_folder_mode(id, "ondemand"); let _ = self.request_sync_folder_mode(id, "ondemand");
} }
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
roots.push(local);
restored += 1; restored += 1;
} }
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
// The reply is gone once the app has shown it; the log keeps why.
for reason in &skipped {
info!(reason, "restore skipped a folder");
}
let mut message = format!("restored {restored} folder(s)"); let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() { if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", "))); message.push_str(&format!("; skipped: {}", skipped.join(", ")));
@@ -482,7 +542,7 @@ mod tests {
#[test] #[test]
fn loading_falls_back_to_a_legacy_device_root_profile() { fn loading_falls_back_to_a_legacy_device_root_profile() {
let source = include_str!("profile.rs"); let source = include_str!("profile.rs");
let load = function_source(source, "fn load_profile("); let load = function_source(source, "fn load_profiles(");
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder"); assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
assert!( assert!(
load.find("PROFILE_DIR_NAME").unwrap() load.find("PROFILE_DIR_NAME").unwrap()
@@ -491,6 +551,16 @@ mod tests {
); );
} }
/// Several computers may share a device, so each saves to a file named
/// after its own machine id and never to the shared `profile.json`
/// (B252).
#[test]
fn each_machine_saves_its_own_profile() {
let save = function_source(include_str!("profile.rs"), "fn save_profile(");
assert!(save.contains("profile_file_name(&machine_id)"));
assert!(!save.contains("PROFILE_FILE_NAME"));
}
/// The profile folder is the client's own bookkeeping: neither the restore /// The profile folder is the client's own bookkeeping: neither the restore
/// picker nor `add_sync_folder` may treat it as a user folder. /// picker nor `add_sync_folder` may treat it as a user folder.
#[test] #[test]
+206 -28
View File
@@ -1070,37 +1070,121 @@ impl Core {
offset: u64, offset: u64,
size: u64, size: u64,
) -> Result<Vec<u8>, Errno> { ) -> Result<Vec<u8>, Errno> {
if offset >= len || size == 0 { let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
return Ok(Vec::new()); self.read_range(uid, base_mtime, base_size, s, n, true)
.map(Some)
})?;
Ok(read.unwrap_or_default())
}
/// [`Core::serve_open_read`] from bytes already on this machine only:
/// `Ok(None)` the moment a range of the base would have to be downloaded.
///
/// The dispatch loop answers a read on a write handle with this, and hands
/// it to a worker only when it says `None`. Fetching the base there held
/// every request of the mount for as long as the download took, up to the
/// read deadline (`docs/BUGS.md` B227).
#[allow(clippy::too_many_arguments)]
pub(super) fn serve_open_read_local(
&self,
file: &Arc<File>,
len: u64,
uid: &NodeUid,
base_mtime: i64,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
) -> Result<Option<Vec<u8>>, Errno> {
assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
Ok(self.cached_range(uid, base_mtime, base_size, s, n))
})
}
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
/// them, but only from this machine: an open write handle, a queued write,
/// or the caches. `Ok(None)` the moment any of it would have to be
/// downloaded, and for a size still provisional (B100), which only the
/// network settles. Short or empty past the end of the file.
///
/// `copy_file_range` answers with this and leaves anything else to the
/// kernel's read/write copy.
pub(super) fn read_local(
&self,
ino: u64,
offset: u64,
size: u64,
) -> Result<Option<Vec<u8>>, Errno> {
let handle = self.state().active_writes.get(&ino).map(|h| {
(
h.file.clone(),
h.len,
h.uid.clone(),
h.base_mtime,
h.base_size,
h.written.clone(),
)
});
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
return self.serve_open_read_local(
&file, len, &uid, base_mtime, base_size, &written, offset, size,
);
} }
let end = offset.saturating_add(size).min(len); let (uid, mtime, fsize, provisional) = {
let mut out = Vec::with_capacity((end - offset) as usize); let st = self.state();
for (s, e, authored) in written.segments(offset, end) { match st.entries.get(&ino) {
if authored { Some(e) if e.node.is_file() => (
let mut buf = vec![0u8; (e - s) as usize]; e.uid.clone(),
file.read_exact_at(&mut buf, s).map_err(|err| { e.node.modification_time,
warn!(%uid, error = %err, "scratch read failed"); node_size(&e.node),
Errno::EIO matches!(
})?; &e.node.kind,
out.extend_from_slice(&buf); NodeKind::File {
} else { claimed_size: None,
let bend = e.min(base_size); ..
if s < bend { }
out.extend_from_slice(&self.read_range( ),
uid, ),
base_mtime, Some(_) => return Err(Errno::EISDIR),
base_size, None => return Err(Errno::ENOENT),
s,
bend - s,
true,
)?);
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
} }
};
let uid = match is_local_uid(&uid) {
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
false => uid,
};
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
let m = &pending.meta;
let file = File::open(&pending.path).map_err(|e| {
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
Errno::EIO
})?;
let mut written = Intervals::default();
for &(s, e) in &m.authored {
written.add(s, e);
}
return self.serve_open_read_local(
&Arc::new(file),
m.len,
&uid,
m.base_mtime,
m.base_size,
&written,
offset,
size,
);
} }
Ok(out) // Created here and never written: an empty file.
if is_local_uid(&uid) {
return Ok(Some(Vec::new()));
}
if provisional {
return Ok(None);
}
if offset >= fsize || size == 0 {
return Ok(Some(Vec::new()));
}
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
} }
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local /// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
@@ -1242,6 +1326,49 @@ impl Core {
} }
} }
/// The bytes `[offset, offset + size)` of a file open for writing: authored
/// ranges from its scratch `file`, the rest from its base through `base`,
/// and zeros past the base. `Ok(None)` when `base` says it cannot answer.
#[allow(clippy::too_many_arguments)]
fn assemble_open_read(
file: &File,
len: u64,
uid: &NodeUid,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
mut base: impl FnMut(u64, u64) -> Result<Option<Vec<u8>>, Errno>,
) -> Result<Option<Vec<u8>>, Errno> {
if offset >= len || size == 0 {
return Ok(Some(Vec::new()));
}
let end = offset.saturating_add(size).min(len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "scratch read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
} else {
let bend = e.min(base_size);
if s < bend {
match base(s, bend - s)? {
Some(bytes) => out.extend_from_slice(&bytes),
None => return Ok(None),
}
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
}
}
Ok(Some(out))
}
#[cfg(test)] #[cfg(test)]
mod block_ring_tests { mod block_ring_tests {
use super::*; use super::*;
@@ -1394,3 +1521,54 @@ mod prefetch_tests {
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1); assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
} }
} }
#[cfg(test)]
mod open_read_tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn scratch(bytes: &[u8]) -> (File, PathBuf) {
let path = std::env::temp_dir().join(format!(
"pdfs-open-read-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let mut file = File::options()
.create_new(true)
.read(true)
.write(true)
.open(&path)
.unwrap();
file.write_all(bytes).unwrap();
(file, path)
}
/// The dispatch loop answers from what is on this machine and says so
/// when the base would have to be downloaded, for a worker to do it
/// (`docs/BUGS.md` B227).
#[test]
fn a_read_needing_the_base_says_so_instead_of_fetching() {
let uid = NodeUid::new(VolumeId::from("v"), LinkId::from("f"));
let (file, path) = scratch(b"ABCDEFGH");
let mut written = Intervals::default();
written.add(0, 4);
let authored = assemble_open_read(&file, 8, &uid, 8, &written, 0, 4, |_, _| {
panic!("an authored range never asks the base")
});
assert_eq!(authored.unwrap().as_deref(), Some(&b"ABCD"[..]));
let missing = assemble_open_read(&file, 8, &uid, 8, &written, 2, 6, |_, _| Ok(None));
assert_eq!(missing.unwrap(), None);
let base = assemble_open_read(&file, 10, &uid, 8, &written, 2, 8, |s, n| {
assert_eq!((s, n), (4, 4));
Ok(Some(b"efgh".to_vec()))
});
assert_eq!(base.unwrap().as_deref(), Some(&b"CDefgh\0\0"[..]));
let _ = std::fs::remove_file(path);
}
}
+10 -2
View File
@@ -190,6 +190,7 @@ impl Core {
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))? .map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
.len(); .len();
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream"); let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
let thumbnails = self.upload_thumbnails(&path, &candidate.name);
let guard = let guard =
self.transfers self.transfers
@@ -206,7 +207,7 @@ impl Core {
media_type, media_type,
reader, reader,
size as i64, size as i64,
Vec::new(), thumbnails,
metadata, metadata,
false, false,
)); ));
@@ -291,7 +292,14 @@ impl Core {
let mut state = REDATE.lock(); let mut state = REDATE.lock();
state.running = false; state.running = false;
state.cancel = None; state.cancel = None;
state.last = outcome.as_ref().ok().cloned(); state.last = Some(match &outcome {
Ok(summary) => summary.clone(),
Err(e) => RedateSummary {
error: Some(e.to_string()),
dry_run,
..Default::default()
},
});
match &outcome { match &outcome {
Ok(summary) => info!( Ok(summary) => info!(
candidates = summary.candidates, candidates = summary.candidates,
+36 -9
View File
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn}; use tracing::{info, warn};
use super::state::State;
use super::sync::mirror_subtree_unsynced; use super::sync::mirror_subtree_unsynced;
use super::{Core, is_local_uid, parse_uid}; use super::{Core, is_local_uid, parse_uid};
@@ -130,6 +131,24 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
} }
} }
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
/// parent cycle in corrupt data cannot hang the caller.
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
for _ in 0..MAX_TREE_DEPTH {
if ino == root {
return true;
}
match st.entries.get(&ino) {
Some(entry) if entry.parent != ino => ino = entry.parent,
_ => return false,
}
}
false
}
/// How deep [`ino_is_under`] walks before it gives up.
const MAX_TREE_DEPTH: usize = 1024;
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows. /// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool { fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
let root = root.to_string(); let root = root.to_string();
@@ -336,7 +355,7 @@ impl Core {
core.require_uid_writable(authority) core.require_uid_writable(authority)
.map_err(|e| core.errno_error(e, "move access"))?; .map_err(|e| core.errno_error(e, "move access"))?;
} }
if self.has_pending_work_under(&uid)? { if self.has_pending_work_under(ino, &uid)? {
return Err(CoreError::conflict( return Err(CoreError::conflict(
"it has changes that are not uploaded yet; wait for the upload to finish", "it has changes that are not uploaded yet; wait for the upload to finish",
)); ));
@@ -410,10 +429,11 @@ impl Core {
Ok(uid) Ok(uid)
} }
/// Whether the queue owes Drive anything for `uid` or a node under it, or a /// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
/// file under it is open for writing. Moving such a node away would strand /// under it, or a file under it is open for writing. Moving such a node away
/// the pending change against a path that no longer leads to it. /// would strand the pending change against a path that no longer leads to
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> { /// it.
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
let ops = self let ops = self
.db .db
.pending_ops() .pending_ops()
@@ -427,12 +447,19 @@ impl Core {
}) { }) {
return Ok(true); return Ok(true);
} }
// Collected first so no State lock is held across the nodes-table reads. // Open handles are placed in the tree by inode, not by uid in the nodes
let mut writing = Vec::new(); // table. A file opened just after its create landed holds the
// placeholder uid until the tree catches up, and the nodes table no
// longer has that uid, so the move went ahead under an open write.
let mut writing = false;
self.for_each_state(|st| { self.for_each_state(|st| {
writing.extend(st.active_writes.values().map(|w| w.uid.to_string())); writing = st
.active_writes
.keys()
.chain(&st.releasing)
.any(|&open| ino_is_under(st, open, ino));
}); });
Ok(writing.iter().any(|w| under(self, uid, w))) Ok(writing)
} }
/// Bring the source location level with a move Drive has made. /// Bring the source location level with a move Drive has made.
+86 -1
View File
@@ -9,7 +9,8 @@
use std::path::Path; use std::path::Path;
use pdfs_core::control::{ use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem, BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
SharedItem,
}; };
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use tracing::debug; use tracing::debug;
@@ -66,6 +67,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
size, size,
modified: n.modification_time, modified: n.modification_time,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(), path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
uid: n.uid.to_string(), uid: n.uid.to_string(),
@@ -366,6 +368,89 @@ impl Core {
Ok(public_link_info(link)) Ok(public_link_info(link))
} }
/// Change the public link `id` on the node at `rel`, keeping its URL.
pub(crate) fn update_public_link(
&self,
rel: &Path,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// [`Core::update_public_link`] for a node addressed through any daemon
/// location.
pub(crate) fn update_public_link_by_uid(
&self,
uid: &str,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let uid = self.resolve_anywhere(uid)?;
self.update_public_link_for_uid(&uid, id, role, password, expires)
}
/// The SDK sets all three options on every update, so each `Keep` is
/// filled in from the link as it stands.
fn update_public_link_for_uid(
&self,
uid: &NodeUid,
id: &str,
role: Option<&str>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> CoreResult<PublicLinkInfo> {
let link = self
.rt
.block_on(self.client.get_public_link(uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?;
let role = match role {
Some(role) => role_from_str(role)?,
None => link.role,
};
let unchanged = role == link.role
&& matches!(password, LinkChange::Keep)
&& matches!(expires, LinkChange::Keep);
if unchanged {
return Ok(public_link_info(link));
}
let password = match password {
LinkChange::Keep if link.has_custom_password => {
// Sending `None` here would quietly remove the password.
Some(link.custom_password.clone().ok_or_else(|| {
CoreError::conflict(
"the link's password could not be read; set a new one or remove it",
)
})?)
}
LinkChange::Keep | LinkChange::Remove => None,
LinkChange::Set(password) if password.is_empty() => {
return Err(CoreError::invalid("the new password is empty"));
}
LinkChange::Set(password) => Some(password),
};
let expires = match expires {
LinkChange::Keep => link.expiration_time,
LinkChange::Remove => None,
LinkChange::Set(at) => Some(at),
};
let link = self
.rt
.block_on(
self.client
.update_public_link(uid, role, password.as_deref(), expires),
)
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`. /// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> { pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?; let (_ino, uid) = self.resolve(rel)?;
File diff suppressed because it is too large. Load diff
+184 -3
View File
@@ -31,6 +31,9 @@
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with //! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
//! `InsufficientQuota`. //! `InsufficientQuota`.
//! //!
//! It also holds the account's computers, each with a root folder outside My
//! files, so a simulated daemon can register its own and sync a local folder.
//!
//! A test can also hold the next read of one node on its way to Drive //! A test can also hold the next read of one node on its way to Drive
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits //! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]), //! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
use proton_drive_rs::proton_sdk::account::Quota; use proton_drive_rs::proton_sdk::account::Quota;
use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result}; use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; use proton_drive_rs::proton_sdk::ids::{
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
};
use proton_drive_rs::{ use proton_drive_rs::{
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail, AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
}; };
use sha1::{Digest as _, Sha1}; use sha1::{Digest as _, Sha1};
@@ -111,6 +117,9 @@ struct Server {
block_pattern: Vec<u64>, block_pattern: Vec<u64>,
/// The account's storage limit in bytes. /// The account's storage limit in bytes.
max_space: i64, max_space: i64,
/// The account's computers, oldest first, as `(name, type, root folder)`.
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
devices: Vec<(String, DeviceType, NodeUid)>,
} }
impl Server { impl Server {
@@ -162,6 +171,53 @@ impl Server {
false false
} }
/// The `n`th computer of the account, as Drive lists it.
fn computer(&self, n: usize) -> Computer {
let (name, device_type, root) = &self.devices[n];
Computer {
uid: DeviceUid::new(format!("device{}", n + 1)),
device_type: *device_type,
name: Ok(name.clone()),
root_folder_uid: root.clone(),
creation_time: self.current(root).map_or(0, |remote| remote.created),
last_sync_time: None,
share_id: ShareId::new(format!("device-share{}", n + 1)),
}
}
/// Copy `uid` and, for a folder, everything live below it under `parent` as
/// `name`, the way Drive's copy endpoint does. Returns the copy's uid.
fn copy_subtree(&mut self, uid: &NodeUid, parent: &NodeUid, name: String) -> NodeUid {
let source = self
.current(uid)
.cloned()
.expect("the caller checked the source");
let copy = self.mint_uid();
let now = now_secs();
self.put(
&copy,
Some(Remote {
parent: Some(parent.clone()),
name,
created: now,
..source
}),
);
let children: Vec<(NodeUid, String)> = self
.nodes
.keys()
.filter_map(|child| {
let remote = self.current(child)?;
(remote.parent.as_ref() == Some(uid) && !remote.trashed)
.then(|| (child.clone(), remote.name.clone()))
})
.collect();
for (child, child_name) in children {
self.copy_subtree(&child, &copy, child_name);
}
copy
}
fn mint_uid(&mut self) -> NodeUid { fn mint_uid(&mut self) -> NodeUid {
self.next_link += 1; self.next_link += 1;
uid(&format!("L{}", self.next_link)) uid(&format!("L{}", self.next_link))
@@ -305,6 +361,7 @@ impl FakeDrive {
events: Vec::new(), events: Vec::new(),
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE], block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
max_space: 1 << 40, max_space: 1 << 40,
devices: Vec::new(),
}; };
let now = now_secs(); let now = now_secs();
server.nodes.insert( server.nodes.insert(
@@ -338,6 +395,8 @@ impl FakeDrive {
hold: Mutex::new(None), hold: Mutex::new(None),
hold_answer: Mutex::new(None), hold_answer: Mutex::new(None),
hold_trash: Mutex::new(None), hold_trash: Mutex::new(None),
hold_download: Mutex::new(None),
hold_any_landing: AtomicBool::new(false),
hold_landing: Mutex::new(None), hold_landing: Mutex::new(None),
drop_after_create: AtomicBool::new(false), drop_after_create: AtomicBool::new(false),
lose_create_reply: AtomicBool::new(false), lose_create_reply: AtomicBool::new(false),
@@ -616,8 +675,13 @@ struct ClientInner {
hold_answer: Mutex<Option<Arc<HoldState>>>, hold_answer: Mutex<Option<Arc<HoldState>>>,
/// The trash [`FakeClient::hold_next_trash`] is waiting for. /// The trash [`FakeClient::hold_next_trash`] is waiting for.
hold_trash: Mutex<Option<Arc<HoldState>>>, hold_trash: Mutex<Option<Arc<HoldState>>>,
/// The download [`FakeClient::hold_next_download`] is waiting for.
hold_download: Mutex<Option<Arc<HoldState>>>,
/// The landing [`FakeClient::hold_next_landing`] is waiting for. /// The landing [`FakeClient::hold_next_landing`] is waiting for.
hold_landing: Mutex<Option<Arc<HoldState>>>, hold_landing: Mutex<Option<Arc<HoldState>>>,
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
/// lands next.
hold_any_landing: AtomicBool,
/// Set by [`FakeClient::drop_link_after_next_create`]. /// Set by [`FakeClient::drop_link_after_next_create`].
drop_after_create: AtomicBool, drop_after_create: AtomicBool,
/// Set by [`FakeClient::lose_reply_to_next_create`]. /// Set by [`FakeClient::lose_reply_to_next_create`].
@@ -755,6 +819,18 @@ impl FakeClient {
Held(state) Held(state)
} }
/// Hold the next download of `uid`'s content once Drive has answered it,
/// before the bytes reach the daemon, until the answer is dropped.
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
let state = Arc::new(HoldState {
uid: uid.clone(),
reached: AtomicBool::new(false),
released: AtomicBool::new(false),
});
*self.0.hold_download.lock() = Some(state.clone());
Held(state)
}
/// Hold the drain once the next upload to `uid` has landed and its blob /// Hold the drain once the next upload to `uid` has landed and its blob
/// is let go of, before the tree has the node (`DriveApi::landing`), until /// is let go of, before the tree has the node (`DriveApi::landing`), until
/// the answer is dropped. /// the answer is dropped.
@@ -768,6 +844,14 @@ impl FakeClient {
Held(state) Held(state)
} }
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
/// as a new file, whose uid Drive has not minted yet.
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
let held = self.hold_next_landing(&uid("*"));
self.0.hold_any_landing.store(true, Ordering::SeqCst);
held
}
/// Make the next file create on Drive, then hold its answer until the /// Make the next file create on Drive, then hold its answer until the
/// answer is dropped, as a slow link does. /// answer is dropped, as a slow link does.
pub(crate) fn hold_reply_to_next_create(&self) -> Held { pub(crate) fn hold_reply_to_next_create(&self) -> Held {
@@ -1396,6 +1480,8 @@ impl DriveApi for FakeClient {
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found")) .ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
}) })
.await?; .await?;
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
.await;
output.write_all(&content).map_err(io_error) output.write_all(&content).map_err(io_error)
} }
@@ -1437,6 +1523,61 @@ impl DriveApi for FakeClient {
.await .await
} }
async fn copy_node(
&self,
uid: &NodeUid,
new_parent: &NodeUid,
name: Option<&str>,
) -> Result<NodeUid> {
self.request(true, |server| {
if !server.is_folder(new_parent) {
return Err(api(ResponseCode::DoesNotExist, "parent folder not found"));
}
let source = server
.current(uid)
.filter(|remote| !remote.trashed)
.cloned()
.ok_or_else(|| api(ResponseCode::DoesNotExist, "node not found"))?;
if server.is_below(new_parent, uid) {
return Err(api(
ResponseCode::InvalidRequirements,
"cannot copy a folder into itself",
));
}
let name = name.map_or(source.name.clone(), str::to_owned);
if server.live_child_named(new_parent, &name).is_some() {
return Err(api(
ResponseCode::AlreadyExists,
"a file or folder with that name already exists",
));
}
Ok(server.copy_subtree(uid, new_parent, name))
})
.await
}
async fn get_available_name(
&self,
parent_uid: &NodeUid,
name: &str,
target: AvailableNameTarget,
) -> Result<String> {
self.request(false, |server| {
let (stem, extension) = match (target, name.rsplit_once('.')) {
(AvailableNameTarget::File, Some((stem, ext))) if !stem.is_empty() => {
(stem, format!(".{ext}"))
}
_ => (name, String::new()),
};
let free = std::iter::once(name.to_owned())
.chain((1..).map(|n| format!("{stem} ({n}){extension}")))
.find(|candidate| server.live_child_named(parent_uid, candidate).is_none())
.expect("an unbounded sequence has a free name");
Ok(free)
})
.await
}
fn move_nodes_streaming<'a>( fn move_nodes_streaming<'a>(
&'a self, &'a self,
items: Vec<NodeMoveItem>, items: Vec<NodeMoveItem>,
@@ -1484,8 +1625,12 @@ impl DriveApi for FakeClient {
fn landing(&self, uid: &NodeUid) { fn landing(&self, uid: &NodeUid) {
let state = { let state = {
let mut hold = self.0.hold_landing.lock(); let mut hold = self.0.hold_landing.lock();
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
match hold.as_ref() { match hold.as_ref() {
Some(state) if state.uid == *uid => hold.take(), Some(state) if any || state.uid == *uid => {
self.0.hold_any_landing.store(false, Ordering::SeqCst);
hold.take()
}
_ => None, _ => None,
} }
}; };
@@ -1506,6 +1651,42 @@ impl DriveApi for FakeClient {
.await .await
} }
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
self.request(false, |server| {
Ok((0..server.devices.len())
.map(|n| server.computer(n))
.collect())
})
.await
}
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
self.request(true, |server| {
// A device root has no parent: like on Drive, it is not in My files.
// It is listed at once, as the My files root is: the listing lag is
// about children appearing, and the daemon lists the new root next.
let root = server.mint_uid();
let now = now_secs();
server.nodes.insert(
root.clone(),
vec![(
Instant::now() - HISTORY,
Some(Remote {
parent: None,
name: name.to_owned(),
trashed: false,
created: now,
modified: now,
file: None,
}),
)],
);
server.devices.push((name.to_owned(), device_type, root));
Ok(server.computer(server.devices.len() - 1))
})
.await
}
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> { async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
self.request(false, |server| { self.request(false, |server| {
Ok(server Ok(server
+97 -7
View File
@@ -212,6 +212,31 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
lid.unsigned_abs() + crate::ROOT_INO lid.unsigned_abs() + crate::ROOT_INO
} }
/// The inodes of the stored nodes among `nodes`, from their local ids, for
/// [`State::intern_batch_with_inos`]. Called before the state lock is taken;
/// a node that already has a row keeps its id. Empty when the database fails,
/// which leaves the allocation to the lock holder, as before.
pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap<NodeUid, u64> {
let stored: Vec<&Node> = nodes
.iter()
.filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid))
.collect();
if stored.is_empty() {
return HashMap::new();
}
match db.lids_for(&stored) {
Ok(lids) => stored
.iter()
.zip(lids)
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
.collect(),
Err(error) => {
warn!(%error, "no local ids before the listing; allocating them under the lock");
HashMap::new()
}
}
}
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]: /// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
/// `None` for the root and a fallback inode. /// `None` for the root and a fallback inode.
pub(crate) fn ino_lid(ino: u64) -> Option<i64> { pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
@@ -241,6 +266,11 @@ pub(crate) struct State {
pub(crate) next_ino: u64, pub(crate) next_ino: u64,
/// Shared write state keyed by inode. Concurrent writers share the scratch file. /// Shared write state keyed by inode. Concurrent writers share the scratch file.
pub(crate) active_writes: HashMap<u64, WriteHandle>, pub(crate) active_writes: HashMap<u64, WriteHandle>,
/// Inodes whose last write handle `release` has taken out of
/// [`State::active_writes`] and is still queueing. `close(2)` returns
/// before that, and in between the edit is in neither the handles nor the
/// queue, so a check for changes not on Drive yet looks here too.
pub(crate) releasing: HashSet<u64>,
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and /// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
/// have no entry here. /// have no entry here.
pub(crate) handles: HashMap<u64, u64>, pub(crate) handles: HashMap<u64, u64>,
@@ -403,6 +433,7 @@ impl State {
roots: HashSet::new(), roots: HashSet::new(),
next_ino: FALLBACK_INO, next_ino: FALLBACK_INO,
active_writes: HashMap::new(), active_writes: HashMap::new(),
releasing: HashSet::new(),
handles: HashMap::new(), handles: HashMap::new(),
next_fh: 1, next_fh: 1,
dir_snapshots: HashMap::new(), dir_snapshots: HashMap::new(),
@@ -1074,20 +1105,34 @@ impl State {
/// ///
/// This is what keeps `ls` on a large folder quick: one commit for the /// This is what keeps `ls` on a large folder quick: one commit for the
/// listing, rather than one autocommit — and one fsync — per child. /// listing, rather than one autocommit — and one fsync — per child.
pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec<Node>) -> Vec<u64> { #[cfg(test)]
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
self.intern_batch_with_inos(parent, nodes, HashMap::new())
}
/// [`State::intern_batch`], with inodes for new entries that the caller
/// allocated before it took the lock, from [`inos_for`]. Allocating them
/// is a write transaction, and under the lock every inline handler of
/// every mount waited for it (`docs/BUGS.md` B221). A node missing from
/// `inos` still gets one here.
pub(crate) fn intern_batch_with_inos(
&mut self,
parent: u64,
mut nodes: Vec<Node>,
mut inos: HashMap<NodeUid, u64>,
) -> Vec<u64> {
for node in &mut nodes { for node in &mut nodes {
self.keep_revision_mtime(node); self.keep_revision_mtime(node);
} }
self.outbox.push(DbWrite::Upsert(nodes.clone())); self.outbox.push(DbWrite::Upsert(nodes.clone()));
let fresh: Vec<&Node> = nodes let fresh: Vec<&Node> = nodes
.iter() .iter()
.filter(|node| !self.by_uid.contains_key(&node.uid)) .filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid))
.collect(); .collect();
let mut inos = if fresh.is_empty() { if !fresh.is_empty() {
HashMap::new() let allocated = self.new_inos(&fresh);
} else { inos.extend(allocated);
self.new_inos(&fresh) }
};
nodes nodes
.into_iter() .into_iter()
.map(|node| { .map(|node| {
@@ -1098,6 +1143,13 @@ impl State {
.collect() .collect()
} }
/// Record `uid`'s listing as complete once the lock is released, after the
/// rows of its children: written the other way round, a crash between the
/// two left a folder marked complete with no children to show.
pub(crate) fn record_listed(&mut self, uid: NodeUid) {
self.outbox.push(DbWrite::SetListed(uid, true));
}
/// Check if a directory inode has any child nodes in memory or in the database. /// Check if a directory inode has any child nodes in memory or in the database.
pub(crate) fn has_children(&self, parent: u64) -> bool { pub(crate) fn has_children(&self, parent: u64) -> bool {
// A resident listing is authoritative even when empty. Falling through // A resident listing is authoritative even when empty. Falling through
@@ -1467,6 +1519,44 @@ mod tests {
); );
} }
/// A listing's inodes are allocated before the lock is taken, and are the
/// ones a node would get under it (`docs/BUGS.md` B221).
#[test]
fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() {
let (mut st, _dir) = state();
let db = st.db.clone();
let listing = vec![
node("a", "root", "a.txt", false),
node("b", "root", "b.txt", false),
];
let early = inos_for(&db, &listing);
assert_eq!(early.len(), 2);
let inos = st.intern_batch_with_inos(0, listing, early.clone());
assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]);
st.flush_outbox();
let mut restarted = State::new(db, HashMap::new());
assert_eq!(
restarted.intern(0, node("b", "root", "b.txt", false)),
early[&uid("b")]
);
}
/// The listing is recorded complete with its children's rows, when the
/// lock goes, never ahead of them.
#[test]
fn a_listing_is_recorded_complete_after_its_children() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root", "none", "root", true));
st.flush_outbox();
st.intern_batch(root, vec![node("a", "root", "a.txt", false)]);
st.record_listed(uid("root"));
assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none());
st.flush_outbox();
let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap();
assert_eq!(kids.len(), 1);
}
/// Audit A5. Moving a file out of a folder leaves that folder's DB row /// Audit A5. Moving a file out of a folder leaves that folder's DB row
/// claiming a complete listing, so once the hot cache drops it, remote /// claiming a complete listing, so once the hot cache drops it, remote
/// changes under it stop being seen. `relocate` has to clear both sides. /// changes under it stop being seen. `relocate` has to clear both sides.
+38 -4
View File
@@ -37,10 +37,12 @@ const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
/// fresh mount does at startup (hydrate, recover, initial enumeration). /// fresh mount does at startup (hydrate, recover, initial enumeration).
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30); const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`, /// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
/// returning the original file name it was a copy of. `None` when `name` is not a /// `name`, returning the original file name it was a copy of. The label names
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix` /// the computer that made the copy (B251); copies made before it have the bare
/// formats, so the two halves of the product agree on what a conflict is. /// stamp. `None` when `name` is not a conflict copy. Inverse of the
/// `conflict_name` / `conflict_path_with_suffix` formats, so the two halves of
/// the product agree on what a conflict is.
pub(crate) fn conflict_base_name(name: &str) -> Option<String> { pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
const MARKER: &str = " (sync-conflict "; const MARKER: &str = " (sync-conflict ";
let start = name.find(MARKER)?; let start = name.find(MARKER)?;
@@ -48,6 +50,20 @@ pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
let close_rel = name[after..].find(')')?; let close_rel = name[after..].find(')')?;
let close = after + close_rel; let close = after + close_rel;
let inner = &name[after..close]; let inner = &name[after..close];
// A label is one token of the characters `conflict_label_of` keeps.
let inner = match inner.rsplit_once(' ') {
Some((label, token)) => {
let label_ok = !label.is_empty()
&& label
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if !label_ok {
return None;
}
token
}
None => inner,
};
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a // The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
// file that merely happens to contain the phrase is not mistaken for a copy. // file that merely happens to contain the phrase is not mistaken for a copy.
let mut parts = inner.splitn(2, '-'); let mut parts = inner.splitn(2, '-');
@@ -486,6 +502,24 @@ mod tests {
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(), conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
Some("notes.txt") Some("notes.txt")
); );
// Named after the computer that made it (B251).
assert_eq!(
conflict_base_name("notes (sync-conflict laptop 1700).txt").as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name("notes (sync-conflict my-pc.local 1700-2).txt").as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name(&crate::conflict_name("notes.txt", 1700)).as_deref(),
Some("notes.txt")
);
assert_eq!(
conflict_base_name("x (sync-conflict two words 1).txt"),
None
);
assert_eq!(conflict_base_name("x (sync-conflict 1).txt"), None);
// Not a conflict copy. // Not a conflict copy.
assert_eq!(conflict_base_name("report.xml"), None); assert_eq!(conflict_base_name("report.xml"), None);
// Looks close but the token is not a stamp. // Looks close but the token is not a stamp.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -22,7 +22,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120);
/// per-item work is a network round-trip, so a folder with thousands of files is /// per-item work is a network round-trip, so a folder with thousands of files is
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload /// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
/// engine does. /// engine does.
pub(super) const SYNC_CONCURRENCY: usize = 8; pub(crate) const SYNC_CONCURRENCY: usize = 8;
/// A message to the sync engine's control thread. /// A message to the sync engine's control thread.
pub(crate) enum SyncMsg { pub(crate) enum SyncMsg {
+70 -10
View File
@@ -83,6 +83,46 @@ pub(super) struct RemoteItem {
pub(super) is_dir: bool, pub(super) is_dir: bool,
pub(super) mtime: i64, pub(super) mtime: i64,
pub(super) size: i64, pub(super) size: i64,
/// The active revision's id. The light walk carries it for every file, so
/// it is the signal that tells another client's new revision from a link
/// merely re-stamped.
pub(super) revision: Option<String>,
/// The active revision's content SHA-1, read only for files the walk
/// enumerated in full (no baseline, or a moved mtime). `None` is never
/// proof of anything.
pub(super) sha1: Option<String>,
}
/// What a pass saw of a remote file: the signature its baseline records once
/// the file is synced, and what an upload checks Drive still holds before it
/// supersedes it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct RemoteSeen {
pub(super) mtime: i64,
pub(super) size: i64,
pub(super) revision: Option<String>,
}
impl RemoteSeen {
/// Whether `node` is still the revision this was taken from. The revision
/// id decides it when both sides have one; otherwise the modification time
/// and size, which is all a baseline row from before schema 44 holds.
pub(super) fn still_current(&self, node: &Node) -> bool {
match (&self.revision, crate::node_revision_id(node)) {
(Some(seen), Some(now)) => *seen == now,
_ => (self.mtime, self.size) == remote_file_sig(node),
}
}
}
impl From<&RemoteItem> for RemoteSeen {
fn from(item: &RemoteItem) -> Self {
Self {
mtime: item.mtime,
size: item.size,
revision: item.revision.clone(),
}
}
} }
/// The result of a reconcile pass: what it moved, how many paths were kept as /// The result of a reconcile pass: what it moved, how many paths were kept as
@@ -113,7 +153,7 @@ impl Outcome {
Applied::Uploaded => self.uploaded += 1, Applied::Uploaded => self.uploaded += 1,
Applied::Downloaded => self.downloaded += 1, Applied::Downloaded => self.downloaded += 1,
Applied::Conflict => self.conflicts += 1, Applied::Conflict => self.conflicts += 1,
Applied::Vanished => {} Applied::Vanished | Applied::Adopted => {}
} }
} }
@@ -157,10 +197,16 @@ pub(super) enum Pending {
CreateDir { rel: String, parent: NodeUid }, CreateDir { rel: String, parent: NodeUid },
/// Upload a brand-new local file into `parent`. /// Upload a brand-new local file into `parent`.
UploadNew { rel: String, parent: NodeUid }, UploadNew { rel: String, parent: NodeUid },
/// Upload a changed local file as a new revision of `uid`. /// Upload a changed local file as a new revision of `uid`, if Drive still
UploadRevision { rel: String, uid: NodeUid }, /// holds the revision `seen` describes (B249).
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the UploadRevision {
/// remote's reported size, used as the transfer's expected total. rel: String,
uid: NodeUid,
seen: RemoteSeen,
},
/// Download remote `uid` to the local path, stamping `seen.mtime`.
/// `seen.size` is the remote's reported size, used as the transfer's
/// expected total.
/// ///
/// `local` is what the planning walk saw at this path, or `None` if nothing /// `local` is what the planning walk saw at this path, or `None` if nothing
/// was there. A download can run for minutes, so the apply step revalidates /// was there. A download can run for minutes, so the apply step revalidates
@@ -170,16 +216,26 @@ pub(super) enum Pending {
Download { Download {
rel: String, rel: String,
uid: NodeUid, uid: NodeUid,
mtime: i64, seen: RemoteSeen,
size: i64,
local: Option<LocalSig>, local: Option<LocalSig>,
}, },
/// First sync of a path present on both sides at the same size, with a
/// digest on Drive: hash the local file, and record the baseline if it holds
/// the same bytes. A different file of the same size goes the way of
/// [`Pending::Conflict`]: two machines syncing one folder must not read as
/// in step while they hold different content.
Adopt {
rel: String,
uid: NodeUid,
seen: RemoteSeen,
sha1: String,
local: LocalSig,
},
/// Both sides changed: set the local copy aside, then download remote `uid`. /// Both sides changed: set the local copy aside, then download remote `uid`.
Conflict { Conflict {
rel: String, rel: String,
uid: NodeUid, uid: NodeUid,
mtime: i64, seen: RemoteSeen,
size: i64,
}, },
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local /// Both sides changed during a [`push pass`](Core::push_pass): upload the local
/// copy into `parent` under a conflict name, leaving the remote file as it is. /// copy into `parent` under a conflict name, leaving the remote file as it is.
@@ -194,6 +250,7 @@ impl Pending {
| Pending::UploadNew { rel, .. } | Pending::UploadNew { rel, .. }
| Pending::UploadRevision { rel, .. } | Pending::UploadRevision { rel, .. }
| Pending::Download { rel, .. } | Pending::Download { rel, .. }
| Pending::Adopt { rel, .. }
| Pending::Conflict { rel, .. } | Pending::Conflict { rel, .. }
| Pending::PushConflict { rel, .. } => rel, | Pending::PushConflict { rel, .. } => rel,
} }
@@ -206,7 +263,9 @@ impl Pending {
Pending::UploadNew { .. } Pending::UploadNew { .. }
| Pending::UploadRevision { .. } | Pending::UploadRevision { .. }
| Pending::PushConflict { .. } => ActivityKind::Upload, | Pending::PushConflict { .. } => ActivityKind::Upload,
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download, Pending::Download { .. } | Pending::Adopt { .. } | Pending::Conflict { .. } => {
ActivityKind::Download
}
} }
} }
@@ -217,6 +276,7 @@ impl Pending {
Pending::UploadNew { .. } => "new file", Pending::UploadNew { .. } => "new file",
Pending::UploadRevision { .. } => "new version", Pending::UploadRevision { .. } => "new version",
Pending::Download { .. } => "from Drive", Pending::Download { .. } => "from Drive",
Pending::Adopt { .. } => "already the same on Drive",
Pending::Conflict { .. } => "local changes kept as a conflict copy", Pending::Conflict { .. } => "local changes kept as a conflict copy",
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy", Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
} }
+371 -21
View File
@@ -192,21 +192,169 @@ pub(super) fn guard_local_wipe<B, L>(
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) { if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
return Err(format!( return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \ "every one of the {} synced paths is missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable.", them on Drive. Check that the folder is mounted and readable; to download \
them again, sync the folder from scratch.",
baseline.len() baseline.len()
)); ));
} }
Ok(()) Ok(())
} }
/// Refuse to run a pass whose remote side has vanished in its entirety: 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.
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)) {
return Err(format!(
"every one of the {} synced paths is 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()
));
}
Ok(())
}
/// What a remote walk saw but could not read: a node the SDK left out because
/// it would not decrypt, or a file whose size could not be read.
///
/// A path missing from the walk is read as deleted on Drive, so a pass must
/// not classify one it only failed to read. Since a node that failed to decrypt
/// has no name, all a walk can say is that a folder's listing came back short;
/// every path under that folder that the walk did not return is then left out,
/// as [`Unscanned`] leaves out what the local scan could not read.
#[derive(Default)]
pub(super) struct RemoteGaps {
/// Folders whose listing came back with fewer nodes than it named.
short: HashSet<String>,
/// Paths seen, but whose entry could not be read.
unread: HashSet<String>,
}
impl RemoteGaps {
/// The listing of `folder` named nodes that did not come back.
pub(super) fn short_listing(&mut self, folder: &str) {
self.short.insert(folder.to_string());
}
/// `rel` is on Drive, but the walk could not read what it needs of it.
pub(super) fn unread(&mut self, rel: String) {
self.unread.insert(rel);
}
pub(super) fn is_empty(&self) -> bool {
self.short.is_empty() && self.unread.is_empty()
}
/// Whether `rel` may be missing from `remote` only because the walk could
/// not read it. A path the walk returned is never hidden.
pub(super) fn hides<R>(
&self,
rel: &str,
remote: &HashMap<String, R>,
remote_dirs: &HashMap<String, NodeUid>,
) -> bool {
if self.is_empty() || remote.contains_key(rel) {
return false;
}
if self.unread.contains(rel) {
return true;
}
// The deepest folder the walk did list decides: under a short listing,
// anything not returned may be one of the nodes it could not read.
let mut at = parent_rel(rel);
loop {
if remote_dirs.contains_key(at) {
return self.short.contains(at);
}
if at.is_empty() {
return false;
}
at = parent_rel(at);
}
}
/// The folders to name to the user, sorted.
pub(super) fn report(&self) -> Vec<String> {
let mut lines: Vec<String> = self
.short
.iter()
.map(|rel| {
let shown = if rel.is_empty() { "the folder" } else { rel };
format!("{shown} (items on Proton Drive that cannot be read)")
})
.chain(
self.unread
.iter()
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
)
.collect();
lines.sort_unstable();
lines
}
}
/// Why the remote folder at `rel` must not be trashed although it was deleted
/// here: something under it is new or changed on Drive since the last sync,
/// so it is another device's work that this computer has never had.
///
/// Trashing the folder would take that work with it, and the next pass would
/// then delete the copy this pass just downloaded. `None` when everything under
/// it is exactly what the baseline recorded.
pub(super) fn remote_subtree_changed(
rel: &str,
remote: &HashMap<String, RemoteItem>,
baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<String> {
let prefix = format!("{rel}/");
let mut changed: Vec<&String> = remote
.iter()
.filter(|(path, _)| path.starts_with(&prefix))
.filter(|(path, item)| match baseline.get(*path) {
None => true,
Some(_) if item.is_dir => false,
Some(base) => {
remote_sig(base) != Some((item.mtime, item.size))
|| revision_moved(Some(base), item.revision.as_deref())
}
})
.map(|(path, _)| path)
.collect();
changed.sort_unstable();
changed
.first()
.map(|path| format!("{path} is new or changed on Proton Drive"))
}
/// The size the baseline recorded for `rel`, if its remote signature's mtime /// The size the baseline recorded for `rel`, if its remote signature's mtime
/// still matches `mtime`. /// still matches `mtime` and its revision is still `revision`.
///
/// A row without a revision id, while Drive reports one, cannot vouch for the
/// revision, so the size is read for real. Such a row comes from before
/// schema 44, or from an upload Drive did not list yet when it was read back.
/// Another computer's revision in the same second as that upload kept the
/// modification time, and only the size could show it (B254). The pass then
/// records the id on the row, so this costs one read per row.
pub(super) fn unchanged_remote_size( pub(super) fn unchanged_remote_size(
baseline: &HashMap<String, StoredSyncEntry>, baseline: &HashMap<String, StoredSyncEntry>,
rel: &str, rel: &str,
mtime: i64, mtime: i64,
revision: Option<&str>,
) -> Option<i64> { ) -> Option<i64> {
match baseline.get(rel).and_then(remote_sig) { let base = baseline.get(rel)?;
if revision_moved(Some(base), revision)
|| (base.remote_revision_id.is_none() && revision.is_some())
{
return None;
}
match remote_sig(base) {
Some((recorded, size)) if recorded == mtime => Some(size), Some((recorded, size)) if recorded == mtime => Some(size),
_ => None, _ => None,
} }
@@ -221,6 +369,17 @@ pub(super) fn join_rel(prefix: &str, name: &str) -> String {
} }
} }
/// Whether Drive's active revision `now` is not the one the baseline row was
/// synced at. Only a revision id on both sides can say so; a row from before
/// schema 44, or a node read without one, leaves it to the modification time
/// and size.
pub(super) fn revision_moved(base: Option<&StoredSyncEntry>, now: Option<&str>) -> bool {
match (base.and_then(|b| b.remote_revision_id.as_deref()), now) {
(Some(recorded), Some(now)) => recorded != now,
_ => false,
}
}
/// The stored remote signature of a baseline row, if it has one. /// The stored remote signature of a baseline row, if it has one.
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> { pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
match (&e.remote_rev, &e.remote_hash) { match (&e.remote_rev, &e.remote_hash) {
@@ -251,7 +410,8 @@ pub(super) fn rel_to_path(rel: &str) -> PathBuf {
rel.split('/').collect() rel.split('/').collect()
} }
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`. /// The name for a conflict copy of `path`, e.g.
/// `notes (sync-conflict laptop 1700000000).txt`.
#[cfg(test)] #[cfg(test)]
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf { pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
conflict_path_with_suffix(path, stamp, 0) conflict_path_with_suffix(path, stamp, 0)
@@ -260,16 +420,17 @@ pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
/// As [`conflict_path`], with a deterministic suffix used when that name already /// As [`conflict_path`], with a deterministic suffix used when that name already
/// exists. This keeps conflict preservation from ever replacing an older copy. /// exists. This keeps conflict preservation from ever replacing an older copy.
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf { pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
conflict_path_as(path, crate::conflict_label(), stamp, suffix)
}
/// [`conflict_path_with_suffix`] with the machine's label given.
pub(super) fn conflict_path_as(path: &Path, label: &str, stamp: i64, suffix: u32) -> PathBuf {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file"); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|s| s.to_str()); let ext = path.extension().and_then(|s| s.to_str());
let suffix = if suffix == 0 { let tag = crate::conflict_tag(label, stamp, suffix);
String::new()
} else {
format!("-{suffix}")
};
let name = match ext { let name = match ext {
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"), Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
None => format!("{stem} (sync-conflict {stamp}{suffix})"), None => format!("{stem} (sync-conflict {tag})"),
}; };
match path.parent() { match path.parent() {
Some(dir) => dir.join(name), Some(dir) => dir.join(name),
@@ -290,11 +451,14 @@ pub(super) enum FilePlan {
DeleteLocal, DeleteLocal,
DeleteRemote, DeleteRemote,
ForgetBaseline, ForgetBaseline,
/// First sync of a file that already exists on both sides with identical /// First sync of a file that exists on both sides at the same size, with a
/// content: record the baseline without transferring anything. Without this, /// digest on Drive: record the baseline without transferring anything if
/// the local file hashes the same, else keep a conflict copy. Without this,
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every /// a folder synced for the first time cuts a `(sync-conflict)` copy of every
/// pre-existing file, because a missing baseline makes both sides read as /// pre-existing file, because a missing baseline makes both sides read as
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md). /// "changed" (see the mass first-sync conflict storm in docs/BUGS.md). Size
/// alone is not enough: a second machine joining the folder would read a
/// different file of the same size as in step (B248).
AdoptBaseline, AdoptBaseline,
} }
@@ -313,8 +477,13 @@ pub(super) fn plan_file(
// left the file the same length, so it was never uploaded. // left the file the same length, so it was never uploaded.
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base))) baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
}); });
// Another client's new revision shows in its id even when the link's
// modification time and the size come out the same.
let remote_changed = remote.is_some_and(|item| { let remote_changed = remote.is_some_and(|item| {
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size))) baseline.is_none_or(|base| {
remote_sig(base) != Some((item.mtime, item.size))
|| revision_moved(Some(base), item.revision.as_deref())
})
}); });
match (local, remote) { match (local, remote) {
@@ -324,10 +493,13 @@ pub(super) fn plan_file(
(false, true) => FilePlan::Download, (false, true) => FilePlan::Download,
// With a baseline, both-changed is a genuine concurrent edit and must // With a baseline, both-changed is a genuine concurrent edit and must
// be preserved as a conflict. Without one, "both changed" only means // be preserved as a conflict. Without one, "both changed" only means
// this is the first sync and there was nothing to compare to: adopt // this is the first sync and there was nothing to compare to: when the
// the baseline when the sizes already match rather than conflict-copy // sizes match and Drive has a digest to compare against, the apply
// an already-identical file. Divergent sizes stay a real conflict. // step hashes the local file before adopting it. Divergent sizes, or
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline, // no digest, stay a real conflict: a missing digest proves nothing.
(true, true) if baseline.is_none() && l.size == r.size && r.sha1.is_some() => {
FilePlan::AdoptBaseline
}
(true, true) => FilePlan::Conflict, (true, true) => FilePlan::Conflict,
}, },
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew, (Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
@@ -359,6 +531,8 @@ mod file_plan_tests {
is_dir: false, is_dir: false,
mtime, mtime,
size, size,
revision: None,
sha1: None,
} }
} }
@@ -376,6 +550,7 @@ mod file_plan_tests {
local_size, local_size,
remote_rev: Some(remote_mtime.to_string()), remote_rev: Some(remote_mtime.to_string()),
remote_hash: Some(remote_size.to_string()), remote_hash: Some(remote_size.to_string()),
remote_revision_id: None,
} }
} }
@@ -497,12 +672,28 @@ mod file_plan_tests {
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() { fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
// No baseline yet (first sync). Same size on both sides is a pre-existing // No baseline yet (first sync). Same size on both sides is a pre-existing
// identical file, not a concurrent edit: adopt it rather than cut a // identical file, not a concurrent edit: adopt it rather than cut a
// conflict copy of every file in the folder. // conflict copy of every file in the folder. The apply step still
// hashes the local file before it records anything.
let local = local(10, 40);
let remote = RemoteItem {
sha1: Some("da39a3ee".into()),
..remote(30, 40)
};
assert_eq!(
plan_file(Some(&local), Some(&remote), None),
FilePlan::AdoptBaseline
);
}
/// A file on Drive without a digest cannot be compared without a download,
/// and the same size is not the same content (B248).
#[test]
fn first_sync_without_a_remote_digest_is_a_conflict() {
let local = local(10, 40); let local = local(10, 40);
let remote = remote(30, 40); let remote = remote(30, 40);
assert_eq!( assert_eq!(
plan_file(Some(&local), Some(&remote), None), plan_file(Some(&local), Some(&remote), None),
FilePlan::AdoptBaseline FilePlan::Conflict
); );
} }
@@ -518,6 +709,73 @@ mod file_plan_tests {
); );
} }
/// Another computer's new revision can leave the modification time and
/// the size as they were. The revision id still tells it apart (B249).
#[test]
fn a_new_remote_revision_is_a_change_even_at_the_same_mtime_and_size() {
let mut base = baseline(10, 20, 30, 40);
base.remote_revision_id = Some("r1".into());
let unedited = local(10, 20);
let same = RemoteItem {
revision: Some("r1".into()),
..remote(30, 40)
};
let newer = RemoteItem {
revision: Some("r2".into()),
..remote(30, 40)
};
assert_eq!(
plan_file(Some(&unedited), Some(&same), Some(&base)),
FilePlan::Unchanged
);
assert_eq!(
plan_file(Some(&unedited), Some(&newer), Some(&base)),
FilePlan::Download
);
let edited = local(11, 20);
assert_eq!(
plan_file(Some(&edited), Some(&newer), Some(&base)),
FilePlan::Conflict
);
// A row from before schema 44 has no id to compare, and a node read
// without one says nothing: both fall back to the signature.
let old = baseline(10, 20, 30, 40);
assert_eq!(
plan_file(Some(&unedited), Some(&newer), Some(&old)),
FilePlan::Unchanged
);
assert_eq!(
plan_file(Some(&unedited), Some(&remote(30, 40)), Some(&base)),
FilePlan::Unchanged
);
}
/// The walk reuses the recorded size only while the revision is the one
/// the baseline holds.
#[test]
fn a_moved_revision_makes_the_walk_read_the_size_again() {
let mut base = baseline(10, 20, 30, 40);
base.remote_revision_id = Some("r1".into());
let rows = HashMap::from([("file".to_string(), base)]);
assert_eq!(
unchanged_remote_size(&rows, "file", 30, Some("r1")),
Some(40)
);
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r2")), None);
assert_eq!(unchanged_remote_size(&rows, "file", 31, Some("r1")), None);
}
/// A row without a revision id reuses its size only while Drive gives no
/// id either (B254).
#[test]
fn a_row_without_a_revision_makes_the_walk_read_the_size_again() {
let rows = HashMap::from([("file".to_string(), baseline(10, 20, 30, 40))]);
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r1")), None);
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
}
#[test] #[test]
fn a_missing_remote_signature_is_treated_as_changed() { fn a_missing_remote_signature_is_treated_as_changed() {
let local = local(10, 20); let local = local(10, 20);
@@ -530,4 +788,96 @@ mod file_plan_tests {
FilePlan::Download FilePlan::Download
); );
} }
fn uid(id: &str) -> NodeUid {
NodeUid::new(VolumeId::from("v"), LinkId::from(id))
}
fn folder() -> RemoteItem {
RemoteItem {
is_dir: true,
mtime: 0,
size: 0,
..remote(0, 0)
}
}
/// 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() {
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());
let one = HashMap::from([("b".to_string(), remote(1, 1))]);
assert!(guard_remote_wipe(&base, &one).is_ok());
let empty: HashMap<String, ()> = HashMap::new();
assert!(guard_remote_wipe(&empty, &none).is_ok());
}
/// A node that failed to decrypt has no name, so everything a short
/// listing did not return is left out, and nothing else.
#[test]
fn a_short_remote_listing_hides_what_it_did_not_return() {
let remote = HashMap::from([
("docs".to_string(), folder()),
("docs/a.txt".to_string(), remote(1, 1)),
("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();
assert!(!gaps.hides("docs/b.txt", &remote, &dirs));
gaps.short_listing("docs");
// Returned paths stand; unreturned ones under the short folder hide,
// and so does anything under a subfolder it failed to return.
assert!(!gaps.hides("docs/a.txt", &remote, &dirs));
assert!(gaps.hides("docs/b.txt", &remote, &dirs));
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
// A complete sibling listing still reads deletions as deletions.
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
assert!(!gaps.hides("top.txt", &remote, &dirs));
gaps.short_listing("");
assert!(gaps.hides("top.txt", &remote, &dirs));
}
#[test]
fn a_file_whose_size_could_not_be_read_is_hidden() {
let remote: HashMap<String, RemoteItem> = HashMap::new();
let dirs = HashMap::from([(String::new(), uid("root"))]);
let mut gaps = RemoteGaps::default();
gaps.unread("a.txt".to_string());
assert!(gaps.hides("a.txt", &remote, &dirs));
assert!(!gaps.hides("b.txt", &remote, &dirs));
assert_eq!(gaps.report(), ["a.txt (cannot be read on Proton Drive)"]);
}
/// A folder deleted here holds another device's new work on Drive, so
/// trashing it would lose that work.
#[test]
fn a_remote_folder_with_new_or_changed_files_is_not_trashed() {
let mut base = HashMap::from([
("x".to_string(), baseline(0, 0, 0, 0)),
("x/old.txt".to_string(), baseline(1, 1, 5, 6)),
]);
let mut remote_tree = HashMap::from([
("x".to_string(), folder()),
("x/old.txt".to_string(), remote(5, 6)),
("xy/new.txt".to_string(), remote(1, 1)),
]);
// Unchanged under x; a name-prefixed sibling does not count.
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
remote_tree.insert("x/old.txt".to_string(), remote(9, 6));
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
remote_tree.insert("x/old.txt".to_string(), remote(5, 6));
remote_tree.insert("x/sub".to_string(), folder());
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
base.insert("x/sub".to_string(), baseline(0, 0, 0, 0));
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
}
} }
+95 -15
View File
@@ -25,7 +25,7 @@ use std::collections::{BTreeMap, HashMap};
use std::io::Read; use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection}; use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives}; use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
@@ -36,6 +36,7 @@ use sha1::{Digest, Sha1};
use tracing::{info, warn}; use tracing::{info, warn};
use super::Core; use super::Core;
use crate::photos::can_make_thumbnail;
use crate::transfers::CountingReader; use crate::transfers::CountingReader;
/// The one import this daemon may be running, and how the last one ended. /// The one import this daemon may be running, and how the last one ended.
@@ -136,6 +137,30 @@ fn read_entry<R>(
} }
impl Core { impl Core {
/// Copy archive entry `entry` into the thumbnail staging folder and return
/// its path. The caller removes it.
fn stage_takeout_entry(
&self,
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
entry: &str,
) -> CoreResult<PathBuf> {
static STAGED: AtomicU64 = AtomicU64::new(0);
let path = self.cache.raw_thumbnail_staging_dir().join(format!(
"takeout-{}-{}",
std::process::id(),
STAGED.fetch_add(1, Ordering::Relaxed)
));
let copied = read_entry(archive, entry, |reader| {
let mut file = std::fs::File::create(&path)?;
std::io::copy(reader, &mut file)
})?;
if let Err(e) = copied {
let _ = std::fs::remove_file(&path);
return Err(CoreError::internal(format!("copy {entry}: {e}")));
}
Ok(path)
}
/// Import Google Photos Takeout archives. Blocking; run on its own thread. /// Import Google Photos Takeout archives. Blocking; run on its own thread.
/// ///
/// `cancel` stops the run at the next photo boundary — an upload already on /// `cancel` stops the run at the next photo boundary — an upload already on
@@ -321,18 +346,65 @@ impl Core {
capture_time: photo.capture_time, capture_time: photo.capture_time,
..Default::default() ..Default::default()
}; };
let uploaded = read_entry(&mut archive, &photo.entry, |reader| { // A thumbnail is made from a file, so a photo that can have one
let counting = CountingReader::new(reader, &guard); // is copied out of the archive first and uploaded from the copy.
self.rt.block_on(photos.upload_photo_from( // Without one, Proton's apps show a blank tile until they make
&photo.name, // their own.
&photo.media_type, let staged = can_make_thumbnail(&photo.name, photo.size)
counting, .then(|| self.stage_takeout_entry(&mut archive, &photo.entry))
photo.size as i64, .and_then(|staged| {
Vec::new(), staged
metadata, .inspect_err(|e| {
false, warn!(name = photo.name, error = %e,
)) "copying a takeout photo out failed; uploading it without a thumbnail")
}); })
.ok()
});
let uploaded = match &staged {
Some(path) => {
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 metadata = PhotoUploadMetadata {
capture_time: metadata
.capture_time
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
..metadata
};
std::fs::File::open(path)
.map_err(|e| {
CoreError::internal(format!("open {}: {e}", path.display()))
})
.map(|file| {
let counting =
CountingReader::new(std::io::BufReader::new(file), &guard);
self.rt.block_on(photos.upload_photo_from(
&photo.name,
&photo.media_type,
counting,
photo.size as i64,
thumbnails,
metadata,
false,
))
})
}
None => read_entry(&mut archive, &photo.entry, |reader| {
let counting = CountingReader::new(reader, &guard);
self.rt.block_on(photos.upload_photo_from(
&photo.name,
&photo.media_type,
counting,
photo.size as i64,
Vec::new(),
metadata,
false,
))
}),
};
if let Some(path) = staged {
let _ = std::fs::remove_file(path);
}
drop(guard); drop(guard);
job.step(); job.step();
@@ -480,7 +552,15 @@ impl Core {
let mut state = IMPORT.lock(); let mut state = IMPORT.lock();
state.running = false; state.running = false;
state.cancel = None; state.cancel = None;
state.last = outcome.as_ref().ok().cloned(); // A failed run is reported too: without it, `--wait` read
// "no import has run" and exited as if all was well.
state.last = Some(match &outcome {
Ok(summary) => summary.clone(),
Err(e) => ImportSummary {
error: Some(e.to_string()),
..Default::default()
},
});
} }
match outcome { match outcome {
Ok(summary) => { Ok(summary) => {
@@ -545,7 +625,7 @@ pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
Ok(hex_lower(&hasher.finalize())) Ok(hex_lower(&hasher.finalize()))
} }
fn hex_lower(bytes: &[u8]) -> String { pub(crate) fn hex_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2); let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes { for byte in bytes {
out.push_str(&format!("{byte:02x}")); out.push_str(&format!("{byte:02x}"));
+5
View File
@@ -125,6 +125,11 @@ impl TransferRegistry {
self.download_limit.set(download); self.download_limit.set(download);
} }
/// The upload cap in bytes per second, `0` when there is none.
pub fn upload_cap(&self) -> u64 {
self.upload_limit.rate.load(Ordering::Relaxed)
}
fn limit(&self, direction: TransferDirection) -> &RateLimit { fn limit(&self, direction: TransferDirection) -> &RateLimit {
match direction { match direction {
TransferDirection::Upload => &self.upload_limit, TransferDirection::Upload => &self.upload_limit,
+27 -8
View File
@@ -10,9 +10,14 @@
//! `dead_code` allowances below are for — not code nobody uses. //! `dead_code` allowances below are for — not code nobody uses.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration;
use pdfs_core::control::{PhotoKind, SearchHit}; use pdfs_core::control::{PhotoKind, SearchHit};
/// How long a `stat` of the mount may take before the file counts as not
/// there. A frozen mount never answers, and every caller has a fallback.
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DriveActivation { pub(crate) enum DriveActivation {
Folder, Folder,
@@ -55,18 +60,32 @@ pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathB
present(mounted_or_relative(mountpoint, hit)) present(mounted_or_relative(mountpoint, hit))
} }
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
/// than a search hit — the browser's listing entries, which are by definition
/// paths in the primary mount's tree.
#[allow(dead_code)] // browser only
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
present(mounted_path(mountpoint, relative_path))
}
fn present(path: PathBuf) -> Option<PathBuf> { fn present(path: PathBuf) -> Option<PathBuf> {
path.symlink_metadata().is_ok().then_some(path) path.symlink_metadata().is_ok().then_some(path)
} }
/// Whether anything is at `path`, asked on a thread of its own and given up
/// on after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer".
///
/// For the GTK binaries, whose main thread must not `stat` the mount: a
/// mount that hangs hung the whole window with it. The thread of a `stat`
/// that never returns is left behind; there is no way to cancel one.
#[allow(dead_code)] // GTK binaries only
pub(crate) async fn present_within(path: PathBuf) -> Option<PathBuf> {
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(present(path));
});
match gtk4::glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
Ok(Ok(target)) => target,
Ok(Err(_)) => None,
Err(_) => {
tracing::warn!("the mount did not answer in time");
None
}
}
}
/// The mount path for a hit, whether or not anything is there — for callers /// The mount path for a hit, whether or not anything is there — for callers
/// that have no fallback (a folder cannot be materialised) or already know the /// that have no fallback (a folder cannot be materialised) or already know the
/// entry came from a mount listing. /// entry came from a mount listing.
+155 -20
View File
@@ -10,6 +10,7 @@ pub(crate) mod pages;
pub(crate) mod reload; pub(crate) mod reload;
#[path = "../theme.rs"] #[path = "../theme.rs"]
pub(crate) mod theme; pub(crate) mod theme;
pub(crate) mod update;
pub(crate) mod widgets; pub(crate) mod widgets;
use compat::*; use compat::*;
@@ -33,6 +34,7 @@ use pages::trash::*;
use pages::verify::*; use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items}; use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme}; use theme::{load_resources, proton_theme_active, set_proton_theme};
use update::*;
use widgets::compat::*; use widgets::compat::*;
use widgets::crumb::*; use widgets::crumb::*;
use widgets::details::*; use widgets::details::*;
@@ -69,15 +71,15 @@ use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::{AppDirs, FileSort, FilesView}; use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo, DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response, PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem, Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic, ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
TransferDirection, TransferItem, send, ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
}; };
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
@@ -130,8 +132,12 @@ struct Ui {
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere. /// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
sidebar: gtk4::ListBox, sidebar: gtk4::ListBox,
/// The sidebar/content split. Collapsed while signed out, so the login page /// The sidebar/content split. Collapsed while signed out, so the login page
/// owns the whole window and no destination is reachable without a session. /// owns the whole window and no destination is reachable without a session,
/// and while the window is [`narrow`](Self::narrow).
nav: adw::NavigationSplitView, nav: adw::NavigationSplitView,
/// The window is too narrow for the sidebar beside a page, so the two take
/// turns. Set by the narrow breakpoint; [`refresh`] reads it.
narrow: Cell<bool>,
/// Shared thumbnails for ordinary image files outside the Photos gallery. /// Shared thumbnails for ordinary image files outside the Photos gallery.
/// One cache and request queue serves Files, search, Shared and Trash, so /// One cache and request queue serves Files, search, Shared and Trash, so
/// the same image is downloaded and decoded only once. /// the same image is downloaded and decoded only once.
@@ -151,6 +157,7 @@ struct Ui {
pub(crate) locations: LocationsState, pub(crate) locations: LocationsState,
pub(crate) activity: ActivityState, pub(crate) activity: ActivityState,
pub(crate) takeout: TakeoutState, pub(crate) takeout: TakeoutState,
pub(crate) update: UpdateState,
} }
impl Ui { impl Ui {
@@ -297,7 +304,10 @@ fn present_window(app: &adw::Application) {
/// Spawn the tray icon process in the background. /// Spawn the tray icon process in the background.
pub(crate) fn spawn_tray() { pub(crate) fn spawn_tray() {
match Command::new("pdfs-tray").spawn() { match Command::new("pdfs-tray").spawn() {
Ok(_) => tracing::info!("spawned `pdfs-tray`"), Ok(child) => {
tracing::info!("spawned `pdfs-tray`");
pdfs_core::opener::reap(child);
}
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
} }
} }
@@ -318,7 +328,9 @@ fn build_window(app: &adw::Application) {
tracing::warn!("cannot create the app's directories: {e}"); tracing::warn!("cannot create the app's directories: {e}");
} }
let stack = adw::ViewStack::new(); // Not homogeneous: each page sets the window's minimum width only while it
// is shown, so the widest page does not hold every other one wide.
let stack = adw::ViewStack::builder().hhomogeneous(false).build();
let (login_page, login_widgets) = build_login_page(); let (login_page, login_widgets) = build_login_page();
let main_widgets = build_main_page(); let main_widgets = build_main_page();
let (browser_page, browser_widgets) = build_browser_page(); let (browser_page, browser_widgets) = build_browser_page();
@@ -336,8 +348,12 @@ fn build_window(app: &adw::Application) {
login_frame.add_top_bar(&{ login_frame.add_top_bar(&{
let header = adw::HeaderBar::new(); let header = adw::HeaderBar::new();
header.set_show_title(false); header.set_show_title(false);
// The split is collapsed while signed out; going "back" would only
// uncover a sidebar with nowhere to go.
header.set_show_back_button(false);
header header
}); });
login_frame.add_top_bar(&login_widgets.keyring_banner);
login_frame.set_content(Some(&login_page)); login_frame.set_content(Some(&login_page));
stack.add_named(&login_frame, Some("login")); stack.add_named(&login_frame, Some("login"));
stack.add_named(&browser_page, Some("browser")); stack.add_named(&browser_page, Some("browser"));
@@ -352,7 +368,8 @@ fn build_window(app: &adw::Application) {
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack; // Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
// login lands on Files). // login lands on Files).
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer); let update = UpdateState::new();
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer, &update.banner);
// Every page brings its own header bar (see `page_frame`), so the content // Every page brings its own header bar (see `page_frame`), so the content
// side is the bare stack. // side is the bare stack.
@@ -365,6 +382,8 @@ fn build_window(app: &adw::Application) {
.content(&content_page) .content(&content_page)
.min_sidebar_width(200.0) .min_sidebar_width(200.0)
.max_sidebar_width(240.0) .max_sidebar_width(240.0)
// A narrow window opens on the page, not the sidebar.
.show_content(true)
.build(); .build();
// Toasts float over everything, so a report from a background action reaches // Toasts float over everything, so a report from a background action reaches
@@ -372,17 +391,22 @@ fn build_window(app: &adw::Application) {
let toasts = adw::ToastOverlay::new(); let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&split)); toasts.set_child(Some(&split));
let (startup_session, keyring_error) = match read_session() {
Ok(session) => (session, None),
Err(e) => (None, Some(e)),
};
let ui = Rc::new(Ui { let ui = Rc::new(Ui {
dirs, dirs,
stack: stack.clone(), stack: stack.clone(),
toasts: toasts.clone(), toasts: toasts.clone(),
busy: Cell::new(0), busy: Cell::new(0),
opening: RefCell::new(HashSet::new()), opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()), session: RefCell::new(startup_session),
unlock_offered: Cell::new(false), unlock_offered: Cell::new(false),
mounted: RefCell::new(false), mounted: RefCell::new(false),
sidebar: sidebar_list.clone(), sidebar: sidebar_list.clone(),
nav: split.clone(), nav: split.clone(),
narrow: Cell::new(false),
file_thumbs: FileThumbnailState::new(), file_thumbs: FileThumbnailState::new(),
login: login_widgets, login: login_widgets,
status: StatusState { status: StatusState {
@@ -412,6 +436,7 @@ fn build_window(app: &adw::Application) {
theme_row: main_widgets.theme_row.clone(), theme_row: main_widgets.theme_row.clone(),
online_map_row: main_widgets.online_map_row.clone(), online_map_row: main_widgets.online_map_row.clone(),
tray_row: main_widgets.tray_row.clone(), tray_row: main_widgets.tray_row.clone(),
update_row: main_widgets.update_row.clone(),
language_row: main_widgets.language_row.clone(), language_row: main_widgets.language_row.clone(),
settings_suppress: Cell::new(false), settings_suppress: Cell::new(false),
budget_source: RefCell::new(None), budget_source: RefCell::new(None),
@@ -538,6 +563,7 @@ fn build_window(app: &adw::Application) {
reload_pending: Cell::new(false), reload_pending: Cell::new(false),
loading: Cell::new(false), loading: Cell::new(false),
has_more: Cell::new(false), has_more: Cell::new(false),
library: Cell::new(true),
page_waiters: RefCell::new(Vec::new()), page_waiters: RefCell::new(Vec::new()),
width: Cell::new(0), width: Cell::new(0),
photo_tex: RefCell::new(HashMap::new()), photo_tex: RefCell::new(HashMap::new()),
@@ -580,6 +606,7 @@ fn build_window(app: &adw::Application) {
pause: locations_widgets.pause.clone(), pause: locations_widgets.pause.clone(),
paused: Cell::new(false), paused: Cell::new(false),
sign_in: locations_widgets.card_sign_in.clone(), sign_in: locations_widgets.card_sign_in.clone(),
restart: locations_widgets.card_restart.clone(),
}, },
queue: QueueState { queue: QueueState {
group: locations_widgets.queue_group.clone(), group: locations_widgets.queue_group.clone(),
@@ -632,8 +659,12 @@ fn build_window(app: &adw::Application) {
running: Cell::new(false), running: Cell::new(false),
dry_run: Cell::new(false), dry_run: Cell::new(false),
}, },
update,
}); });
wire_login(&ui); wire_login(&ui);
if let Some(e) = keyring_error {
keyring_unreadable(&ui, &e);
}
let ui_status = ui.clone(); let ui_status = ui.clone();
main_widgets main_widgets
.status_button .status_button
@@ -675,6 +706,7 @@ fn build_window(app: &adw::Application) {
wire_activity(&ui, &activity_widgets); wire_activity(&ui, &activity_widgets);
wire_takeout(&ui, &takeout_widgets); wire_takeout(&ui, &takeout_widgets);
wire_retry(&ui); wire_retry(&ui);
wire_update(&ui);
// Lazily load the Files / Photos pages the first time they're shown, so the // Lazily load the Files / Photos pages the first time they're shown, so the
// network round-trip only happens on demand rather than on every refresh. // network round-trip only happens on demand rather than on every refresh.
@@ -711,13 +743,18 @@ fn build_window(app: &adw::Application) {
} }
}); });
let saved = ui.dirs.load_config().window.usable();
let window = adw::ApplicationWindow::builder() let window = adw::ApplicationWindow::builder()
.application(app) .application(app)
.title("Proton Drive") .title("Proton Drive")
.default_width(980) .default_width(saved.width)
.default_height(680) .default_height(saved.height)
.maximized(saved.maximized)
.width_request(WindowState::MIN_WIDTH)
.height_request(WindowState::MIN_HEIGHT)
.content(&toasts) .content(&toasts)
.build(); .build();
remember_window_state(&ui, app, &window);
// A wide window docks the details pane beside the files instead of laying it // A wide window docks the details pane beside the files instead of laying it
// over them. // over them.
let wide = adw::Breakpoint::new( let wide = adw::Breakpoint::new(
@@ -725,6 +762,18 @@ fn build_window(app: &adw::Application) {
); );
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value())); wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
window.add_breakpoint(wide); window.add_breakpoint(wide);
// A narrow window shows the sidebar and the page in turn, so every header
// bar keeps room for the window buttons. Signals rather than a setter:
// `refresh` decides the collapse while signed out, and would undo one.
let narrow = adw::Breakpoint::new(
adw::BreakpointCondition::parse("max-width: 760sp").expect("static breakpoint"),
);
narrow.add_setter(&ui.browser.search, "width-chars", Some(&8.to_value()));
let ui_narrow = ui.clone();
narrow.connect_apply(move |_| set_narrow(&ui_narrow, true));
let ui_wide = ui.clone();
narrow.connect_unapply(move |_| set_narrow(&ui_wide, false));
window.add_breakpoint(narrow);
install_shortcuts(&ui, &window); install_shortcuts(&ui, &window);
install_window_actions(&ui, &window); install_window_actions(&ui, &window);
install_launch_actions(&ui, app, &window); install_launch_actions(&ui, app, &window);
@@ -738,6 +787,51 @@ fn build_window(app: &adw::Application) {
window.present(); window.present();
} }
/// Follow the narrow breakpoint. While signed out the login page keeps the
/// split collapsed whatever the width.
fn set_narrow(ui: &Ui, narrow: bool) {
ui.narrow.set(narrow);
if ui.session.borrow().is_some() {
ui.nav.set_collapsed(narrow);
}
}
/// Save the window's size when it closes, and when the app quits with the
/// window still open (Ctrl+Q, the tray's Quit), which never asks it to close.
fn remember_window_state(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
let ui_close = ui.clone();
window.connect_close_request(move |window| {
save_window_state(&ui_close, window);
glib::Propagation::Proceed
});
let ui_quit = ui.clone();
let window_quit = window.downgrade();
app.connect_shutdown(move |_| {
if let Some(window) = window_quit.upgrade() {
save_window_state(&ui_quit, &window);
}
});
}
/// Write the window's unmaximised size and maximised state to the config, if
/// either changed. Runs as the window goes away, so a failure is only logged.
fn save_window_state(ui: &Ui, window: &adw::ApplicationWindow) {
let (width, height) = window.default_size();
let state = WindowState {
width,
height,
maximized: window.is_maximized(),
};
let mut config = ui.dirs.load_config();
if config.window == state {
return;
}
config.window = state;
if let Err(e) = ui.dirs.save_config(&config) {
tracing::warn!("cannot save the window size: {e}");
}
}
/// Whether `window` is minimised or otherwise out of sight (another workspace, /// Whether `window` is minimised or otherwise out of sight (another workspace,
/// fully covered), as far as the compositor tells us. /// fully covered), as far as the compositor tells us.
fn window_hidden(window: &adw::ApplicationWindow) -> bool { fn window_hidden(window: &adw::ApplicationWindow) -> bool {
@@ -784,10 +878,13 @@ const DESTINATIONS: [(&str, &str, &str); 7] = [
/// Index of the first row in the sidebar's sync section. /// Index of the first row in the sidebar's sync section.
const SIDEBAR_SYNC_SECTION: i32 = 6; const SIDEBAR_SYNC_SECTION: i32 = 6;
/// The navigation sidebar: the destinations over a footer carrying the sync /// The navigation sidebar: the destinations under the update banner and over a
/// status, the account quota and the account menu. Returns the page (for the /// footer carrying the sync status, the account quota and the account menu. Returns the page (for the
/// split view) and the list (to drive + reflect the current page). /// split view) and the list (to drive + reflect the current page).
fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) { fn build_sidebar(
footer: &gtk4::Box,
update_banner: &adw::Banner,
) -> (adw::NavigationPage, gtk4::ListBox) {
let list = gtk4::ListBox::new(); let list = gtk4::ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single); list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("navigation-sidebar"); list.add_css_class("navigation-sidebar");
@@ -840,6 +937,7 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
.build(); .build();
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.add_top_bar(update_banner);
toolbar.set_content(Some(&scroll)); toolbar.set_content(Some(&scroll));
toolbar.add_bottom_bar(footer); toolbar.add_bottom_bar(footer);
@@ -865,6 +963,18 @@ fn set_busy_spinners(visible: bool) {
}); });
} }
/// `row` in a frame that scrolls sideways once the window is too narrow for it,
/// so a row of labelled buttons never sets the window's minimum width.
fn scroll_sideways(row: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Automatic)
.vscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_width(true)
.propagate_natural_height(true)
.child(row)
.build()
}
/// A destination page's frame: its own header bar over the page content, the /// A destination page's frame: its own header bar over the page content, the
/// way GNOME apps lay a split view out. The title sits in the middle with the /// way GNOME apps lay a split view out. The title sits in the middle with the
/// busy spinner beside it; the page packs its actions into the returned header /// busy spinner beside it; the page packs its actions into the returned header
@@ -928,6 +1038,14 @@ fn wire_sidebar(ui: &Rc<Ui>) {
ui_row.stack.set_visible_child_name(page); ui_row.stack.set_visible_child_name(page);
} }
}); });
// In a narrow window the sidebar covers the page, so picking a row, even
// the selected one, has to bring the page forward.
let ui_open = ui.clone();
ui.sidebar.connect_row_activated(move |_, _| {
if ui_open.nav.is_collapsed() {
ui_open.nav.set_show_content(true);
}
});
} }
/// Re-fetch the visible page from the server, bypassing every layer of cache /// Re-fetch the visible page from the server, bypassing every layer of cache
@@ -1063,8 +1181,17 @@ pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
/// Show a transient toast. Non-blocking by design: an action's outcome is /// Show a transient toast. Non-blocking by design: an action's outcome is
/// reported without stealing focus or forcing a click, so the user can keep /// reported without stealing focus or forcing a click, so the user can keep
/// working while a slow upload lands. /// working while a slow upload lands.
///
/// A toast's title is Pango markup unless told otherwise. Messages carry file
/// names and daemon errors, and one with a `&` or `<` in it showed as an empty
/// toast, so every toast here is plain text.
fn toast(ui: &Rc<Ui>, message: &str) { fn toast(ui: &Rc<Ui>, message: &str) {
ui.toasts.add_toast(adw::Toast::new(message)); ui.toasts.add_toast(
adw::Toast::builder()
.title(message)
.use_markup(false)
.build(),
);
} }
/// Show a toast that offers one follow-up action — an Undo, or a way to the page /// Show a toast that offers one follow-up action — an Undo, or a way to the page
@@ -1075,6 +1202,7 @@ fn toast(ui: &Rc<Ui>, message: &str) {
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) { fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
let toast = adw::Toast::builder() let toast = adw::Toast::builder()
.title(message) .title(message)
.use_markup(false)
.button_label(label) .button_label(label)
.timeout(8) .timeout(8)
.build(); .build();
@@ -1097,7 +1225,11 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)]) gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
}; };
tracing::warn!("{message}"); tracing::warn!("{message}");
let toast = adw::Toast::builder().title(&message).timeout(6).build(); let toast = adw::Toast::builder()
.title(&message)
.use_markup(false)
.timeout(6)
.build();
ui.toasts.add_toast(toast); ui.toasts.add_toast(toast);
} }
@@ -1697,9 +1829,12 @@ pub(crate) fn flash_copied(button: &gtk4::Button, icon: &'static str) {
/// Open a local path with the handler the user configured — `xdg-open` unless /// Open a local path with the handler the user configured — `xdg-open` unless
/// `open_with` in `config.json` overrides it for this kind of file. /// `open_with` in `config.json` overrides it for this kind of file.
///
/// On a thread of its own: telling a folder from a file is a `stat`, and the
/// path is often in the mount, which may hang.
fn open_path(path: &str) { fn open_path(path: &str) {
let path = Path::new(path); let path = PathBuf::from(path);
pdfs_core::opener::open_default(path, path.is_dir()); std::thread::spawn(move || pdfs_core::opener::open_default(&path, path.is_dir()));
} }
/// [`open_path`] for a file the daemon materialised into the content cache, /// [`open_path`] for a file the daemon materialised into the content cache,
+4 -1
View File
@@ -378,7 +378,9 @@ pub(crate) fn activity_status(
) { ) {
ui.activity.status.set_icon_name(Some(icon)); ui.activity.status.set_icon_name(Some(icon));
ui.activity.status.set_title(title); ui.activity.status.set_title(title);
ui.activity.status.set_description(Some(description)); ui.activity
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.activity.retry.set_visible(retry); ui.activity.retry.set_visible(retry);
ui.activity.content.set_visible_child_name("status"); ui.activity.content.set_visible_child_name("status");
} }
@@ -1041,6 +1043,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
ActivityKind::Sync => "pdfs-sync-symbolic", ActivityKind::Sync => "pdfs-sync-symbolic",
ActivityKind::Rename => "document-edit-symbolic", ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic", ActivityKind::Move => "go-jump-symbolic",
ActivityKind::Copy => "edit-copy-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic", ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash => "user-trash-symbolic", ActivityKind::Trash => "user-trash-symbolic",
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic", ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
@@ -179,6 +179,10 @@ pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args), (ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
// Translators: {name} is a file or folder name. // Translators: {name} is a file or folder name.
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args), (ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
// Translators: {name} is a file or folder name.
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
// Translators: {name} is a folder name. // Translators: {name} is a folder name.
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args), (ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
// Translators: {name} is a folder name. // Translators: {name} is a folder name.
+3 -1
View File
@@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) { fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.albums_status.set_icon_name(Some(icon)); ui.gallery.albums_status.set_icon_name(Some(icon));
ui.gallery.albums_status.set_title(title); ui.gallery.albums_status.set_title(title);
ui.gallery.albums_status.set_description(Some(description)); ui.gallery
.albums_status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.albums_stack.set_visible_child_name("status"); ui.gallery.albums_stack.set_visible_child_name("status");
} }
+291 -108
View File
@@ -1,4 +1,4 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel}; use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
use crate::*; use crate::*;
pub(crate) struct BrowserState { pub(crate) struct BrowserState {
@@ -376,7 +376,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
bulk_box.append(&bulk_clear); bulk_box.append(&bulk_clear);
let bulk = gtk4::Revealer::builder() let bulk = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown) .transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&bulk_box) .child(&scroll_sideways(&bulk_box))
.build(); .build();
// The status bar: contextual text (stretch 1), then the icon-size slider. // The status bar: contextual text (stretch 1), then the icon-size slider.
@@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) { pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.browser.status.set_icon_name(Some(icon)); ui.browser.status.set_icon_name(Some(icon));
ui.browser.status.set_title(title); ui.browser.status.set_title(title);
ui.browser.status.set_description(Some(description)); ui.browser
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.browser.retry.set_visible(retry); ui.browser.retry.set_visible(retry);
// Only the empty-folder state offers a way to fill the folder; every other // Only the empty-folder state offers a way to fill the folder; every other
// status (loading, offline, error) turns them back off. // status (loading, offline, error) turns them back off.
@@ -520,9 +522,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
} }
/// Reflect the current selection in the bulk bar: how many are selected, and /// Reflect the current selection in the bulk bar: how many are selected, and
/// which bulk actions apply to that mix. Offline state is a file-only notion, so /// which bulk actions apply to that mix. An entry kept offline through a pinned
/// a folder anywhere in the selection disables the two pin buttons rather than /// folder above it cannot be made online only on its own, so it does not count
/// half-failing once pressed. /// towards Make online only.
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) { pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
let entries = selected_entries(ui); let entries = selected_entries(ui);
// One selected entry is the details pane's job; the bar is for a batch. // One selected entry is the details pane's job; the bar is for a batch.
@@ -530,8 +532,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
ui.browser.bulk.set_reveal_child(false); ui.browser.bulk.set_reveal_child(false);
return; return;
} }
let files_only = entries.iter().all(|e| !e.is_dir); let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
let any_pinned = entries.iter().any(|e| e.pinned);
let any_unpinned = entries.iter().any(|e| !e.pinned); let any_unpinned = entries.iter().any(|e| !e.pinned);
let mounted = *ui.mounted.borrow(); let mounted = *ui.mounted.borrow();
ui.browser.bulk_label.set_label(&ngettext_f( ui.browser.bulk_label.set_label(&ngettext_f(
@@ -541,12 +542,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
&[], &[],
)); ));
ui.browser.bulk_trash.set_sensitive(mounted); ui.browser.bulk_trash.set_sensitive(mounted);
ui.browser ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
.bulk_pin ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
.set_sensitive(mounted && files_only && any_unpinned);
ui.browser
.bulk_unpin
.set_sensitive(mounted && files_only && any_pinned);
ui.browser.bulk.set_reveal_child(true); ui.browser.bulk.set_reveal_child(true);
} }
@@ -652,16 +649,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
); );
} }
/// Pin or unpin every selected file, one request at a time. /// Pin or unpin every selected entry, one request at a time. A folder's files
/// download in the background, so its request returns before they are there.
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) { pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
let socket = ui.dirs.control_socket(); let socket = ui.dirs.control_socket();
// Only the entries that need changing: re-pinning an already-pinned file is // Only the entries that need changing: re-pinning an already-pinned file is
// a wasted round-trip, and it would inflate the count the toast reports. // a wasted round-trip, and it would inflate the count the toast reports. One
let paths: Vec<String> = entries // kept through a pinned folder above it has no pin of its own to remove.
let changing: Vec<&DirEntry> = entries
.iter() .iter()
.filter(|e| !e.is_dir && e.pinned != pin) .filter(|e| !e.pinned_by_folder && e.pinned != pin)
.map(|e| entry_rel(ui, e))
.collect(); .collect();
let any_folder = changing.iter().any(|e| e.is_dir);
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
@@ -698,13 +698,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
(0, None) => {} (0, None) => {}
(n, _) => toast( (n, _) => toast(
&ui, &ui,
&if pin { &if pin && any_folder {
ngettext_f(
"Making {n} item available offline…",
"Making {n} items available offline…",
n as u64,
&[],
)
} else if pin {
ngettext_f( ngettext_f(
"{n} file is now available offline", "{n} file is now available offline",
"{n} files are now available offline", "{n} files are now available offline",
n as u64, n as u64,
&[], &[],
) )
} else if any_folder {
ngettext_f(
"{n} item is now online only",
"{n} items are now online only",
n as u64,
&[],
)
} else { } else {
ngettext_f( ngettext_f(
"{n} file is now online only", "{n} file is now online only",
@@ -860,13 +874,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
} }
menu.section(); menu.section();
if !entry.is_dir { // Kept through a pinned folder above it, the entry has no pin of its own
// to remove: the check shows that state without offering to change it.
if entry.pinned_by_folder {
menu.locked_toggle(&gettext("Available offline"), true);
} else {
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| { menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
toggle_pin(&ui_c, &entry_c) toggle_pin(&ui_c, &entry_c)
}); });
menu.section();
} }
menu.section();
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Share…"), move || { menu.item(&gettext("Share…"), move || {
@@ -886,6 +904,12 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
menu.item(&gettext("Move To…"), move || { menu.item(&gettext("Move To…"), move || {
prompt_move(&ui_c, vec![entry_c.clone()]) prompt_move(&ui_c, vec![entry_c.clone()])
}); });
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Make a Copy"), move || make_copy(&ui_c, &entry_c));
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Copy To…"), move || {
prompt_copy(&ui_c, vec![entry_c.clone()])
});
if !entry.is_dir { if !entry.is_dir {
let (ui_c, entry_c) = (ui.clone(), entry.clone()); let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&gettext("Versions…"), move || { menu.item(&gettext("Versions…"), move || {
@@ -908,9 +932,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
/// simply absent here rather than offered and then refused. /// simply absent here rather than offered and then refused.
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu { pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new(); let mut menu = ActionMenu::new();
// Offline state is a file-only notion; the check shows whether the whole // The check shows whether the whole batch already is offline.
// batch already is. {
if entries.iter().all(|e| !e.is_dir) {
let (ui_c, batch) = (ui.clone(), entries.clone()); let (ui_c, batch) = (ui.clone(), entries.clone());
let all_pinned = entries.iter().all(|e| e.pinned); let all_pinned = entries.iter().all(|e| e.pinned);
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| { menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
@@ -921,6 +944,10 @@ pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMe
menu.item(&gettext("Move To…"), move || { menu.item(&gettext("Move To…"), move || {
prompt_move(&ui_c, batch.clone()) prompt_move(&ui_c, batch.clone())
}); });
let (ui_c, batch) = (ui.clone(), entries.clone());
menu.item(&gettext("Copy To…"), move || {
prompt_copy(&ui_c, batch.clone())
});
menu.labelled_section(&ngettext_f( menu.labelled_section(&ngettext_f(
"{n} selected", "{n} selected",
"{n} selected", "{n} selected",
@@ -966,28 +993,27 @@ pub(crate) fn background_context_menu(ui: &Rc<Ui>) -> ActionMenu {
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let Some(path) = mounted_target_rel(&mountpoint, &rel) else {
toast_error(
ui,
&gettext("Couldn't open file"),
&gettext("Open With needs the Proton Drive service to be running."),
);
return;
};
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
launcher.set_always_ask(true);
let ui = ui.clone(); let ui = ui.clone();
launcher.launch( glib::spawn_future_local(async move {
ui_window(&ui).as_ref(), let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await else {
gio::Cancellable::NONE, toast_error(
move |result| { &ui,
&gettext("Couldn't open file"),
&gettext("Open With needs the Proton Drive service to be running."),
);
return;
};
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
launcher.set_always_ask(true);
let window = ui_window(&ui);
launcher.launch(window.as_ref(), gio::Cancellable::NONE, move |result| {
if let Err(e) = result if let Err(e) = result
&& !e.matches(gtk4::DialogError::Dismissed) && !e.matches(gtk4::DialogError::Dismissed)
{ {
toast_error(&ui, &gettext("Couldn't open file"), &e.to_string()); toast_error(&ui, &gettext("Couldn't open file"), &e.to_string());
} }
}, });
); });
} }
/// Copy the entry's public link, when it has one this client can read the /// Copy the entry's public link, when it has one this client can read the
@@ -1095,27 +1121,29 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
/// [`mounted_target`](crate::activation::mounted_target). /// [`mounted_target`](crate::activation::mounted_target).
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); // Ignore a repeat activation of a file already opening, so an impatient
if let Some(path) = mounted_target_rel(&mountpoint, &rel) { // double-click doesn't open it twice or kick off a second round-trip.
open_path(&path.to_string_lossy());
return;
}
// Ignore a repeat activation of a file already downloading, so an impatient
// double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(rel.clone()) { if !ui.opening.borrow_mut().insert(rel.clone()) {
return; return;
} }
ui.busy_begin(); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
let name = entry.name.clone(); let (name, uid) = (entry.name.clone(), entry.uid.clone());
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenFile {
path: rel.clone(),
uid: Some(entry.uid.clone()),
},
);
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
// Asked off the main thread: a mount that hangs used to hang the app.
if let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await {
ui.opening.borrow_mut().remove(&rel);
open_path(&path.to_string_lossy());
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenFile {
path: rel.clone(),
uid: Some(uid),
},
);
let result = rx.recv().await; let result = rx.recv().await;
ui.busy_end(); ui.busy_end();
ui.opening.borrow_mut().remove(&rel); ui.opening.borrow_mut().remove(&rel);
@@ -1147,6 +1175,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
let rx = spawn_request(ui.dirs.control_socket(), req); let rx = spawn_request(ui.dirs.control_socket(), req);
let name = entry.name.clone(); let name = entry.name.clone();
let pinned = entry.pinned; let pinned = entry.pinned;
let is_dir = entry.is_dir;
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
match rx.recv().await { match rx.recv().await {
@@ -1166,6 +1195,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
&ui, &ui,
&if pinned { &if pinned {
gettext_f("“{name}” is now online only", &[("name", &name)]) gettext_f("“{name}” is now online only", &[("name", &name)])
} else if is_dir {
// The daemon downloads a folder's files in the background.
gettext_f("Making “{name}” available offline…", &[("name", &name)])
} else { } else {
gettext_f("“{name}” is now available offline", &[("name", &name)]) gettext_f("“{name}” is now available offline", &[("name", &name)])
}, },
@@ -1918,17 +1950,63 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it /// still made on Proton Drive, so nothing is downloaded or uploaded again; it
/// is sent with absolute paths, which is how the daemon tells locations apart. /// is sent with absolute paths, which is how the daemon tells locations apart.
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) { pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
prompt_destination(ui, entries, PickerKind::Move);
}
/// Ask where to copy `entries`, then copy them there on Proton Drive. Only
/// My files is offered: a copy is made on the server, inside the mount.
pub(crate) fn prompt_copy(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
prompt_destination(ui, entries, PickerKind::Copy);
}
/// Copy one entry into the folder it is in. The copy gets a free name, such
/// as "report (1).pdf".
pub(crate) fn make_copy(ui: &Rc<Ui>, entry: &DirEntry) {
let source = entry_rel(ui, entry);
let parent = source
.rfind('/')
.map(|i| source[..i].to_string())
.unwrap_or_default();
run_bulk_copy(ui, vec![source], parent);
}
/// What the folder picker chooses a destination for.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PickerKind {
Move,
Copy,
}
fn prompt_destination(ui: &Rc<Ui>, entries: Vec<DirEntry>, kind: PickerKind) {
if entries.is_empty() { if entries.is_empty() {
return; return;
} }
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect(); let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
let heading = match entries.as_slice() { let (heading, body, confirm) = match (kind, entries.as_slice()) {
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]), (PickerKind::Move, [one]) => (
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]), gettext_f("Move “{name}”", &[("name", &one.name)]),
gettext("Choose the folder to move into."),
gettext("Move Here"),
),
(PickerKind::Move, many) => (
ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
gettext("Choose the folder to move into."),
gettext("Move Here"),
),
(PickerKind::Copy, [one]) => (
gettext_f("Copy “{name}”", &[("name", &one.name)]),
gettext("Choose the folder to copy into."),
gettext("Copy Here"),
),
(PickerKind::Copy, many) => (
ngettext_f("Copy {n} item", "Copy {n} items", many.len() as u64, &[]),
gettext("Choose the folder to copy into."),
gettext("Copy Here"),
),
}; };
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading(heading) .heading(heading)
.body(gettext("Choose the folder to move into.")) .body(body)
.build(); .build();
let up = gtk4::Button::builder() let up = gtk4::Button::builder()
@@ -1959,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
body.append(&scroll); body.append(&scroll);
dialog.set_extra_child(Some(&body)); dialog.set_extra_child(Some(&body));
dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here")); dialog.add_response("move", &confirm);
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested); dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("move")); dialog.set_default_response(Some("move"));
dialog.set_close_response("cancel"); dialog.set_close_response("cancel");
@@ -1972,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
list, list,
up: up.clone(), up: up.clone(),
sources: sources.clone(), sources: sources.clone(),
kind,
folder: RefCell::new(MoveTarget::Locations), folder: RefCell::new(MoveTarget::Locations),
generation: Cell::new(0), generation: Cell::new(0),
}); });
@@ -1991,6 +2070,10 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
} }
match picker_done.folder.borrow().clone() { match picker_done.folder.borrow().clone() {
MoveTarget::Locations => {} MoveTarget::Locations => {}
MoveTarget::Drive(folder) if kind == PickerKind::Copy => {
run_bulk_copy(&ui, sources.clone(), folder)
}
MoveTarget::Synced { .. } if kind == PickerKind::Copy => {}
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder), MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
MoveTarget::Synced { path, .. } => { MoveTarget::Synced { path, .. } => {
let absolute = sources let absolute = sources
@@ -2066,8 +2149,9 @@ struct MovePicker {
location: gtk4::Label, location: gtk4::Label,
list: gtk4::ListBox, list: gtk4::ListBox,
up: gtk4::Button, up: gtk4::Button,
/// What is being moved, mountpoint-relative. /// What is being moved or copied, mountpoint-relative.
sources: Vec<String>, sources: Vec<String>,
kind: PickerKind,
/// The folder on show, and the destination if the user confirms now. /// The folder on show, and the destination if the user confirms now.
folder: RefCell<MoveTarget>, folder: RefCell<MoveTarget>,
/// Drops a listing that arrives after the user went elsewhere. /// Drops a listing that arrives after the user went elsewhere.
@@ -2088,11 +2172,13 @@ impl MovePicker {
self.location.set_tooltip_text(Some(&tooltip)); self.location.set_tooltip_text(Some(&tooltip));
self.up self.up
.set_sensitive(!matches!(folder, MoveTarget::Locations)); .set_sensitive(!matches!(folder, MoveTarget::Locations));
let allowed = match &folder { let allowed = match (&folder, self.kind) {
MoveTarget::Locations => false, (MoveTarget::Locations, _) => false,
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel), (MoveTarget::Drive(rel), PickerKind::Move) => move_target_allowed(&self.sources, rel),
(MoveTarget::Drive(rel), PickerKind::Copy) => copy_target_allowed(&self.sources, rel),
// What is moved is in My files, so it cannot be in a synced folder. // What is moved is in My files, so it cannot be in a synced folder.
MoveTarget::Synced { .. } => true, (MoveTarget::Synced { .. }, PickerKind::Move) => true,
(MoveTarget::Synced { .. }, PickerKind::Copy) => false,
}; };
self.dialog.set_response_enabled("move", allowed); self.dialog.set_response_enabled("move", allowed);
*self.folder.borrow_mut() = folder.clone(); *self.folder.borrow_mut() = folder.clone();
@@ -2158,6 +2244,10 @@ impl MovePicker {
/// My files and every synced folder, as rows of the top level. /// My files and every synced folder, as rows of the top level.
async fn locations(&self) -> Option<Vec<PickerRow>> { async fn locations(&self) -> Option<Vec<PickerRow>> {
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))]; let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
// A copy is made on the server, so it can only land in My files.
if self.kind == PickerKind::Copy {
return Some(rows);
}
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders); let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else { let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
// My files alone is still somewhere to move to. // My files alone is still somewhere to move to.
@@ -2264,11 +2354,16 @@ pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool {
.map(|i| path[..i].to_string()) .map(|i| path[..i].to_string())
.unwrap_or_default() .unwrap_or_default()
}; };
let inside = sources
.iter()
.any(|src| target == src || target.starts_with(&format!("{src}/")));
let all_here = sources.iter().all(|src| parent(src) == target); let all_here = sources.iter().all(|src| parent(src) == target);
!inside && !all_here copy_target_allowed(sources, target) && !all_here
}
/// Whether `target` is somewhere `sources` can be copied to: anywhere but into
/// one of themselves. The folder they are in is fine; the copy gets a free name.
pub(crate) fn copy_target_allowed(sources: &[String], target: &str) -> bool {
!sources
.iter()
.any(|src| target == src || target.starts_with(&format!("{src}/")))
} }
/// Move each of `sources` into `target`, one request at a time, then report /// Move each of `sources` into `target`, one request at a time, then report
@@ -2287,38 +2382,14 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
clear_selection(ui); clear_selection(ui);
let ui = ui.clone(); let ui = ui.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let mut done = 0usize; let (done, failure) = send_each(&socket, sources, |path| Request::Move {
let mut failure: Option<(String, ErrorKind)> = None; path,
for path in sources { new_parent: target.clone(),
let rx = spawn_request( })
socket.clone(), .await;
Request::Move {
path,
new_parent: target.clone(),
},
);
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => done += 1,
Ok(Ok(Response::Error { message, kind })) => {
failure.get_or_insert((message, kind));
}
_ => {
failure.get_or_insert_with(|| {
(
gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
}
}
}
ui.busy_end(); ui.busy_end();
reload_listing(&ui); reload_listing(&ui);
let place = match target.rsplit('/').next() { let place = quoted_place(&target);
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
_ => "Proton Drive".to_string(),
};
match (done, failure) { match (done, failure) {
(0, Some((message, kind))) => { (0, Some((message, kind))) => {
toast_failure(&ui, &gettext("Couldn't move"), &message, kind) toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
@@ -2351,6 +2422,101 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
}); });
} }
/// Copy each of `sources` into `target` on Proton Drive, one request at a
/// time, then report once for the lot.
pub(crate) fn run_bulk_copy(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
if !*ui.mounted.borrow() {
toast_error(
ui,
&gettext("Couldn't copy"),
&gettext("Proton Drive isn't connected."),
);
return;
}
let socket = ui.dirs.control_socket();
ui.busy_begin();
clear_selection(ui);
let ui = ui.clone();
glib::spawn_future_local(async move {
let (done, failure) = send_each(&socket, sources, |path| Request::Copy {
path,
new_parent: target.clone(),
})
.await;
ui.busy_end();
reload_listing(&ui);
let place = quoted_place(&target);
match (done, failure) {
(0, Some((message, kind))) => {
toast_failure(&ui, &gettext("Couldn't copy"), &message, kind)
}
(0, None) => {}
(n, Some((message, _))) => toast_error(
&ui,
// Translators: {place} is a quoted folder name or "Proton Drive".
&ngettext_f(
"Copied {n} item to {place}, but not all",
"Copied {n} items to {place}, but not all",
n as u64,
&[("place", &place)],
),
&message,
),
// Translators: {place} is a quoted folder name or "Proton Drive".
(1, None) => toast(&ui, &gettext_f("Copied to {place}", &[("place", &place)])),
(n, None) => toast(
&ui,
// Translators: {place} is a quoted folder name or "Proton Drive".
&ngettext_f(
"Copied {n} item to {place}",
"Copied {n} items to {place}",
n as u64,
&[("place", &place)],
),
),
}
});
}
/// Send one request per source, in order, and count how many succeeded. The
/// first failure is kept for the report.
async fn send_each(
socket: &Path,
sources: Vec<String>,
request: impl Fn(String) -> Request,
) -> (usize, Option<(String, ErrorKind)>) {
let mut done = 0usize;
let mut failure: Option<(String, ErrorKind)> = None;
for path in sources {
let rx = spawn_request(socket.to_path_buf(), request(path));
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => done += 1,
Ok(Ok(Response::Error { message, kind })) => {
failure.get_or_insert((message, kind));
}
_ => {
failure.get_or_insert_with(|| {
(
gettext("The Proton Drive service didn't respond."),
ErrorKind::Internal,
)
});
}
}
}
(done, failure)
}
/// The destination folder for a report: its name in quotes, or "Proton Drive"
/// for My files.
fn quoted_place(target: &str) -> String {
match target.rsplit('/').next() {
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
_ => "Proton Drive".to_string(),
}
}
/// Move one entry to Trash, through the batch path so it gets the same Undo. /// Move one entry to Trash, through the batch path so it gets the same Undo.
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
run_bulk_delete(ui, vec![entry.clone()]); run_bulk_delete(ui, vec![entry.clone()]);
@@ -2487,16 +2653,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
}); });
} }
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders /// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
/// (which carry no per-file cache state). A sync issue ranks first, on folders /// cache state of its own, so it has one only when kept offline. A sync issue
/// too: the change waits for the user. Pinned (kept offline) ranks above merely /// ranks first, on folders too: the change waits for the user. Pinned (kept
/// cached (downloaded, evictable); everything else is online-only. /// offline) ranks above merely cached (downloaded, evictable); everything else
/// is online-only.
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
if entry.issue.is_some() { if entry.issue.is_some() {
return Some(("dialog-warning-symbolic", "badge-issue")); return Some(("dialog-warning-symbolic", "badge-issue"));
} }
if entry.is_dir { if entry.is_dir {
return None; return entry
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if entry.pinned { Some(if entry.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
@@ -2905,6 +3074,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
size: h.size, size: h.size,
modified: h.modified, modified: h.modified,
pinned: h.pinned, pinned: h.pinned,
pinned_by_folder: false,
cached: h.cached, cached: h.cached,
uid: h.uid.clone(), uid: h.uid.clone(),
path: h.path.clone(), path: h.path.clone(),
@@ -2952,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed, MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
show_thumbnail_build_progress, sort_entries, move_target_allowed, show_thumbnail_build_progress, sort_entries,
}; };
use pdfs_core::config::{FileSort, FilesView}; use pdfs_core::config::{FileSort, FilesView};
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus}; use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
@@ -2965,6 +3135,7 @@ mod tests {
size, size,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: String::new(), uid: String::new(),
path: String::new(), path: String::new(),
@@ -2992,6 +3163,8 @@ mod tests {
let mut folder = entry("d", true, 0, 0); let mut folder = entry("d", true, 0, 0);
assert_eq!(badge_for(&folder), None); assert_eq!(badge_for(&folder), None);
folder.pinned = true;
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
folder.issue = Some(SyncIssue::Missing); folder.issue = Some(SyncIssue::Missing);
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue"); assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
} }
@@ -3038,6 +3211,16 @@ mod tests {
assert!(move_target_allowed(&mixed, "")); assert!(move_target_allowed(&mixed, ""));
} }
#[test]
fn copy_refuses_its_own_subtree_but_not_the_folder_it_is_in() {
let sources = ["Docs/Work".to_string()];
assert!(!copy_target_allowed(&sources, "Docs/Work"));
assert!(!copy_target_allowed(&sources, "Docs/Work/Old"));
assert!(copy_target_allowed(&sources, "Docs"));
assert!(copy_target_allowed(&sources, ""));
assert!(copy_target_allowed(&sources, "Docs/Workshop"));
}
#[test] #[test]
fn listing_summary_matches_dolphin_order_and_wording() { fn listing_summary_matches_dolphin_order_and_wording() {
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files"); assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
+118 -5
View File
@@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) { pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon)); ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title); ui.devices.status.set_title(title);
ui.devices.status.set_description(Some(description)); ui.devices
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.devices.retry.set_visible(retry); ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status"); ui.devices.content.set_visible_child_name("status");
} }
@@ -390,6 +392,7 @@ fn this_computer_row(
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone())) .title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
.subtitle(this_computer_subtitle(folders)) .subtitle(this_computer_subtitle(folders))
.use_markup(false)
.activatable(true) .activatable(true)
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
@@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&dev.name) .title(&dev.name)
.subtitle(device_subtitle(dev)) .subtitle(device_subtitle(dev))
.use_markup(false)
.activatable(true) .activatable(true)
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic")); row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
@@ -440,6 +444,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone()))) prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
}); });
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone()); let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Sync With This Computer…"), move || {
prompt_join_device(&ui_c, &uid_c, &name_c)
});
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Move to This Computer…"), move || { menu.item(&gettext("Move to This Computer…"), move || {
prompt_migrate_device(&ui_c, &uid_c, &name_c) prompt_migrate_device(&ui_c, &uid_c, &name_c)
}); });
@@ -621,9 +629,17 @@ pub(crate) fn sync_state_label(state: &str) -> String {
/// — so both read "12 of 40" rather than a percentage that could go backwards. /// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String { pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase { match p.phase {
// Before the first pass finishes there is no estimate, so the count would SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
// be "checked 12 of 12" — worse than saying nothing. // Before the first pass finishes there is no estimate, so "12 of 12" would
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"), // say nothing. The count alone still shows a first scan of a large folder
// moving, which can take many minutes before anything downloads.
// Translators: {n} counts the folders and files checked so far.
SyncPhase::Scanning if p.total == 0 => ngettext_f(
"checking for changes — {n} item checked",
"checking for changes — {n} items checked",
p.done as u64,
&[],
),
// Translators: {done} and {total} count folders and files checked so far. // Translators: {done} and {total} count folders and files checked so far.
SyncPhase::Scanning => gettext_f( SyncPhase::Scanning => gettext_f(
"checking for changes — {done} of {total}", "checking for changes — {done} of {total}",
@@ -763,7 +779,10 @@ fn show_restore_picker(
.active(true) .active(true)
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let row = adw::EntryRow::builder().title(&f.name).build(); let row = adw::EntryRow::builder()
.title(&f.name)
.use_markup(false)
.build();
row.set_text(&f.local_path); row.set_text(&f.local_path);
row.add_prefix(&check); row.add_prefix(&check);
group.add(&row); group.add(&row);
@@ -1093,6 +1112,69 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref()); dialog.present(win.as_ref());
} }
/// Ask before syncing into another computer's device alongside it, then offer
/// its folders for syncing here.
///
/// The same adoption as "Continue This Backup Here", but the other computer
/// goes on syncing, so the dialog says what that means: the two keep the same
/// folders in step, deletions included.
pub(crate) fn prompt_join_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Sync With This Computer?"))
// Translators: {name} is the name of the other computer this one syncs with.
.body(gettext_f(
"This computer and “{name}” keep the same folders in step. A change or a deletion on one reaches the other.\n\nNew synced folders on this computer go under “{name}” in Proton Drive. Folders already synced here stay where they are. Next you choose which of its folders to sync here.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("join", &gettext("Sync Here"));
dialog.set_response_appearance("join", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the other computer this one now syncs with.
let done = gettext_f("Now syncing with “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "join" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AdoptDevice {
uid: Some(uid.clone()),
},
);
let (ui, done) = (ui.clone(), done.clone());
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
let failed = gettext("Couldn't sync with that computer");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The device is this computer's now, so its own restore
// picker offers the folders.
prompt_restore_folders(&ui, None);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
dialog.present(win.as_ref());
}
/// Ask before moving another computer's folders into this computer's backup, /// Ask before moving another computer's folders into this computer's backup,
/// then offer them for syncing here. /// then offer them for syncing here.
/// ///
@@ -1222,6 +1304,37 @@ mod tests {
); );
} }
fn scanning(done: usize, total: usize) -> SyncProgress {
SyncProgress {
phase: SyncPhase::Scanning,
done,
total,
current: String::new(),
}
}
#[test]
fn a_first_scan_counts_what_it_checked() {
// A first sync of a large folder scans for many minutes before anything
// downloads; without a count it reads as stuck (#46).
assert_eq!(
sync_progress_label(&scanning(0, 0)),
"checking for changes…"
);
assert_eq!(
sync_progress_label(&scanning(1, 0)),
"checking for changes — 1 item checked"
);
assert_eq!(
sync_progress_label(&scanning(1234, 0)),
"checking for changes — 1234 items checked"
);
assert_eq!(
sync_progress_label(&scanning(12, 40)),
"checking for changes — 12 of 40"
);
}
#[test] #[test]
fn the_restore_picker_names_the_computer_it_restores_from() { fn the_restore_picker_names_the_computer_it_restores_from() {
assert!(restore_picker_body(Some("laptop")).contains("“laptop”")); assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
+2 -1
View File
@@ -209,7 +209,8 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
view.status.set_icon_name(Some("dialog-warning-symbolic")); view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed); view.status.set_title(&failed);
view.status.set_description(Some(&message)); view.status
.set_description(Some(&gtk4::glib::markup_escape_text(&message)));
view.progress.set_visible(false); view.progress.set_visible(false);
return; return;
} }
+74 -4
View File
@@ -48,6 +48,8 @@ pub(crate) struct SyncCard {
pub(crate) paused: Cell<bool>, pub(crate) paused: Cell<bool>,
/// Shown only while Proton refuses the session; opens the sign-in page. /// Shown only while Proton refuses the session; opens the sign-in page.
pub(crate) sign_in: gtk4::Button, pub(crate) sign_in: gtk4::Button,
/// Shown only while the daemon is older than the app; restarts it.
pub(crate) restart: gtk4::Button,
} }
/// What a folder row's controls are built from. The subtitle and progress bar /// What a folder row's controls are built from. The subtitle and progress bar
@@ -111,6 +113,7 @@ pub(crate) struct LocationsWidgets {
pub(crate) card_title: gtk4::Label, pub(crate) card_title: gtk4::Label,
pub(crate) card_detail: gtk4::Label, pub(crate) card_detail: gtk4::Label,
pub(crate) card_sign_in: gtk4::Button, pub(crate) card_sign_in: gtk4::Button,
pub(crate) card_restart: gtk4::Button,
pub(crate) pause: adw::SplitButton, pub(crate) pause: adw::SplitButton,
pub(crate) queue_group: adw::PreferencesGroup, pub(crate) queue_group: adw::PreferencesGroup,
pub(crate) retry_all: gtk4::Button, pub(crate) retry_all: gtk4::Button,
@@ -137,7 +140,7 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
.build(); .build();
add_folder.add_css_class("flat"); add_folder.add_css_class("flat");
let transfers_group = build_transfers_group(); let transfers_group = build_transfers_group();
let (card, card_icon, card_title, card_detail, card_sign_in) = build_sync_card(); let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
let pause = build_pause_button(); let pause = build_pause_button();
let (queue_group, retry_all) = build_queue_group(); let (queue_group, retry_all) = build_queue_group();
let conflicts_group = adw::PreferencesGroup::builder() let conflicts_group = adw::PreferencesGroup::builder()
@@ -257,6 +260,7 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
card_title, card_title,
card_detail, card_detail,
card_sign_in, card_sign_in,
card_restart,
pause, pause,
queue_group, queue_group,
retry_all, retry_all,
@@ -299,6 +303,11 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk
.card .card
.sign_in .sign_in
.connect_clicked(move |_| show_login(&ui_sign_in)); .connect_clicked(move |_| show_login(&ui_sign_in));
let ui_restart = ui.clone();
ui.locations
.card
.restart
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
let ui_retry = ui.clone(); let ui_retry = ui.clone();
ui.locations ui.locations
.queue .queue
@@ -396,6 +405,7 @@ fn build_sync_card() -> (
gtk4::Label, gtk4::Label,
gtk4::Label, gtk4::Label,
gtk4::Button, gtk4::Button,
gtk4::Button,
) { ) {
let icon = gtk4::Image::builder() let icon = gtk4::Image::builder()
.icon_name("pdfs-sync-symbolic") .icon_name("pdfs-sync-symbolic")
@@ -430,10 +440,18 @@ fn build_sync_card() -> (
.build(); .build();
sign_in.add_css_class("pill"); sign_in.add_css_class("pill");
sign_in.add_css_class("suggested-action"); sign_in.add_css_class("suggested-action");
let restart = gtk4::Button::builder()
.label(gettext("Restart"))
.valign(gtk4::Align::Center)
.visible(false)
.build();
restart.add_css_class("pill");
restart.add_css_class("suggested-action");
card.append(&icon); card.append(&icon);
card.append(&text); card.append(&text);
card.append(&sign_in); card.append(&sign_in);
(card, icon, title, detail, sign_in) card.append(&restart);
(card, icon, title, detail, sign_in, restart)
} }
/// Pause/Resume, with timed pauses in its menu. /// Pause/Resume, with timed pauses in its menu.
@@ -1127,7 +1145,9 @@ pub(crate) fn locations_status(
) { ) {
ui.locations.status.set_icon_name(Some(icon)); ui.locations.status.set_icon_name(Some(icon));
ui.locations.status.set_title(title); ui.locations.status.set_title(title);
ui.locations.status.set_description(Some(description)); ui.locations
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.locations.retry.set_visible(retry); ui.locations.retry.set_visible(retry);
ui.locations.content.set_visible_child_name("status"); ui.locations.content.set_visible_child_name("status");
} }
@@ -1486,8 +1506,13 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
} else { } else {
gettext("Pause Syncing") gettext("Pause Syncing")
}; };
let ui_pause = ui.clone();
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
let ui = ui.clone(); let ui = ui.clone();
menu.item(&label, move || set_folder_paused(&ui, id, !paused)); let path = spec.local_path.clone();
menu.item(&gettext("Sync From Scratch…"), move || {
confirm_reset_sync_folder(&ui, id, &path)
});
menu.section(); menu.section();
} }
let ui = ui.clone(); let ui = ui.clone();
@@ -1500,6 +1525,51 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
}); });
} }
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
/// that differ come back as conflict copies, so it is not a click to make by
/// accident.
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Sync From Scratch?"))
// Translators: {path} is a local folder path.
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("reset", &gettext("Sync From Scratch"));
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let win = ui_window(ui);
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "reset" {
reset_sync_folder(&ui, id);
}
});
dialog.present(win.as_ref());
}
/// Forget a folder's sync records, then repaint so its row shows the new pass.
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
let ui = ui.clone();
glib::spawn_future_local(async move {
let failed = gettext("Couldn't sync the folder from scratch");
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
refresh_locations(&ui);
});
}
/// Ask before making a synced folder online only: the switch frees disk space /// Ask before making a synced folder online only: the switch frees disk space
/// by removing the local copies, which is not something to do by accident. /// by removing the local copies, which is not something to do by accident.
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) { fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
+108 -2
View File
@@ -9,6 +9,9 @@ pub(crate) struct LoginState {
/// Spins inside the Sign in button while a sign-in is running. /// Spins inside the Sign in button while a sign-in is running.
pub(crate) login_spinner: Spinner, pub(crate) login_spinner: Spinner,
pub(crate) login_status: gtk4::Label, pub(crate) login_status: gtk4::Label,
/// Shown over the form while a saved sign-in exists but the keyring would
/// not hand it over; see [`keyring_unreadable`].
pub(crate) keyring_banner: adw::Banner,
} }
/// Where the login page's account links go. /// Where the login page's account links go.
@@ -87,6 +90,12 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
.child(&inner) .child(&inner)
.build(); .build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
let keyring_banner = adw::Banner::builder()
.title(gettext(
"Couldn't read your saved sign-in. Unlock the system keyring, then try again.",
))
.button_label(gettext("Retry"))
.build();
( (
scroll.upcast(), scroll.upcast(),
@@ -96,10 +105,80 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
login_button, login_button,
login_spinner, login_spinner,
login_status, login_status,
keyring_banner,
}, },
) )
} }
/// The stored session, `None` when there is none, or the keyring's error when
/// it could not be read. A locked or absent keyring is not a sign-out: the
/// session is still saved, and signing in again would only replace it.
pub(crate) fn read_session() -> pdfs_core::Result<Option<auth::StoredSession>> {
match auth::load() {
Ok(session) => Ok(Some(session)),
Err(pdfs_core::Error::NotLoggedIn) => Ok(None),
Err(e) => Err(e),
}
}
/// How long to wait before each automatic retry of an unreadable keyring. A
/// keyring daemon started with the session is usually up within seconds.
const KEYRING_RETRIES: [u32; 4] = [2, 5, 10, 30];
/// The keyring refused to hand over the saved session. Keep the login page up,
/// but say why, and pick the session up as soon as the keyring answers: on its
/// own a few times, and whenever the user presses Retry.
pub(crate) fn keyring_unreadable(ui: &Rc<Ui>, error: &pdfs_core::Error) {
tracing::warn!("cannot read the saved session: {error}");
let banner = &ui.login.keyring_banner;
if banner.is_revealed() {
return;
}
banner.set_revealed(true);
let ui_retry = ui.clone();
let handler = banner.connect_button_clicked(move |_| retry_session(&ui_retry));
// Disconnected once the banner goes, so a later failure wires it afresh.
let handler = RefCell::new(Some(handler));
banner.connect_revealed_notify(move |banner| {
if !banner.is_revealed()
&& let Some(handler) = handler.borrow_mut().take()
{
banner.disconnect(handler);
}
});
let ui = ui.clone();
glib::spawn_future_local(async move {
for seconds in KEYRING_RETRIES {
glib::timeout_future_seconds(seconds).await;
if !ui.login.keyring_banner.is_revealed() {
return;
}
retry_session(&ui);
}
});
}
/// Read the saved session again after [`keyring_unreadable`]. Found: show the
/// signed-in window. Gone: the plain login page. Still unreadable: keep waiting.
fn retry_session(ui: &Rc<Ui>) {
let ui = ui.clone();
glib::spawn_future_local(async move {
let Ok(result) = gio::spawn_blocking(read_session).await else {
return;
};
match result {
Ok(session) => {
ui.login.keyring_banner.set_revealed(false);
if session.is_some() && ui.session.borrow().is_none() {
*ui.session.borrow_mut() = session;
refresh(&ui);
}
}
Err(e) => tracing::debug!("the keyring still cannot be read: {e}"),
}
});
}
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker /// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
/// thread, and report the outcome back on the main loop. /// thread, and report the outcome back on the main loop.
pub(crate) fn wire_login(ui: &Rc<Ui>) { pub(crate) fn wire_login(ui: &Rc<Ui>) {
@@ -199,7 +278,14 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
ui.login.login_status.set_text(""); ui.login.login_status.set_text("");
ui.login.password.set_text(""); ui.login.password.set_text("");
// Cache the new identity so `refresh` never hits the keyring. // Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok(); // Read on a worker: a keyring that has to be unlocked first
// answers only once the user has, and the window must not
// freeze until then.
match gio::spawn_blocking(read_session).await {
Ok(Ok(session)) => *ui.session.borrow_mut() = session,
Ok(Err(e)) => keyring_unreadable(&ui, &e),
Err(_) => {}
}
// Enable+start the mount service now that we have a session. // Enable+start the mount service now that we have a session.
let _ = gio::spawn_blocking(service::enable_start).await; let _ = gio::spawn_blocking(service::enable_start).await;
refresh(&ui); refresh(&ui);
@@ -227,6 +313,11 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => { pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
gettext("Too many sign-in attempts. Wait a few minutes and try again.") gettext("Too many sign-in attempts. Wait a few minutes and try again.")
} }
// Proton's text for this one asks the user to update an app that is
// already current.
pdfs_core::Error::Proton(e) if pdfs_core::error::sign_in_blocked(e) => gettext(
"Proton is temporarily refusing sign-in from this device. Your app version is not the problem. Wait several hours, up to a day, without trying again, then sign in.",
),
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => { pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
api.message.clone() api.message.clone()
} }
@@ -597,7 +688,7 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = gio::spawn_blocking(|| { let result = gio::spawn_blocking(|| {
service::disable_stop(); service::disable_stop();
auth::logout().map_err(|e| e.to_string()) auth::logout().map(|_| ()).map_err(|e| e.to_string())
}) })
.await; .await;
if let Ok(Err(e)) = result { if let Ok(Err(e)) = result {
@@ -628,6 +719,21 @@ mod tests {
assert!(login_error_message(&error).contains("no mailbox password")); assert!(login_error_message(&error).contains("no mailbox password"));
} }
#[test]
fn a_blocked_sign_in_does_not_ask_for_an_update() {
use pdfs_core::proton_sdk::api::ResponseCode;
use pdfs_core::proton_sdk::{ProtonApiError, ProtonError};
let error = pdfs_core::Error::Proton(ProtonError::Api(ProtonApiError {
code: ResponseCode::Blocked,
http_status: 422,
message: "This version of the app is no longer supported, please update to continue using the app".into(),
details: None,
}));
let message = login_error_message(&error);
assert!(message.contains("temporarily refusing"), "{message}");
assert!(!message.contains("update"), "{message}");
}
#[test] #[test]
fn an_unmapped_error_keeps_its_text() { fn an_unmapped_error_keeps_its_text() {
let error = pdfs_core::Error::Other("something odd".into()); let error = pdfs_core::Error::Other("something odd".into());
+168 -47
View File
@@ -275,14 +275,42 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.status.set_label(message); viewer.status.set_label(message);
viewer.status.set_visible(true); viewer.status.set_visible(true);
}; };
match result { let path = match result {
Ok(Ok(Response::FilePath { path })) if is_video => { Ok(Ok(Response::FilePath { path })) => path,
Ok(Ok(Response::Error { message, .. })) => return fail(&message),
Ok(Ok(_)) => return fail(&gettext("Unexpected reply from the Proton Drive service.")),
Ok(Err(_)) | Err(_) => return fail(&gettext("Couldn't reach Proton Drive.")),
};
// Decoding a full-size photo and reading its EXIF take a good part of
// a second for a large one, which froze the whole app on the main
// thread. Done on a worker; a reply for a photo the user has since
// left is dropped like the request's own.
let loaded = {
let path = path.clone();
gio::spawn_blocking(move || {
let size = std::fs::metadata(&path).ok().map(|meta| meta.len());
if is_video {
return (size, None, ExifInfo::default());
}
let texture = gtk4::gdk::Texture::from_filename(&path);
(size, Some(texture), read_exif(&path))
})
.await
};
if *viewer.uid.borrow() != uid {
return;
}
let Ok((size, texture, info)) = loaded else {
return fail(&gettext("Couldn't render this photo."));
};
match texture {
None => {
viewer.video.set_filename(Some(&path)); viewer.video.set_filename(Some(&path));
viewer.media.set_visible_child_name("video"); viewer.media.set_visible_child_name("video");
*viewer.path.borrow_mut() = Some(path.clone()); *viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, &path, ExifInfo::default()); show_info(&viewer, size, ExifInfo::default());
} }
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { Some(texture) => match texture {
Ok(texture) => { Ok(texture) => {
// Zoomed in on the thumbnail: keep the photo the size it is // Zoomed in on the thumbnail: keep the photo the size it is
// on screen, now drawn from the full-size pixels. // on screen, now drawn from the full-size pixels.
@@ -297,16 +325,13 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
zoom_to(&viewer, Some(zoom), None); zoom_to(&viewer, Some(zoom), None);
} }
*viewer.path.borrow_mut() = Some(path.clone()); *viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, &path, read_exif(&path)); show_info(&viewer, size, info);
} }
Err(e) => { Err(e) => {
tracing::error!("Failed to load texture for {path}: {e}"); tracing::error!("Failed to load texture for {path}: {e}");
fail(&gettext("Couldn't render this photo.")); fail(&gettext("Couldn't render this photo."));
} }
}, },
Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
} }
}); });
} }
@@ -452,16 +477,16 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
} }
/// Fill the details drawer for the photo now on screen: its own file facts /// Fill the details drawer for the photo now on screen: its own file facts
/// (size, dimensions) plus whatever EXIF it carries. /// (`size`, dimensions) plus whatever EXIF it carries.
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) { pub(crate) fn show_info(viewer: &Rc<Viewer>, size: Option<u64>, info: ExifInfo) {
while let Some(row) = viewer.info_rows.first_child() { while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row); viewer.info_rows.remove(&row);
} }
let mut fields: Vec<(String, String)> = Vec::new(); let mut fields: Vec<(String, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) { if let Some(size) = size {
// Translators: label of a photo's file size in the details panel. // Translators: label of a photo's file size in the details panel.
fields.push((pgettext("property", "Size"), human_bytes(meta.len()))); fields.push((pgettext("property", "Size"), human_bytes(size)));
} }
fields.extend(info.fields); fields.extend(info.fields);
@@ -481,6 +506,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(label.as_str()) .title(label.as_str())
.subtitle(value) .subtitle(value)
.use_markup(false)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
row.add_css_class("property"); row.add_css_class("property");
@@ -1259,8 +1285,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// Any real pointer movement brings the controls back; resting over one of // Any real pointer movement brings the controls back; resting over one of
// them keeps them up. // them keeps them up.
let motion = gtk4::EventControllerMotion::new(); let motion = gtk4::EventControllerMotion::new();
let viewer_motion = viewer.clone(); let viewer_motion = Rc::downgrade(&viewer);
motion.connect_motion(move |_, x, y| { motion.connect_motion(move |_, x, y| {
let Some(viewer_motion) = viewer_motion.upgrade() else {
return;
};
let (last_x, last_y) = viewer_motion.pointer.get(); let (last_x, last_y) = viewer_motion.pointer.get();
viewer_motion.pointer.set((x, y)); viewer_motion.pointer.set((x, y));
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 { if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
@@ -1270,10 +1299,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
body.add_controller(motion); body.add_controller(motion);
for revealer in [&top_reveal, &prev_reveal, &next_reveal] { for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
let hover = gtk4::EventControllerMotion::new(); let hover = gtk4::EventControllerMotion::new();
let viewer_enter = viewer.clone(); let viewer_enter = Rc::downgrade(&viewer);
hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true)); hover.connect_enter(move |_, _, _| {
let viewer_leave = viewer.clone(); if let Some(viewer) = viewer_enter.upgrade() {
viewer.chrome_hover.set(true);
}
});
let viewer_leave = Rc::downgrade(&viewer);
hover.connect_leave(move |_| { hover.connect_leave(move |_| {
let Some(viewer_leave) = viewer_leave.upgrade() else {
return;
};
viewer_leave.chrome_hover.set(false); viewer_leave.chrome_hover.set(false);
show_chrome(&viewer_leave); show_chrome(&viewer_leave);
}); });
@@ -1283,8 +1319,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo. // Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture); wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
let viewer_wheel = viewer.clone(); let viewer_wheel = Rc::downgrade(&viewer);
wheel.connect_scroll(move |controller, _, dy| { wheel.connect_scroll(move |controller, _, dy| {
let Some(viewer_wheel) = viewer_wheel.upgrade() else {
return glib::Propagation::Proceed;
};
if !controller if !controller
.current_event_state() .current_event_state()
.contains(gtk4::gdk::ModifierType::CONTROL_MASK) .contains(gtk4::gdk::ModifierType::CONTROL_MASK)
@@ -1304,14 +1343,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
scroller.add_controller(wheel); scroller.add_controller(wheel);
let pinch = gtk4::GestureZoom::new(); let pinch = gtk4::GestureZoom::new();
let viewer_pinch = viewer.clone(); let viewer_pinch = Rc::downgrade(&viewer);
pinch.connect_begin(move |_, _| { pinch.connect_begin(move |_, _| {
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
return;
};
viewer_pinch viewer_pinch
.pinch_start .pinch_start
.set(current_scale(&viewer_pinch).unwrap_or(1.0)); .set(current_scale(&viewer_pinch).unwrap_or(1.0));
}); });
let viewer_pinch = viewer.clone(); let viewer_pinch = Rc::downgrade(&viewer);
pinch.connect_scale_changed(move |gesture, delta| { pinch.connect_scale_changed(move |gesture, delta| {
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
return;
};
let start = viewer_pinch.pinch_start.get(); let start = viewer_pinch.pinch_start.get();
zoom_to( zoom_to(
&viewer_pinch, &viewer_pinch,
@@ -1323,8 +1368,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// Dragging a zoomed photo pans it. // Dragging a zoomed photo pans it.
let drag = gtk4::GestureDrag::new(); let drag = gtk4::GestureDrag::new();
let viewer_drag = viewer.clone(); let viewer_drag = Rc::downgrade(&viewer);
drag.connect_drag_begin(move |_, _, _| { drag.connect_drag_begin(move |_, _, _| {
let Some(viewer_drag) = viewer_drag.upgrade() else {
return;
};
let scroller = &viewer_drag.scroller; let scroller = &viewer_drag.scroller;
viewer_drag.drag_start.set(( viewer_drag.drag_start.set((
scroller.hadjustment().value(), scroller.hadjustment().value(),
@@ -1334,8 +1382,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
viewer_drag.picture.set_cursor_from_name(Some("grabbing")); viewer_drag.picture.set_cursor_from_name(Some("grabbing"));
} }
}); });
let viewer_drag = viewer.clone(); let viewer_drag = Rc::downgrade(&viewer);
drag.connect_drag_update(move |_, dx, dy| { drag.connect_drag_update(move |_, dx, dy| {
let Some(viewer_drag) = viewer_drag.upgrade() else {
return;
};
if viewer_drag.zoom.get().is_none() { if viewer_drag.zoom.get().is_none() {
return; return;
} }
@@ -1343,39 +1394,58 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
viewer_drag.scroller.hadjustment().set_value(x - dx); viewer_drag.scroller.hadjustment().set_value(x - dx);
viewer_drag.scroller.vadjustment().set_value(y - dy); viewer_drag.scroller.vadjustment().set_value(y - dy);
}); });
let viewer_drag = viewer.clone(); let viewer_drag = Rc::downgrade(&viewer);
drag.connect_drag_end(move |_, _, _| { drag.connect_drag_end(move |_, _, _| {
let Some(viewer_drag) = viewer_drag.upgrade() else {
return;
};
if viewer_drag.zoom.get().is_some() { if viewer_drag.zoom.get().is_some() {
viewer_drag.picture.set_cursor_from_name(Some("grab")); viewer_drag.picture.set_cursor_from_name(Some("grab"));
} }
}); });
scroller.add_controller(drag); scroller.add_controller(drag);
let w_close = window.clone(); let w_close = window.downgrade();
close_btn.connect_clicked(move |_| { close_btn.connect_clicked(move |_| {
let Some(w_close) = w_close.upgrade() else {
return;
};
w_close.close(); w_close.close();
}); });
let viewer_closing = viewer.clone(); // 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()));
window.connect_close_request(move |_| { window.connect_close_request(move |_| {
stop_video(&viewer_closing); if let Some(viewer) = keep.borrow_mut().take() {
disarm_slide(&viewer_closing); stop_video(&viewer);
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() { disarm_slide(&viewer);
source.remove(); if let Some(source) = viewer.chrome_source.borrow_mut().take() {
source.remove();
}
viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE);
} }
glib::Propagation::Proceed glib::Propagation::Proceed
}); });
let viewer_info = viewer.clone(); let viewer_info = Rc::downgrade(&viewer);
info_toggle.connect_toggled(move |toggle| { info_toggle.connect_toggled(move |toggle| {
let Some(viewer_info) = viewer_info.upgrade() else {
return;
};
viewer_info viewer_info
.info_revealer .info_revealer
.set_reveal_child(toggle.is_active()); .set_reveal_child(toggle.is_active());
}); });
let ui_slides = ui.clone(); let ui_slides = ui.clone();
let viewer_slides = viewer.clone(); let viewer_slides = Rc::downgrade(&viewer);
slideshow_btn.connect_toggled(move |toggle| { slideshow_btn.connect_toggled(move |toggle| {
let Some(viewer_slides) = viewer_slides.upgrade() else {
return;
};
if toggle.is_active() { if toggle.is_active() {
start_slideshow(&ui_slides, &viewer_slides); start_slideshow(&ui_slides, &viewer_slides);
} else { } else {
@@ -1387,9 +1457,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_close.connect_clicked(move |_| toggle_off.set_active(false)); info_close.connect_clicked(move |_| toggle_off.set_active(false));
let ui_map = ui.clone(); let ui_map = ui.clone();
let viewer_map = viewer.clone(); let viewer_map = Rc::downgrade(&viewer);
let w_map = window.clone(); let w_map = window.downgrade();
info_map.connect_clicked(move |_| { 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(); let coords = *viewer_map.coords.borrow();
if let Some((lat, lon)) = coords { if let Some((lat, lon)) = coords {
w_map.close(); w_map.close();
@@ -1398,8 +1474,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}); });
let ui_fav = ui.clone(); let ui_fav = ui.clone();
let viewer_fav = viewer.clone(); let viewer_fav = Rc::downgrade(&viewer);
favorite_btn.connect_toggled(move |btn| { favorite_btn.connect_toggled(move |btn| {
let Some(viewer_fav) = viewer_fav.upgrade() else {
return;
};
if viewer_fav.favorite_suppress.get() { if viewer_fav.favorite_suppress.get() {
return; return;
} }
@@ -1443,17 +1522,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// gone, and leaving it open on a trashed photo invites a second Delete on // gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see. // something the user cannot see.
let ui_delete = ui.clone(); let ui_delete = ui.clone();
let viewer_delete = viewer.clone(); let viewer_delete = Rc::downgrade(&viewer);
let w_delete = window.clone(); let w_delete = window.downgrade();
delete_btn.connect_clicked(move |_| { 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(); let uid = viewer_delete.uid.borrow().clone();
w_delete.close(); w_delete.close();
trash_photos(&ui_delete, vec![uid], false); trash_photos(&ui_delete, vec![uid], false);
}); });
let ui_group = ui.clone(); let ui_group = ui.clone();
let viewer_group = viewer.clone(); let viewer_group = Rc::downgrade(&viewer);
group_btn.connect_clicked(move |_| { group_btn.connect_clicked(move |_| {
let Some(viewer_group) = viewer_group.upgrade() else {
return;
};
let uid = viewer_group.uid.borrow().clone(); let uid = viewer_group.uid.borrow().clone();
let next = let next =
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone()); next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
@@ -1464,9 +1552,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
}); });
let ui_download = ui.clone(); let ui_download = ui.clone();
let w_download = window.clone(); let w_download = window.downgrade();
let viewer_download = viewer.clone(); let viewer_download = Rc::downgrade(&viewer);
download_btn.connect_clicked(move |_| { 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() { if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = viewer_download.name.borrow(); let name = viewer_download.name.borrow();
save_photo_to_disk( save_photo_to_disk(
@@ -1478,23 +1572,35 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
} }
}); });
let w_ext = window.clone(); let w_ext = window.downgrade();
let viewer_ext = viewer.clone(); let viewer_ext = Rc::downgrade(&viewer);
open_ext_btn.connect_clicked(move |_| { 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() { if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref()); open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
} }
}); });
let ui_prev = ui.clone(); let ui_prev = ui.clone();
let viewer_prev = viewer.clone(); let viewer_prev = Rc::downgrade(&viewer);
prev_btn.connect_clicked(move |_| { prev_btn.connect_clicked(move |_| {
let Some(viewer_prev) = viewer_prev.upgrade() else {
return;
};
navigate_photo(&ui_prev, &viewer_prev, -1); navigate_photo(&ui_prev, &viewer_prev, -1);
}); });
let ui_next = ui.clone(); let ui_next = ui.clone();
let viewer_next = viewer.clone(); let viewer_next = Rc::downgrade(&viewer);
next_btn.connect_clicked(move |_| { next_btn.connect_clicked(move |_| {
let Some(viewer_next) = viewer_next.upgrade() else {
return;
};
navigate_photo(&ui_next, &viewer_next, 1); navigate_photo(&ui_next, &viewer_next, 1);
}); });
@@ -1503,8 +1609,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
// reaching for the image doesn't fling the window shut; a zoomed photo has // reaching for the image doesn't fling the window shut; a zoomed photo has
// no backdrop to speak of. Double-click toggles between fitted and 100 %. // no backdrop to speak of. Double-click toggles between fitted and 100 %.
let backdrop = gtk4::GestureClick::new(); let backdrop = gtk4::GestureClick::new();
let viewer_click = viewer.clone(); let viewer_click = Rc::downgrade(&viewer);
backdrop.connect_pressed(move |_, n_press, x, y| { backdrop.connect_pressed(move |_, n_press, x, y| {
let Some(viewer_click) = viewer_click.upgrade() else {
return;
};
if n_press != 2 { if n_press != 2 {
return; return;
} }
@@ -1521,9 +1630,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor)); zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
} }
}); });
let viewer_click = viewer.clone(); let viewer_click = Rc::downgrade(&viewer);
let w_click = window.clone(); let w_click = window.downgrade();
backdrop.connect_released(move |_, n_press, x, y| { backdrop.connect_released(move |_, n_press, x, y| {
let Some(w_click) = w_click.upgrade() else {
return;
};
let Some(viewer_click) = viewer_click.upgrade() else {
return;
};
if n_press == 1 if n_press == 1
&& viewer_click.zoom.get().is_none() && viewer_click.zoom.get().is_none()
&& !over_photo(&viewer_click.picture, x, y) && !over_photo(&viewer_click.picture, x, y)
@@ -1535,9 +1650,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let key_controller = gtk4::EventControllerKey::new(); let key_controller = gtk4::EventControllerKey::new();
let ui_key = ui.clone(); let ui_key = ui.clone();
let viewer_key = viewer.clone(); let viewer_key = Rc::downgrade(&viewer);
let w_key = window.clone(); let w_key = window.downgrade();
key_controller.connect_key_pressed(move |_, key, _keycode, state| { key_controller.connect_key_pressed(move |_, key, _keycode, state| {
let Some(w_key) = w_key.upgrade() else {
return glib::Propagation::Proceed;
};
let Some(viewer_key) = viewer_key.upgrade() else {
return glib::Propagation::Proceed;
};
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video"); let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
match key.name().as_deref() { match key.name().as_deref() {
+19 -10
View File
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
pub(crate) loading: Cell<bool>, pub(crate) loading: Cell<bool>,
/// Whether the last page came back full, so there may be more to load. /// Whether the last page came back full, so there may be more to load.
pub(crate) has_more: Cell<bool>, pub(crate) has_more: Cell<bool>,
/// Whether the account has a photo library. Until the daemon says it has
/// none, assume it does, so Upload is not greyed out while the page loads.
pub(crate) library: Cell<bool>,
/// Run once the page in flight has landed in [`Self::model`] — how the /// Run once the page in flight has landed in [`Self::model`] — how the
/// lightbox steps past the last loaded photo. /// lightbox steps past the last loaded photo.
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>, pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
@@ -606,7 +609,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
select_box.append(&select_done); select_box.append(&select_done);
let select_bar = gtk4::Revealer::builder() let select_bar = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown) .transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&select_box) .child(&scroll_sideways(&select_box))
.build(); .build();
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
@@ -1376,11 +1379,9 @@ pub(crate) fn wire_gallery(
"tiff" | "tif" => "image/tiff", "tiff" | "tif" => "image/tiff",
_ => "image/jpeg", _ => "image/jpeg",
}; };
let capture_time = std::fs::metadata(&path) // The daemon dates the photo: by its EXIF, or by the file's
.and_then(|m| m.modified()) // modification time when it has none.
.ok() let capture_time = None;
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
ui.busy_begin(); ui.busy_begin();
let rx = spawn_request( let rx = spawn_request(
@@ -3341,6 +3342,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
items, items,
counts, counts,
})) => { })) => {
// Nothing can go into a library the account does not have; the
// SDK refuses the upload before sending it (issue #38).
ui.gallery.library.set(available);
ui.gallery
.upload
.set_sensitive(available && *ui.mounted.borrow());
if !available { if !available {
gallery_status( gallery_status(
&ui, &ui,
@@ -3450,7 +3457,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) { pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.gallery.status.set_icon_name(Some(icon)); ui.gallery.status.set_icon_name(Some(icon));
ui.gallery.status.set_title(title); ui.gallery.status.set_title(title);
ui.gallery.status.set_description(Some(description)); ui.gallery
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.retry.set_visible(retry); ui.gallery.retry.set_visible(retry);
// Only the empty timeline offers a way to fill it; every other status turns // Only the empty timeline offers a way to fill it; every other status turns
// those buttons back off. // those buttons back off.
@@ -3470,10 +3479,10 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the /// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
/// user's default handler when mpv isn't installed. /// user's default handler when mpv isn't installed.
pub(crate) fn play_external(path: &str) { pub(crate) fn play_external(path: &str) {
if Command::new("mpv").arg(path).spawn().is_ok() { match Command::new("mpv").arg(path).spawn() {
return; Ok(child) => pdfs_core::opener::reap(child),
Err(_) => open_path(path),
} }
open_path(path);
} }
#[cfg(test)] #[cfg(test)]
+3 -1
View File
@@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) { fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
ui.gallery.places_status.set_icon_name(Some(icon)); ui.gallery.places_status.set_icon_name(Some(icon));
ui.gallery.places_status.set_title(title); ui.gallery.places_status.set_title(title);
ui.gallery.places_status.set_description(Some(description)); ui.gallery
.places_status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.gallery.places_stack.set_visible_child_name("status"); ui.gallery.places_stack.set_visible_child_name("status");
} }
+5 -1
View File
@@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) { pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.shared.status.set_icon_name(Some(icon)); ui.shared.status.set_icon_name(Some(icon));
ui.shared.status.set_title(title); ui.shared.status.set_title(title);
ui.shared.status.set_description(Some(description)); ui.shared
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.shared.retry.set_visible(retry); ui.shared.retry.set_visible(retry);
ui.shared.content.set_visible_child_name("status"); ui.shared.content.set_visible_child_name("status");
} }
@@ -818,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow {
.title(&item) .title(&item)
// Translators: {email} is the address of the person who sent the invitation. // Translators: {email} is the address of the person who sent the invitation.
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)])) .subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
.use_markup(false)
.build(); .build();
row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir { row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir {
"folder-symbolic" "folder-symbolic"
@@ -862,6 +865,7 @@ fn repaint_links(ui: &Rc<Ui>, bookmarks: &[BookmarkInfo]) {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&title) .title(&title)
.subtitle(&bm.url) .subtitle(&bm.url)
.use_markup(false)
.subtitle_lines(1) .subtitle_lines(1)
.activatable(true) .activatable(true)
.tooltip_text(gettext("Open in browser")) .tooltip_text(gettext("Open in browser"))
@@ -329,7 +329,9 @@ pub(crate) fn shared_by_me_status(
) { ) {
ui.shared_by_me.status.set_icon_name(Some(icon)); ui.shared_by_me.status.set_icon_name(Some(icon));
ui.shared_by_me.status.set_title(title); ui.shared_by_me.status.set_title(title);
ui.shared_by_me.status.set_description(Some(description)); ui.shared_by_me
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.shared_by_me.retry.set_visible(retry); ui.shared_by_me.retry.set_visible(retry);
ui.shared_by_me.content.set_visible_child_name("status"); ui.shared_by_me.content.set_visible_child_name("status");
ui.shared_by_me.subtitle.set_subtitle(""); ui.shared_by_me.subtitle.set_subtitle("");
@@ -622,6 +624,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
size: 0, size: 0,
modified: item.modified, modified: item.modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: item.uid.clone(), uid: item.uid.clone(),
path: item.path.clone(), path: item.path.clone(),
+72 -3
View File
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
pub(crate) online_map_row: adw::SwitchRow, pub(crate) online_map_row: adw::SwitchRow,
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`]. /// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
pub(crate) tray_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow,
/// "Check for updates" toggle, not shown where a package manager brings
/// updates. Guarded by [`Self::settings_suppress`].
pub(crate) update_row: adw::SwitchRow,
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by /// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
/// [`Self::settings_suppress`]. /// [`Self::settings_suppress`].
pub(crate) language_row: adw::ComboRow, pub(crate) language_row: adw::ComboRow,
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
pub(crate) theme_row: adw::SwitchRow, pub(crate) theme_row: adw::SwitchRow,
pub(crate) online_map_row: adw::SwitchRow, pub(crate) online_map_row: adw::SwitchRow,
pub(crate) tray_row: adw::SwitchRow, pub(crate) tray_row: adw::SwitchRow,
pub(crate) update_row: adw::SwitchRow,
pub(crate) language_row: adw::ComboRow, pub(crate) language_row: adw::ComboRow,
} }
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
let language_row = build_language_row(); let language_row = build_language_row();
appearance_group.add(&language_row); appearance_group.add(&language_row);
let updates_group = adw::PreferencesGroup::builder()
.title(gettext("Updates"))
.build();
let update_row = adw::SwitchRow::builder()
.title(gettext("Check for updates"))
.subtitle(gettext(
"Ask GitHub once a day whether a new version is out. GitHub learns your IP address and which version you use.",
))
.build();
updates_group.add(&update_row);
let general = adw::PreferencesPage::builder() let general = adw::PreferencesPage::builder()
.title(gettext("General")) .title(gettext("General"))
.icon_name("preferences-system-symbolic") .icon_name("preferences-system-symbolic")
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
general.add(&location_group); general.add(&location_group);
general.add(&network_group); general.add(&network_group);
general.add(&appearance_group); general.add(&appearance_group);
// A package manager brings new versions, so there is nothing to tell.
if !updates_managed_here() {
general.add(&updates_group);
}
// ---- Preferences: Storage // ---- Preferences: Storage
let storage_group = adw::PreferencesGroup::builder() let storage_group = adw::PreferencesGroup::builder()
@@ -442,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
theme_row, theme_row,
online_map_row, online_map_row,
tray_row, tray_row,
update_row,
language_row, language_row,
} }
} }
@@ -541,6 +561,7 @@ pub(crate) fn wire_settings(
.set_active(config.proton_theme.unwrap_or(false)); .set_active(config.proton_theme.unwrap_or(false));
ui.status.online_map_row.set_active(config.online_map); ui.status.online_map_row.set_active(config.online_map);
ui.status.tray_row.set_active(!config.tray_hidden); ui.status.tray_row.set_active(!config.tray_hidden);
ui.status.update_row.set_active(config.update_check);
ui.status ui.status
.language_row .language_row
.set_selected(language_index(config.language.as_deref())); .set_selected(language_index(config.language.as_deref()));
@@ -690,6 +711,26 @@ pub(crate) fn wire_settings(
} }
}); });
// Update check: saved, and the first check runs right away if one is due.
let ui_update = ui.clone();
ui.status.update_row.connect_active_notify(move |row| {
if ui_update.status.settings_suppress.get() {
return;
}
let mut config = ui_update.dirs.load_config();
config.update_check = row.is_active();
if let Err(e) = ui_update.dirs.save_config(&config) {
toast_error(
&ui_update,
&gettext("Couldn't save the update setting"),
&e.to_string(),
);
return;
}
show_latest(&ui_update);
start_update_checks(&ui_update);
});
// Tray icon: the tray reads the same flag at start, so hiding it here also // Tray icon: the tray reads the same flag at start, so hiding it here also
// keeps the login autostart from bringing it back. // keeps the login autostart from bringing it back.
let ui_tray = ui.clone(); let ui_tray = ui.clone();
@@ -860,7 +901,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
if ui.stack.visible_child_name().as_deref() == Some("login") { if ui.stack.visible_child_name().as_deref() == Some("login") {
ui.stack.set_visible_child_name("browser"); ui.stack.set_visible_child_name("browser");
} }
ui.nav.set_collapsed(false); ui.nav.set_collapsed(ui.narrow.get());
if ui.status.account_name.label() != s.username { if ui.status.account_name.label() != s.username {
ui.status.account_name.set_label(&s.username); ui.status.account_name.set_label(&s.username);
ui.status.account_name.set_tooltip_text(Some(&s.username)); ui.status.account_name.set_tooltip_text(Some(&s.username));
@@ -958,7 +999,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) { pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted; *ui.mounted.borrow_mut() = mounted;
sync_mounted_actions(ui, mounted); sync_mounted_actions(ui, mounted);
ui.gallery.upload.set_sensitive(mounted); ui.gallery
.upload
.set_sensitive(mounted && ui.gallery.library.get());
ui.details.details.pin_row.set_sensitive(mounted); ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted); ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted); ui.details.details.trash_button.set_sensitive(mounted);
@@ -1230,6 +1273,7 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
paused_until, paused_until,
session_expired, session_expired,
tokens_unsaved, tokens_unsaved,
version,
.. ..
} = status } = status
else { else {
@@ -1244,6 +1288,8 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
SyncState::SessionExpired { queued } SyncState::SessionExpired { queued }
} else if tokens_unsaved { } else if tokens_unsaved {
SyncState::TokensUnsaved SyncState::TokensUnsaved
} else if pdfs_core::control::daemon_outdated(&version) {
SyncState::Outdated { running: version }
} else if paused { } else if paused {
SyncState::Paused { SyncState::Paused {
until: paused_until, until: paused_until,
@@ -1315,6 +1361,12 @@ pub(crate) enum SyncState {
/// The keyring refuses the session's refreshed tokens. Sync goes on, but /// The keyring refuses the session's refreshed tokens. Sync goes on, but
/// a reboot before the keyring takes them signs the user out. /// a reboot before the keyring takes them signs the user out.
TokensUnsaved, TokensUnsaved,
/// The daemon is an older release than this window: an upgrade replaced
/// the files but left the service running, with the old release's bugs,
/// until it restarts (#33). `running` is empty when it is too old to say.
Outdated {
running: String,
},
/// The user paused syncing; nothing goes up until it resumes. Outranks /// The user paused syncing; nothing goes up until it resumes. Outranks
/// everything else, because it is the reason for everything else. /// everything else, because it is the reason for everything else.
Paused { Paused {
@@ -1350,6 +1402,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
let paused = matches!(state, SyncState::Paused { .. }); let paused = matches!(state, SyncState::Paused { .. });
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding); let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
let expired = matches!(state, SyncState::SessionExpired { .. }); let expired = matches!(state, SyncState::SessionExpired { .. });
let outdated = matches!(state, SyncState::Outdated { .. });
let (icon, class, title, detail) = match state { let (icon, class, title, detail) = match state {
SyncState::SessionExpired { queued } => ( SyncState::SessionExpired { queued } => (
"dialog-password-symbolic", "dialog-password-symbolic",
@@ -1372,6 +1425,20 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
"Unlock your keyring, or the next reboot signs you out", "Unlock your keyring, or the next reboot signs you out",
)), )),
), ),
SyncState::Outdated { running } => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Restart to finish updating"),
Some(if running.is_empty() {
gettext("An older version of Proton Drive is still running")
} else {
// Translators: {version} is a release number such as "3.2.0".
gettext_f(
"Proton Drive {version} is still running",
&[("version", &running)],
)
}),
),
SyncState::Paused { until, queued } => ( SyncState::Paused { until, queued } => (
"media-playback-pause-symbolic", "media-playback-pause-symbolic",
Some("warning"), Some("warning"),
@@ -1443,6 +1510,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
image.add_css_class(class); image.add_css_class(class);
} }
ui.locations.card.sign_in.set_visible(expired); ui.locations.card.sign_in.set_visible(expired);
ui.locations.card.restart.set_visible(outdated);
ui.status.status_title.set_label(&title); ui.status.status_title.set_label(&title);
ui.status.status_detail.set_visible(detail.is_some()); ui.status.status_detail.set_visible(detail.is_some());
ui.status ui.status
@@ -1511,7 +1579,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(gettext("No files kept offline")) .title(gettext("No files kept offline"))
.subtitle(gettext( .subtitle(gettext(
"Right-click a file and choose “Make available offline”.", "Right-click a file or folder and choose “Available offline”.",
)) ))
.build(); .build();
ui.status.pins_group.add(&row); ui.status.pins_group.add(&row);
@@ -1539,6 +1607,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&name) .title(&name)
.subtitle(&pin.path) .subtitle(&pin.path)
.use_markup(false)
.build(); .build();
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic"); let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
row.add_prefix(&icon); row.add_prefix(&icon);
+19 -2
View File
@@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
.unwrap_or_else(|| path.display().to_string()), .unwrap_or_else(|| path.display().to_string()),
) )
.subtitle(human_bytes(size)) .subtitle(human_bytes(size))
.use_markup(false)
.build(); .build();
let remove = gtk4::Button::builder() let remove = gtk4::Button::builder()
.icon_name("edit-delete-symbolic") .icon_name("edit-delete-symbolic")
@@ -645,7 +646,12 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
// An import runs for hours, so its ending is announced rather than // An import runs for hours, so its ending is announced rather than
// left to be discovered: a toast for whoever is looking at the // left to be discovered: a toast for whoever is looking at the
// window, and a desktop notification for whoever is not. // window, and a desktop notification for whoever is not.
if was_running { let failed = summary.as_ref().and_then(|s| s.error.clone());
if was_running && let Some(error) = &failed {
let headline = gettext("Couldn't import from Google Photos");
toast_error(&ui, &headline, error);
notify("takeout-import", &headline, error);
} else if was_running {
let dry = ui.takeout.dry_run.get(); let dry = ui.takeout.dry_run.get();
let headline = if dry { let headline = if dry {
gettext("Scan finished") gettext("Scan finished")
@@ -701,7 +707,18 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
} else { } else {
gettext("Last import") gettext("Last import")
}); });
ui.takeout.summary_group.set_description(Some(&if dry { ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
// The reason is the service's own text, which is not translated. A
// group's description is markup, and the reason may name a path.
glib::markup_escape_text(&gettext_f(
"{what}: {detail}",
&[
("what", &gettext("Couldn't import from Google Photos")),
("detail", error),
],
))
.to_string()
} else if dry {
gettext("Nothing was uploaded — this is what an import would do.") gettext("Nothing was uploaded — this is what an import would do.")
} else if summary.cancelled { } else if summary.cancelled {
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.") gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
+4 -1
View File
@@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) { pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.trash.status.set_icon_name(Some(icon)); ui.trash.status.set_icon_name(Some(icon));
ui.trash.status.set_title(title); ui.trash.status.set_title(title);
ui.trash.status.set_description(Some(description)); ui.trash
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.trash.retry.set_visible(retry); ui.trash.retry.set_visible(retry);
ui.trash.content.set_visible_child_name("status"); ui.trash.content.set_visible_child_name("status");
ui.trash.subtitle.set_subtitle(""); ui.trash.subtitle.set_subtitle("");
@@ -524,6 +526,7 @@ mod tests {
size: 0, size: 0,
modified, modified,
pinned: false, pinned: false,
pinned_by_folder: false,
cached: false, cached: false,
uid: name.into(), uid: name.into(),
path: String::new(), path: String::new(),
+168
View File
@@ -0,0 +1,168 @@
//! Tells the user when a newer release is out, if they turned on Preferences →
//! Updates. The decisions are [`pdfs_core::update`]'s; this asks GitHub and
//! shows the banner.
use crate::*;
use std::time::{SystemTime, UNIX_EPOCH};
use pdfs_core::update::{
RELEASES_LATEST_API, is_newer, latest_version, next_check_in, release_url, updates_managed,
};
/// How long one request to GitHub may take.
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to wait after a failed check, such as one made offline, before
/// trying again. Not saved, so a restart tries again straight away.
const RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
pub(crate) struct UpdateState {
/// Over the sidebar, so it shows whichever page is open.
pub(crate) banner: adw::Banner,
/// The check loop is running. Turning the setting off and on again while it
/// sleeps must not start a second one.
running: Cell<bool>,
/// The release page the banner's button opens.
url: RefCell<String>,
}
impl UpdateState {
pub(crate) fn new() -> Self {
Self {
banner: adw::Banner::builder()
.button_label(gettext("Download"))
.build(),
running: Cell::new(false),
url: RefCell::new(String::new()),
}
}
}
/// Whether a package manager brings new releases to this install. The app then
/// never asks, and Preferences does not offer to.
pub(crate) fn updates_managed_here() -> bool {
std::env::current_exe().is_ok_and(|exe| updates_managed(&exe))
}
/// Wire the banner, show a release an earlier check found, and start checking
/// if the user asked for it.
pub(crate) fn wire_update(ui: &Rc<Ui>) {
let ui_open = ui.clone();
ui.update
.banner
.connect_button_clicked(move |_| open_uri(&ui_open.update.url.borrow()));
show_latest(ui);
start_update_checks(ui);
}
/// Start the daily check, unless it is off, managed elsewhere, or running.
pub(crate) fn start_update_checks(ui: &Rc<Ui>) {
if updates_managed_here()
|| !ui.dirs.load_config().update_check
|| ui.update.running.replace(true)
{
return;
}
let ui = ui.clone();
glib::spawn_future_local(async move {
// Also kept here, so a config that cannot be saved does not make every
// pass ask GitHub again.
let mut checked = None;
loop {
let config = ui.dirs.load_config();
if !config.update_check {
break;
}
let wait = next_check_in(config.update_last_checked.max(checked), unix_now());
if !wait.is_zero() {
glib::timeout_future(wait).await;
continue;
}
match fetch_latest().await {
Ok(latest) => {
checked = Some(unix_now());
let mut config = ui.dirs.load_config();
config.update_last_checked = checked;
config.update_latest = latest;
if let Err(e) = ui.dirs.save_config(&config) {
tracing::warn!("update check: cannot save the result: {e}");
}
show_latest(&ui);
}
Err(e) => {
tracing::warn!("update check: {e}");
glib::timeout_future(RETRY_AFTER).await;
}
}
}
ui.update.running.set(false);
});
}
/// Reveal the banner if the last check found a release newer than this one,
/// and hide it otherwise.
pub(crate) fn show_latest(ui: &Ui) {
let config = ui.dirs.load_config();
let newer = config
.update_latest
.filter(|latest| config.update_check && is_newer(latest, env!("CARGO_PKG_VERSION")));
match newer {
Some(version) => {
ui.update.banner.set_title(&gettext_f(
"Proton Drive {version} is available",
&[("version", &version)],
));
*ui.update.url.borrow_mut() = release_url(&version);
ui.update.banner.set_revealed(!updates_managed_here());
}
None => ui.update.banner.set_revealed(false),
}
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
/// The latest release's version, asked on a worker thread so the window never
/// waits on GitHub. `Ok(None)` when the answer names no `X.Y.Z` release.
async fn fetch_latest() -> Result<Option<String>, String> {
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(fetch_latest_blocking());
});
rx.recv().await.map_err(|e| e.to_string())?
}
fn fetch_latest_blocking() -> Result<Option<String>, String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("no runtime: {e}"))?;
let client = reqwest::Client::builder()
.user_agent(concat!(
"proton-drive-linux/",
env!("CARGO_PKG_VERSION"),
" (",
env!("CARGO_PKG_REPOSITORY"),
")"
))
.timeout(FETCH_TIMEOUT)
.build()
.map_err(|e| format!("no HTTP client: {e}"))?;
runtime.block_on(async {
let reply = client
.get(RELEASES_LATEST_API)
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| e.to_string())?;
if !reply.status().is_success() {
return Err(format!("GitHub answered {}", reply.status()));
}
let body = reply.bytes().await.map_err(|e| e.to_string())?;
Ok(latest_version(&body))
})
}
+65 -11
View File
@@ -98,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
.title(gettext("Location")) .title(gettext("Location"))
.subtitle("—") .subtitle("—")
.subtitle_lines(3) .subtitle_lines(3)
// A folder path, which may hold `&` or `<`.
.use_markup(false)
.build(); .build();
path_row.add_css_class("property"); path_row.add_css_class("property");
props.add(&size_row); props.add(&size_row);
@@ -366,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else { } else {
gettext("File") gettext("File")
}); });
d.size_row.set_subtitle(&if entry.is_dir { // A folder's size is Proton Drive's count, which takes a request; it lands
"—".to_string() // in `load_details_extras`.
if entry.is_dir {
d.size_row.set_title(&gettext("Size on Proton Drive"));
d.size_row.set_subtitle("—");
d.size_row.set_tooltip_text(Some(&gettext(
"Counted by Proton Drive, including items in the trash.",
)));
} else { } else {
human_bytes(entry.size) d.size_row.set_title(&pgettext("column", "Size"));
}); d.size_row.set_subtitle(&human_bytes(entry.size));
d.size_row.set_visible(!entry.is_dir); d.size_row.set_tooltip_text(None);
}
d.modified_row d.modified_row
.set_subtitle(&dates::short_date(entry.modified)); .set_subtitle(&dates::short_date(entry.modified));
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
@@ -384,11 +393,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
} else { } else {
parent.to_string() parent.to_string()
}); });
// Pinning is per file: the context menu and the selection bar offer it for // Kept through a pinned folder above it, the entry has no pin of its own
// files only, so the pane must not offer it for folders either. // to remove, so the switch shows that state without offering to change it.
d.pin_row.set_active(entry.pinned); d.pin_row.set_active(entry.pinned);
d.pin_row.set_visible(!entry.is_dir); d.pin_row.set_subtitle(&if entry.pinned_by_folder {
d.pin_row.set_sensitive(*ui.mounted.borrow()); gettext("Kept on this computer because a folder it is in is available offline.")
} else if entry.is_dir {
gettext(
"Keep a copy of everything in this folder on this computer, even without a connection.",
)
} else {
gettext("Keep a copy on this computer, even without a connection.")
});
d.pin_row
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
d.open_button.set_label(&if entry.is_dir { d.open_button.set_label(&if entry.is_dir {
gettext("Open folder") gettext("Open folder")
} else { } else {
@@ -415,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
d.pages.set_visible_child_name("details"); d.pages.set_visible_child_name("details");
} }
/// Fill the Sharing and Versions rows, which need the network. The answers /// Fill the Sharing and Versions rows, and a folder's size, which need the
/// land only if the pane still shows the same entry. /// network. The answers land only if the pane still shows the same entry.
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) { fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
let by_uid = entry.path.is_empty() && !entry.uid.is_empty(); let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
@@ -442,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
}); });
if entry.is_dir { if entry.is_dir {
let req = if by_uid {
Request::FolderSizeByUid {
uid: entry.uid.clone(),
}
} else {
Request::FolderSize { path: rel }
};
let rx = spawn_request(ui.dirs.control_socket(), req);
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
glib::spawn_future_local(async move {
let text = match rx.recv().await {
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
_ => gettext("Couldn't load the size"),
};
if details_showing(&ui_c, &uid) {
ui_c.details.details.size_row.set_subtitle(&text);
}
});
return; return;
} }
let req = if by_uid { let req = if by_uid {
@@ -488,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
} }
} }
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
// and folders inside a folder.
ngettext_f(
"{size} · {n} item",
"{size} · {n} items",
items,
&[("size", &human_bytes(size))],
)
}
/// "3 versions · latest 2 Sep 2026". /// "3 versions · latest 2 Sep 2026".
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String { pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
let latest = items.iter().map(|r| r.created).max(); let latest = items.iter().map(|r| r.created).max();
@@ -536,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn folder_size_summary_reads_size_and_item_count() {
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
}
#[test] #[test]
fn sharing_summary_names_people_and_link() { fn sharing_summary_names_people_and_link() {
assert_eq!(sharing_summary(0, false), "Only you"); assert_eq!(sharing_summary(0, false), "Only you");
+12
View File
@@ -61,6 +61,18 @@ impl ActionMenu {
self self
} }
/// A greyed-out check item showing `active`, for a state the user cannot
/// change from here.
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
let name = self.next_action();
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
action.set_enabled(false);
self.group.add_action(&action);
self.section
.append(Some(label), Some(&format!("menu.{name}")));
self
}
/// Close the current section; the next item starts a new one below a /// Close the current section; the next item starts a new one below a
/// separator. An empty section is skipped, so callers can end a group of /// separator. An empty section is skipped, so callers can end a group of
/// conditional items unconditionally. /// conditional items unconditionally.
+256 -27
View File
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
.map(|days| now + days * 86_400) .map(|days| now + days * 86_400)
} }
/// The labels of the Expires dropdown on an existing link. When the link has
/// an expiry, the first entry is that expiry, so the dropdown shows what is
/// set and choosing it again changes nothing.
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
current
.into_iter()
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
.collect()
}
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
let idx = match (current, idx) {
(Some(_), 0) => return LinkChange::Keep,
(Some(_), idx) => idx - 1,
(None, idx) => idx,
};
match link_expiry_at(idx, now) {
Some(at) => LinkChange::Set(at),
None if current.is_some() => LinkChange::Remove,
None => LinkChange::Keep,
}
}
/// A row with a title and a dropdown of `labels` as its suffix.
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
let drop = gtk4::DropDown::builder()
.model(&string_list(labels))
.selected(selected)
.valign(gtk4::Align::Center)
.build();
let row = adw::ActionRow::builder().title(title).build();
row.add_suffix(&drop);
(row, drop)
}
/// The roles a public link can grant, in dropdown order.
fn link_roles() -> [String; 2] {
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
}
/// A loose shape check for an email address: one `@` with something before /// A loose shape check for an email address: one `@` with something before
/// it and a dotted domain after it. The server does the real validation; this /// it and a dotted domain after it. The server does the real validation; this
/// only catches typos before the Invite button is pressed. /// only catches typos before the Invite button is pressed.
@@ -237,6 +279,31 @@ impl ShareTarget {
} }
} }
fn update_link(
&self,
id: String,
role: Option<String>,
password: LinkChange<String>,
expires: LinkChange<i64>,
) -> Request {
match self {
ShareTarget::Path(path) => Request::UpdatePublicLink {
path: path.clone(),
id,
role,
password,
expires,
},
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
uid: uid.clone(),
id,
role,
password,
expires,
},
}
}
fn remove_link(&self, id: String) -> Request { fn remove_link(&self, id: String) -> Request {
match self { match self {
ShareTarget::Path(path) => Request::RemovePublicLink { ShareTarget::Path(path) => Request::RemovePublicLink {
@@ -491,6 +558,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(&entry.email) .title(&entry.email)
.subtitle(&subtitle) .subtitle(&subtitle)
.use_markup(false)
.build(); .build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true)); row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
@@ -506,10 +574,16 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
.selected(role_wire_to_index(&entry.role)) .selected(role_wire_to_index(&entry.role))
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
let state_role = state.clone(); // Weak, here and in every row below: the rows belong to groups the
// dialog state holds, so a strong handle in a row's handler is a
// cycle that kept each closed dialog in memory.
let state_role = Rc::downgrade(state);
let id = entry.id.clone(); let id = entry.id.clone();
let kind = entry.kind; let kind = entry.kind;
drop.connect_selected_notify(move |d| { drop.connect_selected_notify(move |d| {
let Some(state_role) = state_role.upgrade() else {
return;
};
share_dialog_op( share_dialog_op(
&state_role, &state_role,
state_role.target.update_role( state_role.target.update_role(
@@ -539,12 +613,14 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
let state_rm = state.clone(); let state_rm = Rc::downgrade(state);
let id = entry.id.clone(); let id = entry.id.clone();
let kind = entry.kind; let kind = entry.kind;
let who = entry.email.clone(); let who = entry.email.clone();
remove.connect_clicked(move |btn| { remove.connect_clicked(move |btn| {
let state = state_rm.clone(); let Some(state) = state_rm.upgrade() else {
return;
};
let id = id.clone(); let id = id.clone();
let button = btn.clone(); let button = btn.clone();
confirm_destructive( confirm_destructive(
@@ -576,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
*state.people_rows.borrow_mut() = rows; *state.people_rows.borrow_mut() = rows;
} }
/// Rebuild the public-link section: an existing link (copy / remove) or a /// Rebuild the public-link section: an existing link (copy / remove, and its
/// create control. /// settings) or a create control.
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) { pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
for row in state.link_rows.borrow_mut().drain(..) { for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row); state.link_group.remove(&row);
@@ -605,6 +681,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
url.clone() url.clone()
}) })
.subtitle(&subtitle) .subtitle(&subtitle)
.use_markup(false)
.build(); .build();
row.add_css_class("property"); row.add_css_class("property");
@@ -615,12 +692,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
copy.add_css_class("flat"); copy.add_css_class("flat");
let state_copy = state.clone(); let ui_copy = state.ui.clone();
let url_copy = url.clone(); let url_copy = url.clone();
copy.connect_clicked(move |btn| { copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy); btn.clipboard().set_text(&url_copy);
flash_copied(btn, "edit-copy-symbolic"); flash_copied(btn, "edit-copy-symbolic");
toast(&state_copy.ui, &gettext("Link copied")); toast(&ui_copy, &gettext("Link copied"));
}); });
row.add_suffix(&copy); row.add_suffix(&copy);
} }
@@ -631,10 +708,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.valign(gtk4::Align::Center) .valign(gtk4::Align::Center)
.build(); .build();
remove.add_css_class("flat"); remove.add_css_class("flat");
let state_rm = state.clone(); let state_rm = Rc::downgrade(state);
let id = link.id.clone(); let id = link.id.clone();
remove.connect_clicked(move |btn| { remove.connect_clicked(move |btn| {
let state = state_rm.clone(); let Some(state) = state_rm.upgrade() else {
return;
};
let id = id.clone(); let id = id.clone();
let button = btn.clone(); let button = btn.clone();
confirm_destructive( confirm_destructive(
@@ -658,39 +737,31 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&row); state.link_group.add(&row);
rows.push(row.upcast()); rows.push(row.upcast());
let settings = link_settings_row(state, link);
state.link_group.add(&settings);
rows.push(settings.upcast());
} }
None => { None => {
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]); let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder()
.title(gettext("Link role"))
.build();
role_row.add_suffix(&role_drop);
let pw_row = adw::PasswordEntryRow::builder() let pw_row = adw::PasswordEntryRow::builder()
.title(gettext("Password (optional)")) .title(gettext("Password (optional)"))
.build(); .build();
let expiry_names: Vec<String> = let expiry_names: Vec<String> =
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect(); LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
let expiry_drop = gtk4::DropDown::builder() let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
.model(&string_list(&expiry_names))
.selected(0)
.valign(gtk4::Align::Center)
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let create = group_button(&gettext("Create Public Link")); let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link. // Enter in the password field creates the link.
let btn = create.clone(); let btn = create.clone();
pw_row.connect_entry_activated(move |_| btn.emit_clicked()); pw_row.connect_entry_activated(move |_| btn.emit_clicked());
let state_c = state.clone(); let state_c = Rc::downgrade(state);
let pw_for = pw_row.clone(); let pw_for = pw_row.clone();
create.connect_clicked(move |btn| { create.connect_clicked(move |btn| {
let Some(state_c) = state_c.upgrade() else {
return;
};
if !btn.is_sensitive() { if !btn.is_sensitive() {
return; return;
} }
@@ -719,6 +790,142 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
*state.link_rows.borrow_mut() = rows; *state.link_rows.borrow_mut() = rows;
} }
/// The settings of an existing link, collapsed under one row. Each control
/// applies at once, the way a member's role does. While a change is on its way
/// the whole row is insensitive: the daemon fills in what a change keeps from
/// the link as it stands, so two changes in flight could undo each other.
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
let now = glib::real_time() / 1_000_000;
let expander = adw::ExpanderRow::builder()
.title(gettext("Link settings"))
.subtitle(gettext(
"Change who can edit, the password or the expiry. The link stays the same.",
))
.build();
let selected_role = u32::from(link.role == "editor");
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
role_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let role = if d.selected() == 1 {
"editor"
} else {
"viewer"
};
let request = state.target.update_link(
id.clone(),
Some(role.into()),
LinkChange::Keep,
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&role_row);
let current = link.expires;
let (expiry_row, expiry_drop) =
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
expiry_drop.connect_selected_notify(move |d| {
let Some(state) = state_w.upgrade() else {
return;
};
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
if expires == LinkChange::Keep {
return;
}
let request = state
.target
.update_link(id.clone(), None, LinkChange::Keep, expires);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&expiry_row);
let pw_row = adw::PasswordEntryRow::builder()
.title(if link.has_password {
gettext("New password")
} else {
gettext("Add a password")
})
.show_apply_button(true)
.build();
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
pw_row.connect_apply(move |row| {
let Some(state) = state_w.upgrade() else {
return;
};
let password = row.text().to_string();
if password.is_empty() {
return;
}
let request = state.target.update_link(
id.clone(),
None,
LinkChange::Set(password),
LinkChange::Keep,
);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&pw_row);
if link.has_password {
let remove = gtk4::Button::builder()
.label(gettext("Remove"))
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let row = adw::ActionRow::builder()
.title(gettext("Remove the password"))
.subtitle(gettext(
"Anyone with the link can open it without a password.",
))
.build();
row.add_suffix(&remove);
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
remove.connect_clicked(move |_| {
let Some(state) = state_w.upgrade() else {
return;
};
let request =
state
.target
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
share_dialog_update_link(&state, request, &expander_w);
});
expander.add_row(&row);
}
expander
}
/// Send a link change, then reload the dialog: on success to show the link as
/// it now is, on failure to put the controls back to what the link still is.
fn share_dialog_update_link(
state: &Rc<ShareDialog>,
request: Request,
settings: &adw::ExpanderRow,
) {
settings.set_sensitive(false);
let rx = spawn_request(state.ui.dirs.control_socket(), request);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
}
_ => toast_error(
&state.ui,
&gettext("Couldn't change the link"),
&gettext("The Proton Drive service didn't respond."),
),
}
share_dialog_reload(&state);
});
}
/// Create a public link, then reload the dialog so the copy/remove controls /// Create a public link, then reload the dialog so the copy/remove controls
/// replace the create form (and the freshly minted URL is shown). /// replace the create form (and the freshly minted URL is shown).
pub(crate) fn share_dialog_create_link( pub(crate) fn share_dialog_create_link(
@@ -848,6 +1055,28 @@ mod tests {
assert_eq!(link_expiry_at(99, 0), None); assert_eq!(link_expiry_at(99, 0), None);
} }
#[test]
fn choosing_an_expiry_keeps_removes_or_sets_it() {
let now = 1_000;
// No expiry yet: the choices are just Never and the lifetimes.
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
assert_eq!(
link_expiry_change(2, None, now),
LinkChange::Set(now + 7 * 86_400)
);
// An expiry set: it comes first, then Never and the lifetimes.
let at = Some(2_000_000_000);
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
assert_eq!(
link_expiry_change(2, at, now),
LinkChange::Set(now + 86_400)
);
}
#[test] #[test]
fn past_expiry_reads_expired() { fn past_expiry_reads_expired() {
assert_eq!(link_expiry_label(10, 20), "expired"); assert_eq!(link_expiry_label(10, 20), "expired");
@@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(title) .title(title)
.subtitle(subtitle) .subtitle(subtitle)
.use_markup(false)
.build(); .build();
let save = gtk4::Button::builder() let save = gtk4::Button::builder()
+5 -21
View File
@@ -15,7 +15,6 @@ mod thumbs;
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration;
use adw::prelude::*; use adw::prelude::*;
use gtk4::{gdk, gio, glib}; use gtk4::{gdk, gio, glib};
@@ -24,7 +23,7 @@ use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource}; use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource};
use pdfs_core::opener::{self, OpenWith}; use pdfs_core::opener::{self, OpenWith};
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, present_within};
use crate::compat::{Spinner, spinner}; use crate::compat::{Spinner, spinner};
use crate::theme::set_proton_theme; use crate::theme::set_proton_theme;
use crate::{ use crate::{
@@ -38,10 +37,6 @@ use thumbs::Thumbs;
/// Recent files shown above the pinned ones while the query is empty. /// Recent files shown above the pinned ones while the query is empty.
const RECENT_LIMIT: usize = 8; const RECENT_LIMIT: usize = 8;
/// How long opening waits for the mount to answer whether it has a file. A
/// frozen mount must not freeze the launcher; the daemon is asked instead.
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
/// The titled groups of the result list. /// The titled groups of the result list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section { enum Section {
@@ -516,22 +511,11 @@ pub(crate) fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
Some(ui) Some(ui)
} }
/// Whether the mount has `hit`, asked on a worker thread and given up on /// Whether the mount has `hit`, without blocking the window
/// after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer"; either /// ([`present_within`]). `None` means "not there" or "no answer"; either way
/// way the daemon is the fallback. /// the daemon is the fallback.
async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<PathBuf> { async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<PathBuf> {
let (tx, rx) = async_channel::bounded(1); present_within(mounted_or_relative(&mountpoint, &hit)).await
std::thread::spawn(move || {
let _ = tx.send_blocking(mounted_target(&mountpoint, &hit));
});
match glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
Ok(Ok(target)) => target,
Ok(Err(_)) => None,
Err(_) => {
tracing::warn!("the mount did not answer in time; opening through the daemon");
None
}
}
} }
impl Ui { impl Ui {
+5 -2
View File
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
} }
/// The sync badge of a Drive file, as in the app's browser: pinned, cached, /// The sync badge of a Drive file, as in the app's browser: pinned, cached,
/// or online only. Folders and local files have none. /// or online only. A folder has one only when kept offline; local files have
/// none.
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> { pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
let Hit::Drive(drive) = hit else { let Hit::Drive(drive) = hit else {
return None; return None;
}; };
if drive.is_dir { if drive.is_dir {
return None; return drive
.pinned
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
} }
Some(if drive.pinned { Some(if drive.pinned {
("pdfs-offline-symbolic", "badge-pinned") ("pdfs-offline-symbolic", "badge-pinned")
+54 -8
View File
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::config::AppDirs; use pdfs_core::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send, Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
follow, send,
}; };
use pdfs_core::service; use pdfs_core::service;
@@ -86,6 +87,9 @@ struct DriveState {
paused: bool, paused: bool,
/// Queued changes that keep failing, for the "N issues" item. /// Queued changes that keep failing, for the "N issues" item.
failing: u64, failing: u64,
/// Whether the daemon is an older release than the tray, left running by
/// an upgrade; offers the restart that finishes it.
outdated: bool,
} }
/// Summarise a work snapshot into one menu line, or empty when idle. A single /// Summarise a work snapshot into one menu line, or empty when idle. A single
@@ -105,7 +109,8 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
&[("title", &j.title), ("done", &done), ("total", &total)], &[("title", &j.title), ("done", &done), ("total", &total)],
) )
} }
Some(j) => format!("{}…", j.title), // Translators: {title} names a running job whose size is not known yet.
Some(j) => gettext_f("{title}…", &[("title", &j.title)]),
None => String::new(), None => String::new(),
}, },
[t] => { [t] => {
@@ -114,10 +119,19 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
TransferDirection::Upload => "↑", TransferDirection::Upload => "↑",
}; };
if t.bytes_total == 0 { if t.bytes_total == 0 {
format!("{arrow} {}…", t.name) // Translators: one transfer of unknown size; {arrow} is ↑ or ↓, {name} a file name.
gettext_f("{arrow} {name}…", &[("arrow", arrow), ("name", &t.name)])
} else { } else {
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100); let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
format!("{arrow} {} ({pct}%)", t.name) // Translators: one transfer; {arrow} is ↑ or ↓, {name} a file name, {percent} how much is done.
gettext_f(
"{arrow} {name} ({percent}%)",
&[
("arrow", arrow),
("name", &t.name),
("percent", &pct.to_string()),
],
)
} }
} }
_ => { _ => {
@@ -166,6 +180,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
}, },
// No daemon: describe login state instead so the menu is still useful. // No daemon: describe login state instead so the menu is still useful.
Err(_) => { Err(_) => {
@@ -202,6 +217,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
} }
} }
} }
@@ -218,6 +234,7 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
sync: String::new(), sync: String::new(),
paused: false, paused: false,
failing: 0, failing: 0,
outdated: false,
} }
} }
@@ -239,6 +256,7 @@ fn status_state(
paused, paused,
session_expired, session_expired,
tokens_unsaved, tokens_unsaved,
version,
.. ..
} = status } = status
else { else {
@@ -246,9 +264,10 @@ fn status_state(
}; };
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused); let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes); let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let outdated = daemon_outdated(version);
let phase = if *session_expired { let phase = if *session_expired {
Phase::SessionExpired Phase::SessionExpired
} else if *tokens_unsaved { } else if *tokens_unsaved || outdated {
Phase::Attention Phase::Attention
} else { } else {
phase_of( phase_of(
@@ -265,6 +284,9 @@ fn status_state(
_ if *session_expired => gettext("Session expired — sign in again"), _ if *session_expired => gettext("Session expired — sign in again"),
// Sync goes on meanwhile, but a reboot ends it until the next login. // Sync goes on meanwhile, but a reboot ends it until the next login.
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"), _ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
// An upgrade replaced the files but not the running service, which
// keeps the old release's bugs until it restarts.
_ if outdated => gettext("Update not finished — restart Proton Drive"),
_ if paused => gettext("Sync paused"), _ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f( _ if failing_ops > 0 => ngettext_f(
"{n} change needs attention", "{n} change needs attention",
@@ -302,6 +324,7 @@ fn status_state(
mountpoint: PathBuf::from(mountpoint), mountpoint: PathBuf::from(mountpoint),
sync: sync_line(items, jobs), sync: sync_line(items, jobs),
failing: failing_ops, failing: failing_ops,
outdated,
} }
} }
@@ -348,8 +371,9 @@ fn open_manager(args: &[&str]) {
.and_then(|p| p.parent().map(|d| d.join("pdfs-app"))) .and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
.filter(|p| p.exists()) .filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("pdfs-app")); .unwrap_or_else(|| PathBuf::from("pdfs-app"));
if let Err(e) = Command::new(&exe).args(args).spawn() { match Command::new(&exe).args(args).spawn() {
tracing::error!("failed to launch {}: {e}", exe.display()); Ok(child) => pdfs_core::opener::reap(child),
Err(e) => tracing::error!("failed to launch {}: {e}", exe.display()),
} }
} }
@@ -445,6 +469,13 @@ impl Tray for DriveTray {
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))), _ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
} }
if self.state.outdated {
items.push(item(&gettext("Restart Proton Drive"), |this| {
service::restart();
this.state.line = gettext("Connecting…");
}));
}
if self.state.mounted { if self.state.mounted {
items.push( items.push(
StandardItem { StandardItem {
@@ -626,9 +657,14 @@ mod tests {
} }
fn status(extra: &str) -> Response { fn status(extra: &str) -> Response {
status_from(env!("CARGO_PKG_VERSION"), extra)
}
fn status_from(version: &str, extra: &str) -> Response {
serde_json::from_str(&format!( serde_json::from_str(&format!(
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0, r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"# "pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
"version":"{version}"{extra}}}}}"#
)) ))
.unwrap() .unwrap()
} }
@@ -660,6 +696,16 @@ mod tests {
assert_eq!(state.line, "Sign-in not saved — unlock your keyring"); assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
} }
/// #33: a daemon an upgrade left running on the old release is named,
/// above a pause, and the menu offers the restart that finishes it.
#[test]
fn a_daemon_older_than_the_tray_needs_a_restart() {
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Update not finished — restart Proton Drive");
assert!(state.outdated);
}
#[test] #[test]
fn a_daemon_that_does_not_say_has_a_live_session() { fn a_daemon_that_does_not_say_has_a_live_session() {
let state = status_state(&status(""), &[], &[], Path::new("/m")); let state = status_state(&status(""), &[], &[], Path::new("/m"));
+26 -2
View File
@@ -255,7 +255,9 @@ not one this daemon sealed itself (`own_sealed_rev` table), the queued bytes are
conflict copy (`Core::revision_conflict`) next to the remote version. A name taken on Drive by a conflict copy (`Core::revision_conflict`) next to the remote version. A name taken on Drive by a
node this machine has not seen lands under a conflict name too. Both go to the activity log. node this machine has not seen lands under a conflict name too. Both go to the activity log.
Conflict copies are named `{stem} (sync-conflict {unix_secs}).{ext}`. Conflict copies are named `{stem} (sync-conflict {host} {unix_secs}).{ext}`, where `{host}` is the
hostname cut down to one token (`conflict_label`). `conflict_base_name` also reads the older form
without a host.
### Failures and sync issues ### Failures and sync issues
@@ -342,6 +344,22 @@ Safety rules:
- **Ignore rules.** `.pdfsignore` (or `.protonignore`) at the folder root plus `ignore_patterns` - **Ignore rules.** `.pdfsignore` (or `.protonignore`) at the folder root plus `ignore_patterns`
from `config.json`, in gitignore syntax. Ignoring never deletes. from `config.json`, in gitignore syntax. Ignoring never deletes.
Another client may write the same folder, such as a second computer syncing into the device. The
baseline therefore also records the active revision id each file was synced at (schema 44):
- **A new revision is a remote change** even when the link's modification time and the size come
out the same.
- **An upload checks first.** `upload_revision` reads the node and leaves a file whose revision
moved since the walk for the next pass, which classifies it as a conflict. A revision committed
between that check and the SDK's own revision draft is not caught.
- **The baseline records what was transferred.** A download records the revision the pass
planned to fetch, not a read-back that may already show a newer one. An upload records the
read-back only when its SHA-1 is the one sent.
- **A first sync compares content.** A path present on both sides without a baseline is adopted
only when the local SHA-1 matches Drive's digest; otherwise it is a conflict.
Remote changes still reach a mirror only through the 120 s poll.
### Moves between locations ### Moves between locations
My files and each online-only folder are separate FUSE sessions, and a mirrored folder has none, My files and each online-only folder are separate FUSE sessions, and a mirrored folder has none,
@@ -369,12 +387,18 @@ named like the hostname, else a new one. An adopted uid that no longer exists is
replaced silently. replaced silently.
The local database does not survive a lost machine, so the daemon backs up its arrangement to The local database does not survive a lost machine, so the daemon backs up its arrangement to
`<device root>/.proton-drive-linux/profile.json` (`pdfs_core::profile`): each synced folder's `<device root>/.proton-drive-linux/profile-<machine id>.json` (`pdfs_core::profile`): each synced folder's
remote uid, local path and mode, the pins, and the ignore patterns. It is uploaded like any other remote uid, local path and mode, the pins, and the ignore patterns. It is uploaded like any other
file, so it is end-to-end encrypted. `Core::touch_profile` coalesces bursts of changes into one file, so it is end-to-end encrypted. `Core::touch_profile` coalesces bursts of changes into one
upload. `PROFILE_VERSION` guards the format: a profile from a newer client is refused, not applied upload. `PROFILE_VERSION` guards the format: a profile from a newer client is refused, not applied
in part. in part.
The machine id is random and lives in `machine-id` next to `config.json`. Each machine has its
own file because several computers may sync into one device: `pdfs devices join` and **Sync With
This Computer…** adopt another computer's device without taking it over. Restore reads every
profile there, the legacy shared `profile.json` included, and prefers the one with this hostname,
then the newest (`profile::saved_folder`).
Restore lists the device's folders, proposes a local path from the profile, and on confirmation Restore lists the device's folders, proposes a local path from the profile, and on confirmation
adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty adds a synced folder bound to the remote uid. The download is an ordinary pass against an empty
baseline. The user side is in [RECOVERY.md](RECOVERY.md). baseline. The user side is in [RECOVERY.md](RECOVERY.md).
+1393
View File
File diff suppressed because it is too large. Load diff
+284 -2
View File
@@ -11,12 +11,272 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
## [3.6.0] — 2026-10-27
Schema: **44**, unchanged.
### Added
- **`pdfs diagnose` names the key that keeps My Files closed.** When you are signed in and the
service does not answer, a new **Keys** section asks Proton which account and address keys
open, which account key each address key depends on, and which address key the My Files share
is encrypted to. It shows key IDs only, no secrets (#40).
- **A COPR repository for Fedora**, so `dnf upgrade` brings each new release:
`sudo dnf copr enable narl-rs/proton-drive-linux`. Every release also ships the vendored crates
as `proton-drive-linux-vendor-<version>.tar.xz`, which the COPR build uses offline (#42).
- **The app can tell you about a new release.** Turn on **Check for updates** in Preferences, and
the app asks GitHub once a day and shows a banner with a **Download** button when a newer
version is out. It is off by default, and not offered for packages that a package manager
updates (COPR, the AUR, Nix) (#42).
- **Copy files and folders on Proton Drive**, without downloading or uploading them. In My files,
right-click an item and choose **Make a Copy** to copy it next to itself, or **Copy To…** to pick
another folder; **Copy To…** also works on a selection. From a terminal:
`pdfs copy ~/ProtonDrive/report.pdf ~/ProtonDrive/Archive`. A folder is copied with everything
in it, and a name already taken gets a " (1)" suffix. A file or folder with changes that have
not synced yet is refused until they have.
- **Change a public link without getting a new one.** In **Share…**, open **Link settings** under
the link to switch between Viewer and Editor, set, change or remove the password, and change or
remove the expiry. The address stays the same, so people who already have the link keep it.
From a terminal: `pdfs public-link update PATH ID [--role] [--password | --no-password]
[--expires EPOCH | --no-expiry]`. `pdfs members` now also shows when a link expires.
- **Folder sizes.** **Details** shows how much a folder holds and how many items are in it, as
Proton Drive counts it, including items in the trash. From a terminal: `pdfs du PATH`.
- **Sync a folder from scratch.** In a synced folder's menu on **Sync → Folders**, **Sync From
Scratch…** forgets what was synced before and compares the folder with Proton Drive again. A
file on one side only is copied to the other, and a file that differs keeps both versions.
Nothing is deleted. From the terminal: `pdfs sync reset ID`.
- **`--json` for `pdfs members`, `pdfs du` and `pdfs public-link create` / `update`**, so scripts
can read a link's id, role, password flag and expiry, and a folder's size and item count.
### Changed
- **Copying a file inside the mount is faster** when the computer already has the file, for
example with `cp` or a file manager: the copy is made on this computer, about four times
faster for a 256 MiB file, instead of going through the mount page by page. A file that is
only on Proton Drive is copied as before. The copy is then uploaded as usual.
- **Reorganised documentation, now also on the
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).** A new
[Getting started](https://github.com/narrrl/proton-drive-linux/wiki/Getting-Started) page and
an [FAQ](https://github.com/narrrl/proton-drive-linux/wiki/FAQ). The user guide is split into
pages for files, synced folders, Photos, sharing, computers, and the launcher and tray.
Installation puts the COPR repository first for Fedora and explains updating and uninstalling
for each kind of package. Each release publishes `docs/` to the wiki.
- **Acceptance cases for the 3.6.0 features:** `copy_file_range` over several calls and at
offsets, `pdfs copy` (including B256), `pdfs du`, `pdfs public-link update`, Sync From Scratch,
and the switch of an emptied mirror folder to on-demand (B255). `docs/TESTING.md` lists what to
check by hand in the app.
### Fixed
- **A synced folder whose files were all gone from this computer no longer gets stuck** (#46).
The sync still refuses to delete everything on Proton Drive, but a switch to online only now
goes ahead and shows what Proton Drive has, instead of waiting for good. This includes a
switch asked for right after the files went, which was refused with "… was deleted here but
not on Proton Drive yet". To download the files again instead, sync the folder from scratch.
Restoring folders also logs why it skipped a folder.
- **The first sync of a folder shows how far its scan got** (#46). Before anything downloads,
the first pass reads every item on Proton Drive, which takes many minutes for a large folder.
The folder showed only "checking for changes…" all that time, and `pdfs sync list` only
`scanning…`, which looked stuck. Both now count the items checked so far.
- **Saved public links show the name of what they point at**, in the app and in `pdfs bookmarks`,
instead of "Shared link". For a link with a custom password, the address no longer has the custom
password stuck to its end, so it matches the link the web shows and the page asks for the
password.
## [3.5.0] — 2026-10-16
Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the
version on Proton Drive it was synced at. Nothing is rewritten, and the upgrade takes a moment.
### Added
- **Several computers can sync the same folders.** On the Computers page, **Sync With This
Computer…** on another computer, or `pdfs devices join`, makes this computer sync into that
computer's backup alongside it, then offers its folders for syncing here. Both computers keep
them in step, deletions included. Each computer now keeps its own settings backup in the
shared backup, so they no longer overwrite each other (B252).
- **The app remembers its window size**, and whether it was maximized, between runs.
### Fixed
- **The app's window could not shrink to fit a small or scaled screen**, so the minimize,
maximize and close buttons ended up off-screen. Below about 760 pixels the sidebar and the
page now take turns, and every page fits in a window 480 pixels wide.
- **The app showed the sign-in page when the system keyring was locked or not running yet**, as
if the sign-in had been lost. It now says that the keyring could not be read, and opens on its
own once the keyring answers (B253).
- **A new version saved on another computer in the same second as an upload from here could
be missed for good** when Drive had not listed the uploaded file yet. The first sync pass
after upgrading reads each file's size from Drive once more to close this (B254).
- **An edit in a synced folder could replace one another computer had just saved.** The newer
version from the other computer was left only in the version history. The edit is now kept as
a conflict copy (B249).
- **A version saved on another computer while this one downloaded the previous version never
arrived.** (B250)
- **Syncing a folder into one that already held a file of the same name and size took the two
for the same file**, even when their content differed. The content is now compared, and a
different file is kept as a conflict copy (B248).
- **Conflict copies now name the computer that made them**, for example
`notes (sync-conflict laptop 1700000000).txt`, so two computers no longer make the same name
(B251).
- **A folder deleted on another device took files with it that had never reached Drive.** A new
file, an edit or an ignored `.git` in a synced folder was deleted along with the folder. Such a
folder is now kept and created on Drive again (B211).
- **Files on Drive that could not be decrypted were deleted from the synced folder.** They read
as deleted on Drive. They are now left alone and named in the activity feed, and a pass in which
nothing synced is left on Drive is refused (B212).
- **An edit made while a synced folder was being checked could be deleted** when another device
had deleted the file. It is now kept and uploaded (B213).
- **Deleting a synced folder sent another device's new files in it to the trash.** The folder now
stays on Drive with them (B214).
- **A file name on Drive containing `/` or `..` could write outside the synced folder**, and made
its folder in the mount fail to list. Such names are left out (B215).
- **A damaged database lost the changes still waiting to upload.** It is still moved aside and
rebuilt, but queued changes, synced folders' state and files kept offline are copied out of it
first (B216).
- **Search slowed down over time, and the database grew far larger than needed.** The search
indexes are now compacted once a day; on one machine that took the local index from 317 MiB to
20 MiB (B217).
- **`pdfs diagnose` and `pdfs cache inspect` froze the drive while they checked the database**,
for half a minute on a large one. They no longer hold up anything else (B218).
- **Listing a folder shared with you could freeze the whole drive on a slow connection**, and so
could folders of photos asking for thumbnails while the connection stalled (B219, B220).
- **Opening a large folder for the first time held up every other file operation on the drive.**
(B221)
- **A full disk while saving a file was not reported.** The program saving it now gets the error
when it closes the file. Copying a large file into the drive no longer stalls everything else at
the end, `fsync` on a file opened for reading no longer fails, and `fallocate` modes the drive
cannot honour are refused instead of corrupting the file (B222, B224, B227).
- **A damaged settings file was replaced with defaults at the next settings change**, losing the
mount location and the ignore rules. Settings are no longer saved over a file that cannot be
read (B223).
- **Google Photos import gave some photos another photo's name and date.** A photo without its
own metadata file took a similarly named one's, and an edited copy took its original's name. An
edited copy now takes its original's date and keeps its own name (B225).
- **Thumbnails of portrait photos lay on their side**, here and in Proton's own apps (B226).
- **A write could be lost to a power cut after the program saving it had closed the file.** Saving
a change into the upload queue is now written through to disk (B229).
- **A partly written file whose upload answer was lost came back as a conflict copy of itself.**
The retry now recognises its own upload (B228).
- **A file or folder created offline could merge with another device's of the same name** made
while this computer was offline (B230).
- **A large upload under a low upload limit never finished.** It ran out of time on every try,
took the drive offline and started over. Its time limit now allows for its share of the limit
(B231).
- **Every reconnect sent all failed uploads again at once,** including those Proton had refused
for a full quota or missing access, and failed uploads were retried no later than every two
minutes. Refused uploads now keep their wait, which grows to five minutes, and retries are
spread out (B237).
- **Signing out left the session valid on Proton.** It is now ended there too; `pdfs logout` says
so when Proton cannot be reached (B233).
- **Any folder could be synced, the home folder included,** which uploaded the app's own cache
and walked into the drive itself. The root of the file system, the home folder and the folders
above it, the Proton Drive folder, the app's own data and folders inside or around another
synced folder are now refused, also when restoring another computer's folders (B234).
- **Names with `&` or `<` showed as empty rows in the app**, and some names were drawn as
formatting, for example shared items, computer names and link addresses (B232).
- **Photos imported from Google Photos, re-dated, or uploaded from the app had no thumbnail** in
Proton's apps (B236).
- **Photos with no other date were filed under the day they were uploaded.** Their own EXIF date
is now used, for Google Photos imports and for uploads from the app (B244).
- **Scanning a Google Photos export with a large folder took up to half a minute.** 10,000 photos
now take a tenth of a second (B235).
- **Every photo opened in the viewer stayed in memory**, with its full-size image, until the app
quit. The Share dialog leaked the same way (B241).
- **Opening a large photo froze the app** while it was decoded (B242).
- **The app froze when opening a file while the drive did not respond.** It now falls back to
downloading the file after two seconds. Applications the app started also no longer linger as
zombie processes (B243).
- **`pdfs import-google-photos --wait` and `pdfs redate-photos --wait` succeeded when the run
failed.** They now exit with an error when the run failed, was cancelled or left photos failed,
and the app shows why an import failed (B239).
- **`pdfs mount`, `login`, `unlock` and `logout` could be core-dumped with the account's keys in
memory.** Only the background service was protected. A file named like an option, such as
`--json`, also broke the file manager's "Keep offline" script (B240).
- **Signing in while the system keyring was locked froze the app window** until the keyring was
unlocked (B246).
- **A program the background service started could keep it from being restarted when it hung**
(B245).
- **The tray's transfer line was not translated** (B247).
### Changed
- **Draining a large batch of offline changes no longer slows down with its size.** The queue is
indexed by name (schema 43, B238).
- **Release binaries are stripped of their symbols**: `pdfs` goes from 43 to 35 MB, `pdfs-app`
from 24 to 19 MB and `pdfs-tray` from 12 to 8 MB.
## [3.4.1] — 2026-10-16
Schema: **42**, unchanged.
### Fixed
- **After a password reset, the drive never mounted and the app stayed on "Connecting…".** A
reset gives the account new keys, and Proton locks the Drive volume that was encrypted to the
old ones. The service then failed every start with `failed to decrypt: missing key`. It now
starts a new My Files volume, as the web app does. The files in the locked volume can still be
restored from [drive.proton.me](https://drive.proton.me) with the previous password
(B210, [#40](https://github.com/narrrl/proton-drive-linux/issues/40)).
- **A blocked sign-in said the app version was no longer supported.** Proton refuses sign-in for
a while after it suspects abuse, and its message then asks you to update. The app and
`pdfs login` now say that Proton is refusing sign-in for now and that waiting helps
(B209, [#39](https://github.com/narrrl/proton-drive-linux/issues/39)).
- **`pdfs diagnose` failed the `database` check on a fresh install.** The daemon creates the
database after the first sign-in, so a missing one is now a warning.
## [3.4.0] — 2026-10-12
Schema: **42**, unchanged.
### Added
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
it. See [Install → Binary cache](INSTALL.md#binary-cache).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
- **Folders can be made available offline in the app.** The context menu, the details pane and
the selection bar now offer it for folders too. Every file in the folder and its subfolders
downloads in the background with its progress in the tray and the app, and pinned folders carry
the offline badge. A file kept through its folder shows as available offline but cannot be
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
on a large folder, and pinned files download to disk instead of into memory.
### Fixed
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
every logged-in user before they are removed. Upgrades are not affected.
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
when the file had been opened just after it first reached Proton Drive. The write then landed
after the move. Such a move is refused now, as it always was meant to be (B207).
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
extension only for GNOME.
## [3.3.0] — 2026-10-11
Schema: **42**, unchanged.
### Added ### Added
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and - **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
the flake's NixOS module installs it and runs the mount in every graphical session. See the flake's NixOS module installs it and runs the mount in every graphical session. Pin the
[Install → NixOS](INSTALL.md#nixos-flake). input to a release tag such as `github:narrrl/proton-drive-linux/v3.3.0` to stay on releases.
See [Install → NixOS](INSTALL.md#nixos-flake).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37)) ([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
### Fixed
- **A failed photo upload said "You're offline" while everything else worked.** Only a failed
connection now reads as offline. Other failures show what went wrong, and Upload is greyed out
on an account without a photo library.
([#38](https://github.com/narrrl/proton-drive-linux/issues/38))
- **After an upgrade, the old version kept running in the background, and nothing said so.**
Installing a package does not restart the service, so fixed bugs, such as an unreadable folder
stopping a synced folder, came back until the next login. The tray, the app, `pdfs status` and
`pdfs diagnose` now say when the service is older than the app, and the tray and the app offer
to restart it. ([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
## [3.2.1] — 2026-10-11
Schema: **42**, unchanged.
### Fixed ### Fixed
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the - **Writing to a file again soon after a partial write could fail with an I/O error.** When the
first write changed only part of a file this client had not read, a second write, or saving first write changed only part of a file this client had not read, a second write, or saving
@@ -26,6 +286,22 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
the app, as on secureblue, the app now offers it in your browser and continues the sign-in the app, as on secureblue, the app now offers it in your browser and continues the sign-in
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing. once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29)) ([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
- **The daemon kept several CPU cores busy while offline with changes queued.** Its upload
workers checked the queue in a tight loop for as long as the outage lasted. They now sleep
until the connection comes back. ([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **An expired sign-in was shown as "offline" for good.** When Proton refuses the session, the
tray now says "Session expired — sign in again" with a Sign In Again… item, the app shows the
same with a button, and `pdfs status` and `pdfs diagnose` tell you to run `pdfs login`. Cached
files stay readable and changes stay queued until you sign in.
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **The sign-in could be lost after `pdfs login`, `pdfs unlock` or a slow network.** The old
daemon wrote its spent tokens over a new sign-in when it stopped, a timeout could drop a token
refresh halfway, and a refresh was retried with a token Proton had already used. Each process
now writes only into its own session, a refresh always finishes, and it is never repeated.
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
- **A sign-in refreshed while the keyring was locked was lost on the next restart.** The client
now keeps retrying, holds the new tokens in the kernel keyring meanwhile, and the tray says
"Sign-in not saved — unlock your keyring" until it is written.
## [3.2.0] — 2026-10-08 ## [3.2.0] — 2026-10-08
@@ -1722,6 +1998,12 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated - The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
by a new POSIX compliance suite for the filesystem. by a new POSIX compliance suite for the filesystem.
[3.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.5.0
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0 [3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2 [3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
+24 -14
View File
@@ -4,7 +4,8 @@
talks to the running service (`proton-drive.service`); `login`, `logout`, `mount`, `daemon` and talks to the running service (`proton-drive.service`); `login`, `logout`, `mount`, `daemon` and
`diagnose` work without it. `diagnose` work without it.
`pdfs <command> --help` prints the full help of any command. This page groups them by task. `pdfs <command> --help` prints the full help of any command. This page groups them by task. For
what the commands do in the app, see the [user guide](USER_GUIDE.md).
- [Conventions](#conventions) - [Conventions](#conventions)
- [Account and service](#account-and-service) - [Account and service](#account-and-service)
@@ -35,15 +36,18 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. | | `pdfs login [-u EMAIL] [--remember]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. `--remember` keeps a two-password account unlocked across logins. |
| `pdfs logout` | Remove the stored session. | | `pdfs unlock [--remember]` | Enter the mailbox password of a two-password account, so the service can open your files. Needed once per login session, or once with `--remember`. |
| `pdfs lock` | Forget the unlock held in memory; the account is locked again. |
| `pdfs logout` | End the session on Proton and remove the stored session. |
| `pdfs status` | Account, mount, queue and cache state. | | `pdfs status` | Account, mount, queue and cache state. |
| `pdfs quota` | Storage used across all Proton products. | | `pdfs quota` | Storage used across all Proton products. |
| `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. | | `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. |
| `pdfs daemon [MOUNTPOINT]` | Run the service in the foreground: wait for a session, mount, stay mounted. `proton-drive.service` runs this. | | `pdfs daemon [MOUNTPOINT]` | Run the service in the foreground: wait for a session, mount, stay mounted. `proton-drive.service` runs this. |
| `pdfs mount [MOUNTPOINT]` | Mount once and block until unmounted. Defaults to `~/ProtonDrive`. | | `pdfs mount [MOUNTPOINT]` | Mount once and block until unmounted. Defaults to `~/ProtonDrive`. |
`pdfs login` cannot show a CAPTCHA. If Proton asks for one, sign in with `pdfs-app` instead. If Proton asks for a CAPTCHA, `pdfs login` prints the address of the page. Open it in your
browser, complete it, then press Enter.
## Files ## Files
@@ -54,6 +58,8 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount
| `pdfs mkdir PARENT NAME` | Create a folder. | | `pdfs mkdir PARENT NAME` | Create a folder. |
| `pdfs rename PATH NEW_NAME` | Rename a file or folder. | | `pdfs rename PATH NEW_NAME` | Rename a file or folder. |
| `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. | | `pdfs move PATH... NEW_PARENT` | Move one or more files or folders into another folder. The two may be in different locations (My files, an on-demand or a mirrored synced folder); the move happens on Proton Drive, without downloading or uploading anything. |
| `pdfs copy PATH... NEW_PARENT` | Copy one or more files or folders into another folder in My files. The copy happens on Proton Drive, without downloading or uploading anything; a name already taken gets a " (1)" suffix. Changes that have not synced yet must sync first. |
| `pdfs du PATH` | Show how much a folder holds on Proton Drive and how many items are in it. The size includes items in the trash. |
| `pdfs rm PATH` | Move to the Trash. | | `pdfs rm PATH` | Move to the Trash. |
| `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. | | `pdfs upload SOURCES... [-t FOLDER]` | Upload local files and folders (recursively) in the background. Follow with `pdfs transfers`. |
| `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. | | `pdfs refresh [TARGET] [--full]` | Forget a cached listing so the next read fetches it again. `TARGET` is a folder, `trash` or `photos`; `--full` with `photos` re-reads every photo's metadata. |
@@ -65,9 +71,9 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. | | `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
| `pdfs unpin PATH` | Release it back to online only. | | `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 kept available offline. | | `pdfs pins` | List files and folders kept available offline. |
## Synced folders ## Synced folders
@@ -78,6 +84,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs sync rm ID [--delete-remote]` | Stop syncing. Local files stay; `--delete-remote` also removes the copy on Proton Drive. | | `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`). | | `pdfs sync mode ID mirror\|ondemand` | Switch between a full local copy (`mirror`) and online only (`ondemand`). |
| `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. | | `pdfs sync now [ID]` | Start a sync pass now, for all folders or one. |
| `pdfs sync reset ID` | Forget what a synced folder synced before and compare it with Proton Drive again. A file on one side only is copied to the other, and a file that differs keeps both versions. Nothing is deleted. |
| `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). | | `pdfs sync pause [--for 1h] [--folder ID]` | Pause uploads and synced folders. Reading files keeps working. `--for` ends the pause by itself (`30m`, `1h`, `2d`). |
| `pdfs sync resume [--folder ID]` | Resume after a pause. | | `pdfs sync resume [--folder ID]` | Resume after a pause. |
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. | | `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
@@ -86,7 +93,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. | | `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. | | `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. | | `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). | | `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [Recovery](RECOVERY.md). |
## Conflicts ## Conflicts
@@ -102,8 +109,9 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs devices list` | List computers registered to the account. | | `pdfs devices list` | List computers registered to the account. |
| `pdfs devices rename UID NAME` | Rename a computer. | | `pdfs devices rename UID NAME` | Rename a computer. |
| `pdfs devices rm UID` | Remove a computer and its backup from the account. | | `pdfs devices rm UID` | Remove a computer and its backup from the account. |
| `pdfs devices join UID [--yes]` | Sync the same folders as another computer. This machine syncs into that computer's backup alongside it, then offers its folders like `pdfs sync restore`. Both computers keep them in step. `--yes` skips the confirmation and accepts every proposed path. |
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. | | `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). | | `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [Recovery](RECOVERY.md). |
## Trash ## Trash
@@ -128,10 +136,11 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. | | `pdfs share PATH EMAILS... [--role viewer\|editor\|admin] [--message TEXT]` | Invite people to a file or folder. |
| `pdfs members PATH` | List members, pending invitations and the public link. | | `pdfs members PATH` | List members, pending invitations and the public link, with the link's id and expiry. |
| `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. | | `pdfs share-role PATH ID KIND ROLE` | Change a member's or invitation's role. |
| `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. | | `pdfs unshare PATH [ID KIND]` | Stop sharing, or remove one member or invitation. |
| `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. | | `pdfs public-link create PATH [--role] [--password] [--expires EPOCH]` | Create a public link. `--expires` takes Unix seconds. |
| `pdfs public-link update PATH ID [--role] [--password TEXT \| --no-password] [--expires EPOCH \| --no-expiry]` | Change a public link's role, password or expiry. The URL stays the same, and an option you leave out is kept. |
| `pdfs public-link remove PATH ID` | Remove a public link. | | `pdfs public-link remove PATH ID` | Remove a public link. |
| `pdfs shared` | List what you share. | | `pdfs shared` | List what you share. |
| `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. | | `pdfs shared-with-me [UID]` | List what others share with you, or the contents of one shared folder. |
@@ -170,19 +179,20 @@ commands are for scripts and for cases where the mount is not available.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `pdfs diagnose` | Check the installation and print a report. Works without the service. Exits non-zero if a check fails. | | `pdfs diagnose` | Check the installation and print a report. Works without the service. When the service does not answer, also asks Proton which of your keys open. Exits non-zero if a check fails. |
| `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. | | `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. |
| `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 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 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 cache clear` | Delete cached file content. Files kept available offline stay. |
[TROUBLESHOOTING.md](TROUBLESHOOTING.md) explains when to use each of these. [Troubleshooting](TROUBLESHOOTING.md) explains when to use each of these.
## Scripting with `--json` ## Scripting with `--json`
The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`, The global `--json` flag makes query commands print JSON: `status`, `ls`, `pins`, `sync list`,
`devices list`, `locations`, `transfers`, `activity`, `trash` and `cache inspect`. Commands that change `devices list`, `locations`, `transfers`, `activity`, `trash`, `cache inspect`, `members` and `du`.
something keep their human-readable output; check their exit code. `public-link create` and `public-link update` print the link they made or changed. Other commands
that change something keep their human-readable output; check their exit code.
```bash ```bash
pdfs --json status | jq -r '.mount.mountpoint' pdfs --json status | jq -r '.mount.mountpoint'
+60
View File
@@ -0,0 +1,60 @@
# Computers
Every computer that backs up folders to your account has its own entry in Proton Drive. The
**Computers** page lists this computer and every other one, including computers that run the
official Windows or macOS app.
- [What you can do](#what-you-can-do)
- [Syncing several computers](#syncing-several-computers)
- [Replacing or reinstalling a computer](#replacing-or-reinstalling-a-computer)
![The Computers page](../images/computers.png)
## What you can do
**On this computer:**
- See its synced folders and their state, and add more with **Add Folder to Sync**. See
[Synced folders](SYNCED_FOLDERS.md).
- **Restore Folders…** brings back folders that are backed up under this computer but not synced
here yet, for example after a reinstall.
**On another computer**, from its menu:
| Choose | When | What happens |
|---|---|---|
| **Browse Files** | You need a file from it | Its backup opens read-only, like My files |
| **Restore to This Computer…** | You want a copy of its folders here | Its folders download here and stay part of its backup |
| **Sync With This Computer…** | Both computers should share the same folders, such as a desktop and a laptop | Both keep the folders in step. See [below](#syncing-several-computers) |
| **Continue This Backup Here…** | This computer replaces it, after a reinstall or with new hardware | This computer takes over its backup. See [Recovery](RECOVERY.md) |
| **Move to This Computer…** | You switch for good, for example from Windows to Linux | Its folders move into this computer's backup and are offered for syncing here. The other computer stays in the list, without those folders |
`pdfs devices list` shows the same computers with their IDs, for the commands in the
[command-line reference](CLI.md#computers).
## Syncing several computers
Two or more computers can sync the same folders through one backup in Proton Drive.
1. Sync the folders on the first computer as usual.
2. On the second computer, open **Computers**, open the menu of the first computer and choose
**Sync With This Computer…**. From a terminal: `pdfs devices join UID`, with the ID from
`pdfs devices list`.
3. Choose which of its folders to sync here, and where.
From then on, both computers sync into that backup:
- A folder you add later on either computer lands there too. A folder with the same name joins
the one already there.
- A change on one computer reaches the other within about two minutes.
- When both computers change the same file within that time, each keeps the other's version under
the original name, and its own as a conflict copy named after it. See
[Conflicts](SYNCED_FOLDERS.md#conflicts).
Folders that were synced on this computer before it joined stay in its own backup.
## Replacing or reinstalling a computer
Your synced folders are on Proton Drive, so a new or reinstalled computer can pick up where the
old one stopped. [Recovering on a new computer](RECOVERY.md) walks through it step by step, and
explains what is lost with a machine and what is not.
+11 -4
View File
@@ -14,6 +14,7 @@ and where the client keeps its data.
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `~/.config/proton-drive-linux/config.json` | Settings (this page) | | `~/.config/proton-drive-linux/config.json` | Settings (this page) |
| `~/.config/proton-drive-linux/machine-id` | A random id for this machine. It names this machine's profile in a computer's backup that several computers may share |
| `~/.local/state/proton-drive-linux/cache.db` | Metadata database: names, folder tree, sync state, upload queue, photo index | | `~/.local/state/proton-drive-linux/cache.db` | Metadata database: names, folder tree, sync state, upload queue, photo index |
| `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted cache of Proton Drive metadata. Safe to delete while the service is stopped | | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted cache of Proton Drive metadata. Safe to delete while the service is stopped |
| `~/.local/state/proton-drive-linux/control.sock` | Socket the app, tray, prompt and CLI use to reach the service | | `~/.local/state/proton-drive-linux/control.sock` | Socket the app, tray, prompt and CLI use to reach the service |
@@ -38,7 +39,7 @@ Inside `content/`:
> not reached Proton Drive and exists nowhere else. > not reached Proton Drive and exists nowhere else.
Everything in these directories except the keyring is **decrypted**. See Everything in these directories except the keyring is **decrypted**. See
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). the [security model](SECURITY_MODEL.md#what-is-kept-on-this-computer).
## `config.json` ## `config.json`
@@ -60,7 +61,9 @@ fix it.
"proton_theme": true, "proton_theme": true,
"online_map": false, "online_map": false,
"tray_hidden": false, "tray_hidden": false,
"update_check": false,
"files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true }, "files_view": { "list": true, "sort": "modified", "descending": true, "folders_first": true },
"window": { "width": 980, "height": 680, "maximized": false },
"open_with": { "open_with": {
"terminal": ["alacritty", "-e"], "terminal": ["alacritty", "-e"],
"rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }] "rules": [{ "match": ["@text"], "command": ["nvim"], "terminal": true }]
@@ -78,14 +81,18 @@ fix it.
| `upload_limit` | bytes/s | none | General → Network | Shared cap for all uploads. `0` or `null` means none | | `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 | | `download_limit` | bytes/s | none | General → Network | Shared cap for all downloads, reads from the mount included |
| `ignore_patterns` | list | see below | — | Global ignore rules for synced folders | | `ignore_patterns` | list | see below | — | Global ignore rules for synced folders |
| `device_uid` | string | none | Computers → Continue This Backup Here… | The computer this machine continues. Unset means "match by hostname" | | `device_uid` | string | none | Computers → Continue This Backup Here… or Sync With This Computer… | The computer this machine syncs into. Unset means "match by hostname" |
| `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) | | `conflict_sweep` | `off` / `report` / `enforce` | `report` | — | See [`conflict_sweep`](#conflict_sweep) |
| `local_first` | bool | `true` | — | See [`local_first`](#local_first) | | `local_first` | bool | `true` | — | See [`local_first`](#local_first) |
| `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` | | `language` | gettext code | system | General → Appearance | Language of the app, tray and prompt, such as `de` or `pt_BR` |
| `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme | | `proton_theme` | bool | follow system | General → Appearance | Proton's colours instead of the system theme |
| `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap | | `online_map` | bool | `false` | General → Appearance | Street map for Places from OpenFreeMap |
| `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon | | `tray_hidden` | bool | `false` | General → Appearance | Hide the tray icon |
| `update_check` | bool | `false` | General → Updates | Ask GitHub once a day for a newer release and show it over the sidebar. Not offered where a package manager installs updates |
| `update_last_checked` | Unix seconds | none | — | Written by the update check, so a restart does not ask again within a day |
| `update_latest` | version | none | — | The latest release the update check found |
| `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` | | `files_view` | object | grid, by name | My files view menu | `list`, `sort` (`name`, `size`, `modified`), `descending`, `folders_first` |
| `window` | object | 980 × 680 | Resizing the app window | `width`, `height` (the unmaximized size), `maximized` |
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) | | `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) | | `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
@@ -106,7 +113,7 @@ Gitignore-style rules applied to every synced folder, in addition to each folder
``` ```
Set it to `[]` to turn the defaults off. See Set it to `[]` to turn the defaults off. See
[USER_GUIDE.md](USER_GUIDE.md#excluding-files-with-pdfsignore) for how ignoring behaves. [Synced folders](SYNCED_FOLDERS.md#excluding-files-with-pdfsignore) for how excluding behaves.
### `conflict_sweep` ### `conflict_sweep`
@@ -195,7 +202,7 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) | | `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`), The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
listed in [TESTING.md](TESTING.md). listed in [Testing](TESTING.md).
## The systemd service ## The systemd service
+34 -11
View File
@@ -102,7 +102,7 @@ for l in $(cat po/LINGUAS); do msgfmt --check --statistics -o /dev/null po/$l.po
| Tool | Use | | Tool | Use |
|---|---| |---|---|
| `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` | | `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` |
| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon | | `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon. When the daemon does not answer, also traces which account and address keys open (`pdfs_core::keycheck`) |
| `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory | | `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory |
| `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` | | `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` |
| `pdfs --json …` | Raw protocol payloads for query commands | | `pdfs --json …` | Raw protocol payloads for query commands |
@@ -141,27 +141,50 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository. code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration) **A user-visible change.** Update the matching page (a user guide topic, the CLI reference,
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies configuration) and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). Link between pages
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`) with relative links such as `[Conflicts](SYNCED_FOLDERS.md#conflicts)`, and show screenshots from
to the GitHub wiki, so keep their file names. `images/` with `../images/<name>.png`.
**The wiki.** `docs/` is the source of the [GitHub wiki](https://github.com/narrrl/proton-drive-linux/wiki).
`scripts/publish-wiki.sh` turns each page listed in its `SECTIONS` into a wiki page, rewrites the
links, copies the images and writes the sidebar. `docs/README.md` becomes the wiki's Home.
`BUGS.md` and `CHANGELOG.md` stay out of it; the wiki links to them on GitHub.
- `scripts/publish-wiki.sh --check` checks every link and heading anchor in `docs/`, `README.md`,
`CONTRIBUTING.md` and `SECURITY.md`. It fails on a page in `docs/` that is not in `SECTIONS`
or `EXCLUDED`. `.github/workflows/wiki.yml` runs it for every change to the documentation.
- Each release publishes the wiki from the tag. Run the **Wiki** workflow from the Actions tab to
publish `main` in between, for example after fixing a mistake.
- `scripts/publish-wiki.sh [WORKDIR]` builds and commits the wiki in a local clone without
pushing, so you can look at the result first. `--push` pushes it.
Never edit the wiki in the browser: the next publish replaces every page.
## Releasing ## Releasing
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then: Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in 1. checks that the tag, the workspace version in `Cargo.toml`, `pkgver` in
`packaging/PKGBUILD` agree; `packaging/PKGBUILD` and the default version in `packaging/proton-drive-linux.spec` agree;
2. runs the quality gates; 2. runs the quality gates;
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`; 3. builds the release binaries and packages a `.tar.gz`, a `.deb`, a GPG-signed `.rpm` and a
4. publishes them as a GitHub release; `.tar.xz` of the vendored crates, and builds the Nix flake at the tag, which is what a flake
input pinned to `vX.Y.Z` gets, and pushes it to the `proton-drive-linux` Cachix cache (secret
`CACHIX_AUTH_TOKEN`);
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
notes;
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them 5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
back to `main`. back to `main`;
6. calls `.github/workflows/copr.yml`, which builds the release in the
[COPR repository](../packaging/copr/README.md) (secret `COPR_CONFIG`);
7. calls `.github/workflows/wiki.yml`, which publishes `docs/` at the tag to the wiki.
To prepare a release: To prepare a release:
```bash ```bash
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file. # 1. Bump the version in Cargo.toml, packaging/PKGBUILD, packaging/proton-drive-linux.spec,
# po/pdfs.pot and the pinned flake URL in docs/INSTALL.md, then refresh the lock file.
cargo update --workspace cargo update --workspace
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version. # 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
# 3. Commit, tag and push. # 3. Commit, tag and push.
+104
View File
@@ -0,0 +1,104 @@
# FAQ
Short answers to common questions, with links to the details.
- [Is this an official Proton app?](#is-this-an-official-proton-app)
- [If I delete a file in `~/ProtonDrive`, is it deleted on Proton Drive?](#if-i-delete-a-file-in-protondrive-is-it-deleted-on-proton-drive)
- [Online only, available offline or synced folder: which do I want?](#online-only-available-offline-or-synced-folder-which-do-i-want)
- [Does it work offline?](#does-it-work-offline)
- [How do I get updates?](#how-do-i-get-updates)
- [Why is there no tray icon?](#why-is-there-no-tray-icon)
- [What is stored unencrypted on my computer?](#what-is-stored-unencrypted-on-my-computer)
- [Does it send telemetry?](#does-it-send-telemetry)
- [Can I use it on several computers?](#can-i-use-it-on-several-computers)
- [Can I continue a backup from Windows or macOS?](#can-i-continue-a-backup-from-windows-or-macos)
- [Can I run it on a server without a desktop?](#can-i-run-it-on-a-server-without-a-desktop)
- [Which architectures and languages are supported?](#which-architectures-and-languages-are-supported)
- [How do I report a bug?](#how-do-i-report-a-bug)
## Is this an official Proton app?
No. It is an independent, open-source client, not affiliated with or endorsed by Proton AG. It
uses a Rust port of Proton's own Drive SDK, and everything stays end-to-end encrypted. It has had
no independent security audit. See the [security model](SECURITY_MODEL.md).
## If I delete a file in `~/ProtonDrive`, is it deleted on Proton Drive?
Yes. `~/ProtonDrive` is your Proton Drive, not a copy. A deleted file moves to the Proton Drive
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.
## Online only, available offline or synced folder: which do I want?
- **Online only** for most of your Drive. Files take no space until you open them.
- **Available offline** for files in `~/ProtonDrive` you need without a connection.
- **Synced folder** for a folder that lives on this computer, such as `~/Documents`, and should be
backed up.
[Getting started](GETTING_STARTED.md#3-choose-how-files-are-kept) compares them side by side.
## Does it work offline?
Yes, for files that are on this computer: available-offline files, synced folders, and recently
opened files still in the cache. You can also create, rename, move and delete files offline. The
changes are queued and sent when the connection is back. See
[Changes waiting for Proton Drive](SYNCED_FOLDERS.md#changes-waiting-for-proton-drive).
## How do I get updates?
Through your package manager. The Fedora COPR repository, the AUR and the Nix flake get every
release, so `dnf upgrade`, your AUR helper or a flake update brings it. The `.deb` has to be
downloaded again for each release. To be told about new releases, turn on **Check for updates**
in **Preferences**. The app never updates itself. See [Installation → Updating](INSTALL.md#updating).
## Why is there no tray icon?
GNOME needs the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) to show
tray icons. If you hid the icon from its own menu, turn it back on under
**Preferences → General → Appearance**.
## What is stored unencrypted on my computer?
To show your files, the client decrypts them on this computer. Recently opened files, files kept
offline, changes waiting to upload, and the names of your files and folders are stored without
extra encryption. Use full-disk or home-folder encryption. The
[security model](SECURITY_MODEL.md#what-is-kept-on-this-computer) has the full list.
## Does it send telemetry?
No. The client connects only to Proton, and, if you turn them on, to OpenFreeMap for the street map
in Photos and to GitHub to check for updates. See
[Network connections](SECURITY_MODEL.md#network-connections).
## Can I use it on several computers?
Yes. Each computer has its own backup in Proton Drive, and two or more computers can also sync the
same folders. See [Syncing several computers](COMPUTERS.md#syncing-several-computers).
## Can I continue a backup from Windows or macOS?
Yes. On the **Computers** page, choose **Move to This Computer…** on the old computer. Its folders
move into this computer's backup on Proton Drive, without uploading anything again. See
[Moving another computer's backup here](RECOVERY.md#moving-another-computers-backup-here).
## Can I run it on a server without a desktop?
Yes, with `pdfs daemon` or `pdfs mount`. It still needs a Secret Service provider such as GNOME
Keyring or KeePassXC to hold the session. See
[Headless and server use](INSTALL.md#headless-and-server-use).
## Which architectures and languages are supported?
Release packages are for x86_64. Other architectures can [build from source](INSTALL.md#build-from-source).
The app, tray and launcher are available in English, German, Spanish, French, Italian, Dutch,
Polish and Brazilian Portuguese. [Translating](TRANSLATING.md) explains how to add a language.
## How do I report a bug?
Run `pdfs diagnose`, then [open an issue](https://github.com/narrrl/proton-drive-linux/issues/new/choose)
with what [Reporting a problem](TROUBLESHOOTING.md#reporting-a-problem) lists. Report security
problems privately, as described in [SECURITY.md](../SECURITY.md).
+95
View File
@@ -0,0 +1,95 @@
# Files and offline access
Your Proton Drive appears on this computer as a folder, `~/ProtonDrive`. Every file and folder in
it is listed straight away, but a file only downloads when a program opens it. This page covers
working in that folder, keeping files on this computer, the Trash, and earlier versions of a
file.
- [Your Drive as a folder](#your-drive-as-a-folder)
- [Keep files available offline](#keep-files-available-offline)
- [Browse in the app](#browse-in-the-app)
- [Trash](#trash)
- [Earlier versions of a file](#earlier-versions-of-a-file)
- [Choose which program opens a file](#choose-which-program-opens-a-file)
## Your Drive as a folder
`~/ProtonDrive` is your Proton Drive, called **My files** in the app. It is not a copy. A file you
change there changes on Proton Drive, and a file you delete there goes to the Proton Drive
Trash. Every program can use it, from your file manager to a terminal.
- **Only what you read is downloaded.** A video player that seeks, or a tool that reads only the
start of a file, downloads only those parts. Videos and music start playing before the whole
file is down.
- **Changes are instant, online or offline.** Creating, renaming, moving and deleting return at
once. When a program saves and closes a file, the new content is stored on this computer and
sent to Proton Drive in the background. Changes survive a lost connection, a crash or a reboot.
See [Changes waiting for Proton Drive](SYNCED_FOLDERS.md#changes-waiting-for-proton-drive).
- **Recently opened files are cached** up to a size limit (5 GiB by default, set in
**Preferences → Storage**). The oldest are removed first to make room.
- **Shared with me** is a folder inside `~/ProtonDrive` that holds what other people share with
you. Shares where you are a viewer are read-only.
Proton Drive cannot store symbolic links, hard links, device files or FIFOs. A program that tries
to create one gets an error at once. For a project that needs them, use a
[synced folder](SYNCED_FOLDERS.md) and exclude those paths.
To move the folder somewhere else, change **Preferences → General → Location**.
## Keep files available offline
Files you mark **Available offline** are downloaded completely and stay on this computer, so
they open without a connection.
- **In the app:** select a file or folder in **My files** and turn on **Available offline**.
- **From a terminal:** `pdfs pin ~/ProtonDrive/Documents/Taxes`
For a folder, every file in it and in its subfolders downloads in the background. The tray and
the app show the progress. **Preferences → Storage → Available offline** and `pdfs pins` list
what is kept.
Turn it off again with the same switch, or `pdfs unpin PATH`. This frees the space. The files stay
on Proton Drive. A file inside a folder that is kept offline stays with its folder: turn the
folder off instead.
## Browse in the app
![My files in the app](../images/files.png)
The **My files** page shows the same files without going through the folder. It has a grid and
a list view, sorting, thumbnails (including camera RAW previews), multi-select, and drag and drop
to upload. **Details** (Alt+Enter) shows the size, dates, sharing and version history of the
selected item. For a folder, the size and the number of items in it come from Proton Drive and
include items in the trash; `pdfs du PATH` prints the same.
Right-click an item for more. **Make a Copy** copies it into the same folder, and **Copy To…**
copies it, or a whole selection, into another folder in My files. The copy is made on Proton
Drive, so nothing is downloaded or uploaded, and a name already taken gets a " (1)" suffix. An
item with changes that have not reached Proton Drive yet can be copied once they have. From a
terminal: `pdfs copy PATH... NEW_PARENT`.
## Trash
Deleted files go to the Trash, both from `~/ProtonDrive` and from the app. The **Trash** page
lists them with the date they were deleted.
- **Restore** puts an item back where it was, together with any trashed folders above it.
- **Delete Forever** and **Empty Trash** are permanent.
From a terminal: `pdfs trash` lists the Trash with an ID for each item, and `pdfs restore`,
`pdfs delete-forever` and `pdfs empty-trash` take those IDs.
## Earlier versions of a file
Proton Drive keeps earlier versions of every file. Open them with **Details → Versions…** in the
app, or `pdfs versions list PATH`. You can:
- **Restore** a version. This happens on Proton Drive, so nothing is uploaded again.
- **Save** a version to a separate file, without touching the current one.
- **Delete** an old version permanently. The current version cannot be deleted.
## Choose which program opens a file
The app and the search launcher open files with your desktop's default program (`xdg-open`). To
open some file types differently, for example text files in Neovim inside a terminal, add
`open_with` rules to `config.json`. See [Configuration → `open_with`](CONFIGURATION.md#open_with).
+72
View File
@@ -0,0 +1,72 @@
# Getting started
From installing to your first synced folder in a few minutes.
> [!IMPORTANT]
> Proton Drive for Linux is an unofficial client, built by the community and not by Proton. Try it
> first with files you also have somewhere else.
## 1. Install
| Distribution | Command |
|---|---|
| Fedora 44 or newer | `sudo dnf copr enable narl-rs/proton-drive-linux`<br>`sudo dnf install proton-drive-linux` |
| Arch Linux | `paru -S proton-drive-for-linux` (or any other AUR helper) |
| Debian, Ubuntu 24.04 or newer | Download the `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then `sudo apt install ./proton-drive-linux_*_amd64.deb` |
| NixOS | See [Installation → NixOS](INSTALL.md#nixos-flake) |
On GNOME, also install the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) to see
the tray icon. [Installation](INSTALL.md) has every option, building from source, and the optional
extras.
## 2. Sign in
![The sign-in window](../images/login.png)
1. Open **Proton Drive** from your applications menu.
2. Sign in with your Proton email and password, and the two-factor code if your account has one.
If Proton asks you to prove you are human (common on a VPN), solve the challenge in the window
that opens.
3. Wait a moment. Your Proton Drive appears as the folder `~/ProtonDrive`.
The app starts the Proton Drive service for you, and the service starts by itself whenever you
log in. Your session is kept in the system keyring (GNOME Keyring, KWallet or KeePassXC), so you
sign in only once.
## 3. Choose how files are kept
There are three ways to have your files on this computer. You can mix them freely.
| | Takes disk space | Works offline | Use it for |
|---|---|---|---|
| **Online only** (the default in `~/ProtonDrive`) | Only for files you opened recently | Only files still in the cache | Most of your Drive: archives, media, anything large |
| **Available offline** | Yes | Yes | Files and folders you need on the road |
| **Synced folder** | Yes, a full copy | Yes | Folders that live on this computer, such as `~/Documents`, backed up to Proton Drive |
- **Online only:** open `~/ProtonDrive` in your file manager. Files download when you open them.
- **Available offline:** select a file or folder in the app and turn on **Available offline**.
[More about offline files](FILES.md#keep-files-available-offline).
- **Synced folder:** in the app, open **Computers** and choose **Add Folder to Sync**.
[More about synced folders](SYNCED_FOLDERS.md).
## 4. Know where to look
- **Tray icon:** shows whether everything is up to date, and pauses or resumes sync.
- **Sync → Overview** in the app: what is uploading, what is waiting, and anything that needs you.
- **Search launcher:** bind `pdfs-prompt` to a keyboard shortcut to find any file in Proton Drive
or your home folder. See [Search launcher and tray](LAUNCHER_AND_TRAY.md).
> [!WARNING]
> `~/ProtonDrive` is your Proton Drive, not a copy. Deleting a file there moves it to the Proton
> Drive Trash on every device. You can restore it from **Trash** in the app.
## Next steps
- [User guide](USER_GUIDE.md): everything the app, tray and launcher do.
- [FAQ](FAQ.md): short answers to common questions.
- [Troubleshooting](TROUBLESHOOTING.md): when something does not work.
- [Recovering on a new computer](RECOVERY.md): read this before you rely on the client for
backups.
- Prefer a terminal? `pdfs status` shows the state of everything, and the
[command-line reference](CLI.md) lists every command.
+251 -151
View File
@@ -1,70 +1,53 @@
# Installation # Installation
This page covers installing Proton Drive for Linux from a package or from source, the first Install Proton Drive for Linux from your distribution's package, sign in, and keep it up to date.
sign-in, running without a desktop, and removing the client again. This page also covers building from source, running without a desktop, and removing the client
again.
- [Requirements](#requirements)
- [Install a package](#install-a-package) - [Install a package](#install-a-package)
- [Build from source](#build-from-source) - [Optional extras](#optional-extras)
- [First run](#first-run) - [First run](#first-run)
- [Headless and server use](#headless-and-server-use)
- [Updating](#updating) - [Updating](#updating)
- [Uninstalling](#uninstalling) - [Uninstalling](#uninstalling)
- [Headless and server use](#headless-and-server-use)
## Requirements - [Build from source](#build-from-source)
- [What the packages install](#what-the-packages-install)
| | Minimum |
|---|---|
| Architecture | x86_64 (release packages); other architectures build from source |
| Kernel | FUSE 3 (`fuse3` package, `/dev/fuse` available to your user) |
| GTK / libadwaita | GTK 4.12, libadwaita 1.5 (1.8 or newer gets the newest widgets) |
| WebKitGTK | 6.0 — used only for the CAPTCHA page during sign-in |
| Secret Service | GNOME Keyring, KWallet, KeePassXC, or another provider of `org.freedesktop.secrets` |
| Rust (source builds) | 1.96 |
The session is stored only in the Secret Service. Without a running provider, sign-in succeeds but
cannot be saved, and the service cannot start unattended.
Optional runtime extras:
| Package | Adds |
|---|---|
| `exiftool` (`perl-image-exiftool`, `libimage-exiftool-perl`) | Thumbnails for camera RAW files |
| `ffmpeg` | Video thumbnails |
| `iso-codes` | Country names in your language on the Places map |
| `xdg-utils` | "Open Folder" in the tray, and the default file opener |
| `fzf` plus a terminal (foot, kitty, alacritty, …) | `pdfs-prompt --fzf`, the search-as-you-type launcher |
| AppIndicator extension (GNOME only) | The tray icon. KDE Plasma and most other desktops show it natively |
## Install a package ## Install a package
Every [release](https://github.com/narrrl/proton-drive-linux/releases/latest) ships a `.deb`, a | Distribution | Package | Updates |
signed `.rpm` and a `.tar.gz` of the raw binaries. All packages install the same files: |---|---|---|
| [Fedora 44 or newer](#fedora-44-or-newer) | COPR repository `narl-rs/proton-drive-linux` | With `dnf upgrade` |
| [Arch Linux](#arch-linux-aur) | `proton-drive-for-linux` in the AUR | With your AUR helper |
| [Debian, Ubuntu 24.04 or newer](#debian-and-ubuntu-2404-or-newer) | `.deb` from the release page | Download each release |
| [NixOS](#nixos-flake) | The flake in this repository | With `nix flake update` |
| [Anything else](#other-distributions) | `.tar.gz` from the release page, or [build from source](#build-from-source) | By hand |
| Path | Purpose | Release packages are for x86_64. You also need a Secret Service provider, such as GNOME
|---|---| Keyring, KWallet or KeePassXC, to keep your session. Most desktops have one already.
| `/usr/bin/pdfs` | Command-line client and background service |
| `/usr/bin/pdfs-app` | Desktop app |
| `/usr/bin/pdfs-tray` | Tray icon |
| `/usr/bin/pdfs-prompt` | Search launcher |
| `/usr/lib/systemd/user/proton-drive.service` | User service that keeps the drive mounted |
| `/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop` | Starts the tray at login |
| `/usr/share/locale/*/LC_MESSAGES/pdfs.mo` | Translations |
### Debian and Ubuntu (24.04 or newer)
```bash
sudo apt install ./proton-drive-linux_*_amd64.deb
```
### Fedora (44 or newer) ### Fedora (44 or newer)
Enable the [COPR repository](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/)
and install the package:
```bash
sudo dnf copr enable narl-rs/proton-drive-linux
sudo dnf install proton-drive-linux
```
COPR builds every release for Fedora 44, 45 and Rawhide, so `sudo dnf upgrade` keeps the client
up to date along with the rest of your system.
You can also download the signed `.rpm` from the
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest). It installs the
same files, but you have to download each new version yourself:
```bash ```bash
sudo dnf install ./proton-drive-linux-*.x86_64.rpm sudo dnf install ./proton-drive-linux-*.x86_64.rpm
``` ```
For a Fedora package built from your own checkout, see To build an RPM from your own checkout, see [Build an RPM locally](#build-an-rpm-locally).
[Build an RPM locally](#build-an-rpm-locally).
### Arch Linux (AUR) ### Arch Linux (AUR)
@@ -72,21 +55,33 @@ Three packages cover the usual choices. They conflict with each other, so instal
| Package | Contents | | Package | Contents |
|---|---| |---|---|
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | Latest tagged release, built from source | | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | Latest release, built from source |
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | Latest tagged release, prebuilt binaries | | [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | Latest release, prebuilt binaries |
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | `main`, built from git | | [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | The newest code on `main`, built from git |
```bash ```bash
paru -S proton-drive-for-linux # or yay, or any other AUR helper paru -S proton-drive-for-linux # or yay, or any other AUR helper
``` ```
The recipes live in [`packaging/aur/`](../packaging/aur/README.md). The recipes are in [`packaging/aur/`](../packaging/aur/README.md).
### Debian and Ubuntu (24.04 or newer)
Download the `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest)
and install it:
```bash
sudo apt install ./proton-drive-linux_*_amd64.deb
```
There is no APT repository yet, so repeat this for each new release. Turn on **Check for
updates** in **Preferences** to be told when one is out.
### NixOS (flake) ### NixOS (flake)
The repository is a flake. Add it as an input and enable the module, which installs the package, The repository is a flake. Add it as an input and enable the module. The module installs the
links the user service into every graphical session and turns on the `fusermount3` wrapper and package, starts the user service in every graphical session, and turns on the `fusermount3`
GNOME Keyring for you: wrapper and GNOME Keyring for you:
```nix ```nix
{ {
@@ -103,25 +98,201 @@ GNOME Keyring for you:
} }
``` ```
To try it without changing your system, run the app straight from the flake: That URL follows `main`. To stay on releases, pin the input to a release tag instead, and change
the tag to update:
```nix
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.6.0";
```
Each release tag is built with Nix before the release is published.
#### Binary cache
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
download the client instead of compiling it. A system build does not read a flake input's cache
settings, so add the cache to your NixOS configuration once:
```nix
nix.settings = {
substituters = [ "https://proton-drive-linux.cachix.org" ];
trusted-public-keys = [
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
];
};
```
The cache holds the build against the `nixpkgs` this flake locks. Setting
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead,
which the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use
the cache the first time; answer yes, or run `cachix use proton-drive-linux`.
To try it without changing your system, run it straight from the flake:
```bash ```bash
nix run github:narrrl/proton-drive-linux#proton-drive-linux -- --help # the CLI nix run github:narrrl/proton-drive-linux#proton-drive-linux -- --help # the CLI
nix shell github:narrrl/proton-drive-linux -c pdfs-app # the desktop app nix shell github:narrrl/proton-drive-linux -c pdfs-app # the desktop app
``` ```
The mount itself needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default The mount needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default with
with the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg and the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg, and with
with it video thumbnails, override the package with `withFfmpeg = false`. The recipe lives in it video thumbnails, override the package with `withFfmpeg = false`. The recipe is in
[`packaging/nix/`](../packaging/nix/). [`packaging/nix/`](../packaging/nix/).
### Other distributions ### Other distributions
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in Unpack the `.tar.gz` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest)
[Using a binary outside `/usr/bin`](#using-a-binary-outside-usrbin). into a directory on your `PATH`, then install the service as shown in
[Using a binary outside `/usr/bin`](#using-a-binary-outside-usrbin). The binaries need GTK 4.12,
libadwaita 1.5, WebKitGTK 6.0 and FUSE 3; see [Requirements](#requirements).
## Optional extras
The packages pull in what the client needs. These add features if you install them:
| Package | Adds |
|---|---|
| `exiftool` (`perl-image-exiftool`, `libimage-exiftool-perl`) | Thumbnails for camera RAW files |
| `ffmpeg` | Video thumbnails |
| `iso-codes` | Country names in your language on the Places map |
| `xdg-utils` | "Open Folder" in the tray, and the default file opener |
| `fzf` and a terminal (foot, kitty, alacritty, …) | `pdfs-prompt --fzf`, the search-as-you-type launcher |
| [AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/) (GNOME only) | The tray icon. KDE Plasma and most other desktops show it without help |
## First run
### With the desktop app
1. Open **Proton Drive** from your applications menu, or run `pdfs-app`.
2. Sign in with your Proton email and password, and your two-factor code if the account has one.
If Proton asks for a CAPTCHA (common on a VPN or a new network), the app shows the challenge in
a window and continues after you solve it.
3. The app enables and starts `proton-drive.service`. Your Drive appears at `~/ProtonDrive`.
The service belongs to your desktop session. It starts after you log in and stops when you log
out. To put the folder somewhere else, change **Preferences → General → Location**.
[Getting started](GETTING_STARTED.md) explains what to do next.
### With the command line
`pdfs login` stores the session but does not enable the service, so a setup that runs the service
another way stays untouched. Enable it yourself:
```bash
pdfs login # email, password, 2FA code
systemctl --user enable --now proton-drive.service
pdfs status # account, mount and queue state
```
`pdfs login` prints the address of the CAPTCHA page if Proton asks for one: open it in your
browser, solve it, then press Enter.
## Updating
| Installed from | Update with |
|---|---|
| COPR (Fedora) | `sudo dnf upgrade` |
| AUR | `paru -Syu`, or your AUR helper's equivalent |
| Nix flake | `nix flake update proton-drive-linux`, or change the pinned tag, then rebuild |
| `.deb`, release `.rpm`, `.tar.gz` | Download the new release and install it over the old one |
The service switches to the new version the next time it starts. To switch at once:
```bash
systemctl --user restart proton-drive.service
```
With the `.deb`, the release `.rpm` or the `.tar.gz`, nothing tells you about a new release.
Turn on **Preferences → General → Check for updates**: the app asks GitHub once a day and shows a
banner over the sidebar when a newer version is out. The app never installs updates itself.
Packages that a package manager updates do not offer the setting. A package marks itself that way
by installing the empty file `share/proton-drive-linux/managed-updates` under the prefix of its
`bin/` directory, for example `/usr/share/proton-drive-linux/managed-updates`.
Coming from 2.8.0 or older, re-enable the service once. Those releases started it before the
desktop session was ready, and on KDE that crashes `ksecretd` at every login. Signing in again or
toggling **Start on login** does this as well:
```bash
systemctl --user reenable proton-drive.service
```
Database upgrades run by themselves and only move forward. A newer release's database does not
open in an older release, so going back to an older version means clearing the local database
(see [Troubleshooting](TROUBLESHOOTING.md#start-over-with-a-clean-local-state)).
## Uninstalling
> [!WARNING]
> Check that nothing is waiting to upload before you remove anything. `pdfs sync queue` must be
> empty. Changes that have not reached Proton Drive exist only on this computer.
1. Stop the service and sign out:
```bash
pdfs sync queue # must list nothing
systemctl --user disable --now proton-drive.service
pdfs logout # removes the session from the keyring
```
2. Remove the package:
| Installed from | Remove with |
|---|---|
| COPR (Fedora) | `sudo dnf remove proton-drive-linux`, then `sudo dnf copr remove narl-rs/proton-drive-linux` |
| Release `.rpm` | `sudo dnf remove proton-drive-linux` |
| `.deb` | `sudo apt remove proton-drive-linux` |
| AUR | `sudo pacman -R proton-drive-for-linux` (or the `-bin` or `-git` package) |
| NixOS | Remove the module and the flake input, then rebuild |
Removing the package also stops the service of every logged-in user, in case step 1 was
skipped.
3. If you want no trace left, remove the local data:
```bash
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
```
4. Sign the device out in your Proton account settings. If you no longer want this computer's
backups, remove it from **Computers** in the Proton Drive web app.
> [!CAUTION]
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
> file deleted there is moved to the Proton Drive trash. Once the service is stopped, the folder
> is empty, and removing it is safe.
## Headless and server use
The packaged service starts with a desktop session. On a machine without one, run the service
directly or from your own systemd unit:
```bash
pdfs daemon # waits for a stored session, mounts, stays mounted
pdfs mount /srv/proton # mounts once at a given path, blocks until unmounted
```
Both still need a Secret Service provider for the session. On a server, run
`gnome-keyring-daemon --unlock` or KeePassXC's Secret Service integration inside the user's D-Bus
session.
## Build from source ## Build from source
### Requirements
| | Minimum |
|---|---|
| Architecture | Any that Rust and GTK support. Release packages are x86_64 only |
| Kernel | FUSE 3 (`fuse3` package, `/dev/fuse` available to your user) |
| GTK / libadwaita | GTK 4.12, libadwaita 1.5 (1.8 or newer gets the newest widgets) |
| WebKitGTK | 6.0, used only for the CAPTCHA page during sign-in |
| Secret Service | GNOME Keyring, KWallet, KeePassXC, or another provider of `org.freedesktop.secrets` |
| Rust | 1.96 |
The session is stored only in the Secret Service. Without a running provider, sign-in succeeds
but cannot be saved, and the service cannot start unattended.
### Build dependencies ### Build dependencies
**Debian / Ubuntu 24.04+** **Debian / Ubuntu 24.04+**
@@ -144,8 +315,8 @@ sudo dnf install -y cargo rust gettext pkgconf-pkg-config fuse3-devel gtk4-devel
sudo pacman -S --needed rust gettext pkgconf fuse3 gtk4 libadwaita webkitgtk-6.0 libsecret dbus sudo pacman -S --needed rust gettext pkgconf fuse3 gtk4 libadwaita webkitgtk-6.0 libsecret dbus
``` ```
Install Rust 1.96 or newer with [rustup](https://rustup.rs) if your distribution ships an older If your distribution ships a Rust older than 1.96, install a newer one with
toolchain. [rustup](https://rustup.rs).
### Compile ### Compile
@@ -156,7 +327,7 @@ cargo build --release --locked
po/build.sh target/locale # optional: compile the translations po/build.sh target/locale # optional: compile the translations
``` ```
The four binaries are in `target/release/`: `pdfs`, `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. The four programs are in `target/release/`: `pdfs`, `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
### Build a package from the working tree ### Build a package from the working tree
@@ -182,7 +353,7 @@ sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
### Using a binary outside `/usr/bin` ### Using a binary outside `/usr/bin`
The service unit starts `/usr/bin/pdfs daemon`. If you installed the binaries somewhere else, for The service unit starts `/usr/bin/pdfs daemon`. If you installed the programs somewhere else, for
example `~/.local/bin`, install the unit for your user and point it at your binary: example `~/.local/bin`, install the unit for your user and point it at your binary:
```bash ```bash
@@ -194,91 +365,20 @@ systemctl --user daemon-reload
``` ```
To start the tray at login, copy `packaging/io.narl.proton-drive-linux-tray.desktop` to To start the tray at login, copy `packaging/io.narl.proton-drive-linux-tray.desktop` to
`~/.config/autostart/`. For the app launcher entry, copy `packaging/io.narl.proton-drive-linux.desktop` `~/.config/autostart/`. For the entry in your applications menu, copy
to `~/.local/share/applications/` and the icon `packaging/io.narl.proton-drive-linux.svg` to `packaging/io.narl.proton-drive-linux.desktop` to `~/.local/share/applications/` and the icon
`~/.local/share/icons/hicolor/scalable/apps/`. `packaging/io.narl.proton-drive-linux.svg` to `~/.local/share/icons/hicolor/scalable/apps/`.
## First run ## What the packages install
### With the desktop app Every package installs the same files:
1. Open **Proton Drive** from your application menu, or run `pdfs-app`. | Path | Purpose |
2. Sign in with your Proton email and password, and your two-factor code if the account has one. |---|---|
If Proton asks for a CAPTCHA (common on a VPN or a new network), the app shows the challenge in | `/usr/bin/pdfs` | Command-line client and background service |
a window and continues after you solve it. | `/usr/bin/pdfs-app` | Desktop app |
3. The app enables and starts `proton-drive.service`. Your Drive appears at `~/ProtonDrive`. | `/usr/bin/pdfs-tray` | Tray icon |
| `/usr/bin/pdfs-prompt` | Search launcher |
The service is tied to your graphical session. It starts after you log in and stops when you log | `/usr/lib/systemd/user/proton-drive.service` | User service that keeps the drive mounted |
out. Change the folder location under **Preferences → General → Location**. | `/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop` | Starts the tray at login |
| `/usr/share/locale/*/LC_MESSAGES/pdfs.mo` | Translations |
### With the command line
`pdfs login` stores the session but does not enable the service, so a setup that runs the daemon
another way stays untouched. Enable the service yourself:
```bash
pdfs login # email, password, 2FA code
systemctl --user enable --now proton-drive.service
pdfs status # account, mount and queue state
```
The CLI cannot show a CAPTCHA. If sign-in reports one, sign in once with `pdfs-app` instead; the
session it stores is used by the CLI and the service as well.
## Headless and server use
The service unit is bound to `graphical-session.target`. On a machine without a desktop session,
run the daemon directly or from your own unit:
```bash
pdfs daemon # waits for a stored session, mounts, stays mounted
pdfs mount /srv/proton # mounts once at a given path, blocks until unmounted
```
Both still need a Secret Service provider. On a server, run
`gnome-keyring-daemon --unlock` or KeePassXC's Secret Service integration inside the user's D-Bus
session.
## Updating
Install the new package over the old one. The service picks up the new binary the next time it
starts; to switch at once:
```bash
systemctl --user restart proton-drive.service
```
Coming from 2.8.0 or older, re-enable the service once. Those releases hooked it into
`default.target`, which starts it before the desktop session is ready; on KDE that crashes
`ksecretd` at every login. Signing in again or toggling "Start on login" does this as well:
```bash
systemctl --user reenable proton-drive.service
```
Database migrations run automatically and only move forward. A database written by a newer
release refuses to open in an older one, so downgrading means clearing the local database (see
[Troubleshooting](TROUBLESHOOTING.md#start-over-with-a-clean-local-state)).
## Uninstalling
> [!WARNING]
> Check that nothing is waiting to upload before you remove local state. `pdfs sync queue` must be
> empty. Queued writes exist only on this computer until they reach Proton Drive.
```bash
pdfs sync queue # must list nothing
systemctl --user disable --now proton-drive.service
pdfs logout # removes the session from the keyring
```
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left,
the local data:
```bash
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
```
Sign out of the device in your Proton account settings as well, and remove the computer from
**Computers** in the Proton Drive web app if you no longer want its backups.
+86
View File
@@ -0,0 +1,86 @@
# Search launcher and tray
The search launcher finds files in Proton Drive and in your home folder from a keyboard shortcut.
The tray icon shows whether everything is up to date and gives quick access to the app.
- [Search launcher](#search-launcher)
- [Using your own launcher](#using-your-own-launcher)
- [Search as you type with fzf](#search-as-you-type-with-fzf)
- [Tray](#tray)
## Search launcher
![The search launcher](../images/prompt_gtk.png)
`pdfs-prompt` searches Proton Drive and your home folder together, in one list with the best
matches first. It copes with prefixes, abbreviations, typos and swapped letters, and also matches
the names of parent folders.
To set it up, bind `pdfs-prompt` to a keyboard shortcut in your desktop's settings.
The window stays loaded after the first use, so later shortcuts open it instantly. Folders, music
and videos open straight from `~/ProtonDrive`, so players can start before the whole file is down.
Other files from Proton Drive download first, then open.
To make even the first shortcut instant, start the launcher hidden when you log in.
`pdfs-prompt --preload` loads the window without showing it, and does nothing if the launcher is
already running. For example, in Hyprland:
```ini
exec-once = pdfs-prompt --preload
bind = SUPER, space, exec, pdfs-prompt
```
Which program opens a file can be changed per file type. See
[Choose which program opens a file](FILES.md#choose-which-program-opens-a-file).
## Using your own launcher
`pdfs-prompt --dmenu` shows the results in fuzzel, rofi, wofi, tofi, bemenu or dmenu, whichever is
installed:
```bash
pdfs-prompt --dmenu
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
pdfs-prompt --dmenu --query invoice
```
These launchers filter a fixed list and cannot ask for new results while you type, so the search
takes two steps:
1. The first list, `Search Drive ›`, shows your available-offline files. Type a search and press
Enter.
2. The second list, `Drive: invoice ›`, shows the results. Enter opens one. Typing something else
searches again.
## Search as you type with fzf
![The search launcher in fzf](../images/prompt_fzf.png)
`pdfs-prompt --fzf` searches again on every key press, like the built-in window. It needs `fzf`.
Started from a keyboard shortcut, it opens a terminal by itself (foot, ghostty, kitty, alacritty,
wezterm or xterm) with the app ID `pdfs-prompt`, so one window rule covers all of them. For
example, in Hyprland:
```ini
windowrulev2 = float, class:^(pdfs-prompt)$
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
windowrulev2 = center, class:^(pdfs-prompt)$
bind = SUPER, space, exec, pdfs-prompt --fzf
```
To make `--dmenu` or `--fzf` the default without changing your shortcut, set `prompt.mode` in
`config.json` (see [Configuration → `prompt`](CONFIGURATION.md#prompt)). `--gtk` always opens the
built-in window.
## Tray
`pdfs-tray` starts when you log in and shows whether your files are up to date, syncing, paused or
offline. Its menu opens the app or `~/ProtonDrive`, pauses or resumes sync, and stops or connects
the service.
- **GNOME** shows tray icons only with the
[AppIndicator extension](https://extensions.gnome.org/extension/615/appindicator-support/).
KDE Plasma and most other desktops show it without help.
- **Hide Tray Icon** in its menu removes it until you turn it back on under
**Preferences → General → Appearance → Show tray icon**.
+77
View File
@@ -0,0 +1,77 @@
# Photos
The **Photos** page of the app shows your Proton Photos library. Photos are stored apart from
My files, so they do not appear in `~/ProtonDrive`.
- [Timeline](#timeline)
- [Albums](#albums)
- [Places](#places)
- [Viewer and duplicates](#viewer-and-duplicates)
- [Importing from Google Photos](#importing-from-google-photos)
- [Fixing photos filed under the wrong date](#fixing-photos-filed-under-the-wrong-date)
![The Photos timeline](../images/photos.png)
## Timeline
- The timeline groups photos by day, month or year as you zoom. Drag the scrubber on the right
edge to jump through the years.
- **On this day** shows photos taken on the same date in earlier years.
- **Filters** narrow the timeline to images, videos or RAW files, to favorites, or to photos that
are in no album.
- **RAW + JPEG pairs** of the same shot show as one tile. The viewer switches between the two, and
moving the tile to the Trash removes both.
- **Upload** adds photos from this computer.
## Albums
**Albums** lists your own albums and the albums others share with you. Create, rename and delete
albums, and add or remove photos. Deleting an album keeps its photos in the timeline.
## Places
![Places on the map](../images/photos_map.png)
**Places** groups photos by the town they were taken in, as cards or on a map. Town names come
from [GeoNames](https://www.geonames.org/) data (CC BY 4.0) and the map from
[Natural Earth](https://www.naturalearthdata.com/) (public domain). Both ship with the app, so
nothing is fetched online.
A street map from [OpenFreeMap](https://openfreemap.org/) (© OpenStreetMap contributors) can be
turned on under **Preferences → General → Appearance**. It is off by default, because the map
server learns which areas you look at.
## Viewer and duplicates
- The viewer marks favorites, shows EXIF details, shows a photo on the map, and runs a slideshow
(F5).
- **Find Duplicates…** in the page menu lists byte-identical copies. Turn on **Similar** to also
find resized or re-encoded copies and burst shots.
## Importing from Google Photos
1. Request a [Google Takeout](https://takeout.google.com/) export of Google Photos and download
every `.zip` of it.
2. In the app, open the **Upload** menu on the Photos page and choose **Import from Google
Photos…**. Select all the `.zip` files at once, because a photo and its details are often in
different parts of the export.
From a terminal:
```bash
pdfs import-google-photos --dry-run ~/Downloads/takeout-*.zip # show what would happen
pdfs import-google-photos --wait ~/Downloads/takeout-*.zip
```
The import recreates your albums and takes capture dates and locations from the export. Photos
already in your library are skipped, so an interrupted import can simply be run again.
`pdfs import-status` shows how far an import is, and `pdfs cancel-import` stops it.
## Fixing photos filed under the wrong date
Photos from an earlier import sometimes sit under the day they were uploaded instead of the day
they were taken. `pdfs redate-photos --dry-run` finds those whose file name carries a different
date, and `pdfs redate-photos` fixes them.
Proton cannot change a capture date in place. So each fix uploads the photo again with the right
date and moves the original to the Trash. Favorites and albums are kept.
+39 -9
View File
@@ -1,18 +1,48 @@
# Documentation # Proton Drive for Linux documentation
## For users Proton Drive for Linux is an unofficial Proton Drive client for the Linux desktop. Your Drive
appears as a folder that downloads files when you open them, local folders are backed up and kept
in sync, and a GTK app with a tray icon and a search launcher covers Photos, sharing and more. A
command-line client does all of it from a terminal.
These pages are also published as the
[wiki](https://github.com/narrrl/proton-drive-linux/wiki), generated from the
[`docs/`](https://github.com/narrrl/proton-drive-linux/tree/main/docs) folder of the repository.
## Where to start
- **New here?** [Getting started](GETTING_STARTED.md) takes you from installing to your first
synced folder.
- **Installing or updating?** [Installation](INSTALL.md) covers Fedora (COPR), Arch (AUR),
Debian and Ubuntu, NixOS, and building from source.
- **Something not working?** [Troubleshooting](TROUBLESHOOTING.md) starts with `pdfs diagnose`
and goes through common problems.
- **New computer, or the old one is gone?** [Recovering on a new computer](RECOVERY.md) gets your
synced folders back.
- **Quick question?** Try the [FAQ](FAQ.md).
## Using it
| Page | What it covers |
|---|---|
| [User guide](USER_GUIDE.md) | How the pieces fit together, the words the app uses, preferences |
| [Files and offline access](FILES.md) | The `~/ProtonDrive` folder, keeping files available offline, the Trash, earlier versions |
| [Synced folders](SYNCED_FOLDERS.md) | Backing up local folders, excluding files, changes waiting to upload, conflicts, pausing |
| [Photos](PHOTOS.md) | Timeline, albums, places, duplicates, importing from Google Photos |
| [Sharing](SHARING.md) | Invitations, public links, items shared with you |
| [Computers](COMPUTERS.md) | Other computers in your account, syncing several computers |
| [Search launcher and tray](LAUNCHER_AND_TRAY.md) | Finding files from a keyboard shortcut, the tray icon |
## Reference
| Page | What it covers | | Page | What it covers |
|---|---| |---|---|
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` | | [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service | | [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug | | [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
| [Changelog](CHANGELOG.md) | What changed in each release | | [Changelog](CHANGELOG.md) | What changed in each release |
## For contributors ## Contributing
| Page | What it covers | | Page | What it covers |
|---|---| |---|---|
@@ -23,5 +53,5 @@
| [Roadmap](ROADMAP.md) | Planned work and verification still owed | | [Roadmap](ROADMAP.md) | Planned work and verification still owed |
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status | | [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository Also see [CONTRIBUTING.md](../CONTRIBUTING.md), and [SECURITY.md](../SECURITY.md) to report a
root. vulnerability privately.
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more