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).
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).
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).
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).
- 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).
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).
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.
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).
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).
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).
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).
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).
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).
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).
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).
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).
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).
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
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
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
GTK ships only a few stock icons itself. On a NixOS session without
Adwaita installed, such as Hyprland, the Photos and Computers sidebar
entries and about twenty other icons showed as missing (#37).
adwaita-icon-theme in buildInputs puts it on the wrapper's XDG_DATA_DIRS.
On NixOS the CAPTCHA window failed with "TLS support is not available":
WebKitGTK's GIO had no TLS backend. With glib-networking in buildInputs,
wrapGAppsHook4 puts its GIO modules on GIO_EXTRA_MODULES in every
wrapper.
Refs #37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
Builds the four binaries with rustPlatform.buildRustPackage from
Cargo.lock, installs the desktop entries, icon, user service and
translations as the other packages do, and wraps the binaries with
exiftool and ffmpeg on PATH. The NixOS module installs the package,
pulls proton-drive.service into the graphical session with the
fusermount3 wrapper on its PATH, and enables FUSE and GNOME Keyring.
A Nix workflow builds the flake when its recipe, the lock files or the
code change.
Refs #37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz
A partial write whose base was not in the content cache queued an
incomplete revision, and every write open over it was refused with EIO
until the drain uploaded it. CI's one-client-lan simulation hit this on
seed 1: a create over a file whose partial edit had not drained yet.
The write open now copies the blob and fills its gaps from the remote
base with Core::fill_gaps, as the drain does, and fails only when that
fill fails (B204).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuyxByBpK3fSWtyPkugeHz