Compare commits

..
Author SHA1 Message Date
nvrl e402981708 fix(packaging): ship the Adwaita icons with the Nix package
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.
2026-10-11 18:55:00 +02:00
Claude 453c439b74 fix(packaging): give the Nix package TLS for WebKitGTK
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
2026-10-10 08:23:21 +00:00
Claude ceba0f5849 feat(packaging): add a Nix flake and NixOS module
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
2026-10-09 22:07:20 +00:00
Claude 3e0db8cf46 fix(fuse): fill an incomplete queued revision's gaps on a write open
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
2026-10-09 11:36:46 +00:00
nvrl 2a86830e72 Merge branch 'fix/issue-35-dead-session'
Issue #35: a refused session showed as offline forever, the drain
workers spun while offline, and refreshed tokens could be lost or
overwritten (B200-B203, B61). Needs proton-sdk 0.8.2.
2026-10-09 12:34:38 +02:00
nvrl 43031ecf99 chore(deps) updated sdk to 0.8.2 2026-10-09 12:34:02 +02:00
nvrl 2b6c37f543 fix(fuse,core,cli,gui): say when the keyring refuses the refreshed sign-in
A rotation the keyring refused was retried and kept in the kernel
keyring, but only the log said so, and a reboot before the keyring took
it still signed the user out.

`Response::Status` carries `tokens_unsaved` while one waits. The tray
says "Sign-in not saved — unlock your keyring" and needs attention, the
app's status says the same, `pdfs status` prints a Keyring line and
`pdfs diagnose` warns. New strings are translated in every catalog
(B61).
2026-10-09 12:22:02 +02:00
nvrl 3458e2e72d fix(core): keep refused token rotations in the kernel keyring
A rotation the Secret Service refused lived only in the daemon's
memory, so a crash or SIGKILL before the retry landed lost the only live
refresh token.

Keep it in the kernel keyring as well until it is written. The next
resume, by the daemon or `pdfs unlock`, puts it back in when the stored
blob still holds the refresh token it spent, and writes it; one left
over from an older login is dropped. `kernelkey` now holds named keys,
the passphrases and the unsaved tokens (B61).
2026-10-09 12:14:25 +02:00
nvrl 0ac5928138 fix(fuse,core,cli): resume a session another process refreshed
`pdfs unlock` resumes the daemon's session and may refresh it. The
daemon then holds a spent refresh token, and since B202 a refusal of it
read as an expired session although the keyring held a live one.

Before calling a refused session expired, compare the keyring with what
this process last stored. When it holds a newer rotation or another
login, end the mount with `MountOutcome::ResumeStored` and mount again
from the keyring. A rotation another process already wrote counts as
this session's, so the daemon keeps writing its later ones (B203).
2026-10-09 12:08:17 +02:00
nvrl beb3b2e4e4 fix(core): retry rotated tokens the keyring refused
A failed keyring write after a refresh was only a warning, and the one
live refresh token then lived in memory only (B61). The sink now keeps
the rotation as unsaved and retries it in the background after 1 s, 5 s,
30 s and then every 5 minutes, until it is written or the blob has moved
on. The refusal is logged as an error naming `pdfs login`.

docs/BUGS.md B61.
2026-10-09 11:29:31 +02:00
nvrl bf43476e88 fix(fuse,core,cli,gui): show a refused session as expired, not offline
A refresh token Proton refused, or a deleted or disabled account, was
treated as a lost connection: the mount came up "offline", the probe
retried forever and logged only at debug, and nothing told the user that
only signing in again helps (issue #35).

session_revoked() names those errors. The root fetch, probe, drain,
event poll, event seed and photos root now set session_expired, log one
warning naming `pdfs login`, and stop retrying. Cached files stay
readable and changes stay queued.

Response::Status carries session_expired (serde default false). The
tray shows "Session expired — sign in again" with a Sign In Again…
item that opens the app's sign-in page; the app's status and Sync card
say the same with a button. `pdfs status` and `pdfs diagnose` report it.
New strings are translated in every catalog.

docs/BUGS.md B202.
2026-10-09 11:27:09 +02:00
nvrl 6363125fec fix(core,cli): write rotated tokens only into their own session's blob
Proton refresh tokens are single-use. store_tokens replaced the tokens of
whatever blob was stored, so after `pdfs login` restarted the daemon, the
old daemon's shutdown persist wrote its spent tokens into the new
session's blob, and the new daemon failed with InvalidRefreshToken
(issue #35). `pdfs unlock` and the legacy migration had the same shape.

Each session now writes through a TokenSink that only touches a blob
with its session id that still holds the refresh token it last left
there. Shutdown only flushes a rotation the keyring refused. unlock and
migrate_legacy re-read the blob after verifying.

docs/BUGS.md B201.
2026-10-09 11:08:40 +02:00
nvrl 51d114a8da fix(fuse,core): idle drain workers no longer spin while offline
Offline or paused, a drain worker claims nothing and then slept until the
earliest queued op fell due. Every queued op was already due and nothing
attempted it, so the wait was zero and all 16 workers spun on the queue
for the whole outage (issue #35: 17 min of CPU in 2 min 16 s).

A worker that claims nothing now sleeps the full idle poll; a reconnect
or resume wakes it. earliest_due_at also uses the claim's exclusions, so
an op held behind a claimed op on the same node no longer counts as due.

docs/BUGS.md B200.
2026-10-09 11:05:12 +02:00
Nils Pukropp 31be27f30e chore(aur): update packages to 3.2.0 2026-10-09 01:14:52 +00:00
nvrl 4c498d559c Merge pull request #32 from narrrl/fix/captcha-in-browser
fix(gui,cli,core): offer the CAPTCHA in the browser when the web view…
2026-10-09 03:04:10 +02:00
nvrl 7fd3439b60 Merge remote-tracking branch 'origin/main' into pr32-merge
# Conflicts:
#	docs/CHANGELOG.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
2026-10-09 03:03:31 +02:00
nvrl c4847c4d52 chore(release): 3.2.0 2026-10-09 02:56:26 +02:00
nvrl ad49e32de9 fix(gui): context menus size to fit instead of scrolling
A popover is sized and placed again when its parent's layout manager
allocates it. Context menus were parented to the outermost list view,
and the keyboard menu to the ScrolledWindow; neither has a layout
manager, so a menu kept the size it was first given and often opened
too short, with a scrollbar.

The host is now the first widget from the list view up that has a
layout manager, which still keeps menus out of list items and the theme
rules that style their insides.
2026-10-09 02:44:00 +02:00
nvrl 19854b2d76 fix(gui): tidy the Share dialog
Send Invitations and Create Public Link sat alone in boxed-list rows,
which read as broken settings. They now sit under their section at its
right edge. The role picker moves into the address row it applies to,
people get avatars, a member needs no subtitle, and invitations say they
are pending. The People and Public link sections show a loading row
until the share is read, and one line instead of nothing when it can't
be. The page is an AdwPreferencesPage, for the standard spacing.

Translated in every catalog.
2026-10-09 02:41:07 +02:00
nvrl 085335ca14 feat(gui): one main menu, on the sidebar's account row
The sidebar header had a hamburger beside the brand, and the account row
in the footer had its own ⋮ menu; both offered Preferences. The header
button sat next to the page's own tools and read as one of them.

The account row is now the main menu's button, as in Proton's own apps:
Preferences, Keyboard Shortcuts and About, then Sign Out. The whole row
opens it, upwards, and F10 still does as the primary menu.
2026-10-09 02:37:25 +02:00
nvrl 9fb7e84d41 fix(fuse,gui): a stopped share leaves Shared by me at once (B199)
Stop Sharing removes the link, invitations and members but cannot delete
the share itself, and Drive keeps listing the empty share until its
backend removes it, minutes later.

The daemon now leaves a share with no member, invitation or link out of
the listing (unless a lookup failed, so a live share is never hidden),
and the app drops stopped items from the page as soon as the requests
succeed instead of waiting for the slow reload.
2026-10-09 02:34:03 +02:00
nvrl 7ebe8e5210 fix(fuse,core,gui): routine mirror scans no longer show as syncing
Every mirror pass, the two-minute poll included, reported a "Checking
{folder}" job, and the tray took any job for syncing, so its icon spun
every two minutes over folders with nothing to do.

JobItem gains a background flag (absent from older daemons, so false)
for housekeeping that changes nothing in the user's files: a pass still
scanning, the home index rebuild and the cache integrity check. The tray
keeps them in its menu line but only turns busy for transfers or a job
that is not background; a pass that queues work becomes a Syncing job
and still spins the icon.

The watcher also reconciles only the folder an event lies in, instead of
walking every mirror folder over one edit.
2026-10-09 02:27:50 +02:00
nvrl 922399b9a2 fix(gui): even breadcrumb spacing, no invented hyphens in tile names
The current folder in the breadcrumb trail was a bare label, so the "›"
before it hugged it while the ones between buttons sat in their padding.
One helper now builds the separator and the current segment for My Files,
Shared with me and Computers, with a flat button's side padding.

Grid tile names broke mid-word with a hyphen that is not in the name
("SteamSetup.e-xe"); turn Pango's inserted hyphens off.
2026-10-09 02:18:47 +02:00
nvrl 3b80e9bc8f test(core): make under-budget cache check deterministic
The timing ratio between a near-empty and a 2500-entry index measured the
CI runner's load and flaked on main. Plant an oversized index row behind
the running total instead: any budget check that consults the index would
evict and reseed, so the test fails without depending on wall-clock time.
2026-10-08 21:26:56 +02:00
nvrl dc8eb27c99 Merge pull request #34 from narrrl/fix/sync-unreadable-subfolder
fix(fuse): skip unreadable folders in sync, never evict local-only files
2026-10-08 20:59:55 +02:00
nvrl 07d4ee42d9 Merge branch 'main' into fix/sync-unreadable-subfolder 2026-10-08 20:59:21 +02:00
nvrl 0ac5e2e4fe Merge pull request #31 from narrrl/feature/windows-migration
Feature/windows migration
2026-10-08 20:57:31 +02:00
nvrl 7eb6aa0640 fix(fuse): skip unreadable folders in sync, never evict local-only files
An unreadable subfolder, such as a container's root-owned cache, failed
every pass of its synced folder, so nothing else in the tree reached
Drive (#33, B196). The scan now leaves such a path out, together with an
entry that vanished mid-scan and a name that is not UTF-8, and the pass
drops everything under it from classification, the wipe guard and the
push pass's deletions. It is never read as deleted and never downloaded
into. An unreadable or absent root still fails the pass. The activity
feed names unreadable paths once each time the set changes.

Two related bugs on the way to online-only:

- The push pass read deletions from the raw baseline, so a synced path
  that later became ignored was trashed on Drive (B197).
- The switch evicted everything once the folder was idle, including
  ignored paths such as .git/, symlinks, unreadable folders and files
  changed after the pass, none of which are on Drive (B198). It now
  refuses and names the path.

Closes #33
2026-10-08 20:45:54 +02:00
nvrl d13e74fc12 fix(gui,cli,core): offer the CAPTCHA in the browser when the web view cannot start (B195)
The verification window now runs in its own process, so the app survives
when WebKit cannot start its sandbox on secureblue, but the CAPTCHA still
could not be solved there (#29). WebKit binds the journal socket with
`--ro-bind-try`, bubblewrap only skips a missing path, not an unreadable
one, and WebKit has no switch to leave the socket out.

Proton Mail Bridge never uses a web view: it opens verify.proton.me in the
system browser and retries with the original challenge token, typed with
the offered methods. Do the same as a fallback.

- core: `browser_verification_url` and `browser_verified` build the page
  URL and the retry credential from the challenge
- gui: when the verification window fails, a dialog opens the page in the
  browser and continues the sign-in once the user is done
- cli: `pdfs login` handles a CAPTCHA the same way instead of failing
- i18n: translate the new dialog in every catalog
- docs: B195, TROUBLESHOOTING.md, ARCHITECTURE.md, changelog

Fixes #29
2026-10-08 19:02:29 +02:00
Nils Pukropp c0d211238b chore(aur): update packages to 3.1.2 2026-10-08 13:55:27 +00:00
nvrl 07c9b675d3 chore(deps) updated sdk to 0.8.1 2026-10-08 15:25:44 +02:00
nvrl f82343b844 Merge pull request #30 from narrrl/fix/secureblue-captcha
Fix/secureblue captcha
2026-10-08 15:25:05 +02:00
nvrl 3e3dd7db41 feat(fuse,core,cli,gui): move another computer's backup to this one
A user switching from a Windows PC to Linux had two half answers:
adopting the old device makes this machine back up as it, and restoring
its folders keeps them in the old device's tree. Neither puts the data
under this machine's own device.

Device roots are separate shares on the main volume, so each top-level
folder of the other device can move under this device's root in one
server-side move. Nothing is uploaded again, and the other device stays
registered, without those folders.

- core: `Request::MigrateDevice { uid }` and
  `Db::sync_folder_set_share`
- fuse: `Core::migrate_device` moves every folder, suffixes a name
  already taken here with the other device's name, keeps going past a
  failed folder, and re-points sync folders that already tracked a
  moved folder at the new share
- cli: `pdfs devices migrate UID [--yes]` confirms, moves, then runs
  the ordinary restore picker
- gui: "Move to This Computer…" on another computer's row, followed by
  the restore picker
- i18n: translate the new strings in every catalog
- docs: CLI.md, USER_GUIDE.md, RECOVERY.md, ARCHITECTURE.md, changelog
2026-10-08 13:34:28 +02:00
Nils Pukropp d09e10dc7d chore(aur): update packages to 3.1.1 2026-10-08 09:49:25 +00:00
nvrl 8d0351124c chore(release): 3.1.2 2026-10-08 11:31:18 +02:00
nvrl a888e0bb28 fix(gui): run the CAPTCHA page in its own process (B195)
On secureblue, WebKit cannot start the sandbox for the verification page:
bubblewrap is kept from the journal socket, xdg-dbus-proxy fails, and
WebKit ends its process with g_error. The webview lived in pdfs-app, so
the whole app crashed after the sign-in (#29).

- gui: `pdfs-app --human-verification` shows the page in its own window,
  reads the URL on stdin and prints the token on stdout; its logs go to
  stderr
- gui: a child that dies or fails without a token ends the sign-in with a
  message that the page could not be opened, instead of "cancelled"
- i18n: translate the new message in every catalog
- docs: B195 in the bug ledger, the cause and a one-time workaround in
  TROUBLESHOOTING.md, the child process in ARCHITECTURE.md, changelog
2026-10-08 10:25:14 +02:00
nvrl c7fc85e943 fix(gui): create the state dir before the tray and app use it (B194)
On a fresh install only the daemon and the CLI created
~/.local/state/proton-drive-linux. The app starts the tray before the
first sign-in, so the tray failed to bind its single-instance socket and
exited, and the Takeout page could not remember staged archives (#29).

- gui: pdfs-tray and pdfs-app call AppDirs::ensure at start, which also
  makes the directories owner-only
- docs: B194 in the bug ledger, changelog entry
2026-10-08 10:25:04 +02:00
nvrl 5bfae0fee1 docs(changelog): add the 3.1.1 entry
3.1.1 shipped the client identity change without a changelog entry. Also
add the missing release links for 3.1.0 and 3.1.1.
2026-10-08 10:24:07 +02:00
nvrl edd62c5f7a chore(release): 3.1.1 2026-10-07 17:01:32 +02:00
nvrl 5c1530b4b9 fix(core,gui): send the release version as the client identity
Every release sent the fixed `external-drive-linux@0.1.0-alpha`. If Proton
ever retires that version, every release fails sign-in at once and no update
can fix it.

- core: APP_VERSION and USER_AGENT follow CARGO_PKG_VERSION
  (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`)
- core: `app_version` and `user_agent` in config.json are optional overrides;
  the fixed values older releases stored there read as unset and leave the
  file on the next save, a hand-set value is kept
- gui: the About dialog shows the release, its debug info the identity
  actually sent
- docs: describe the new default and the migration
2026-10-07 17:01:32 +02:00
Nils Pukropp ce324510de chore(aur): update packages to 3.1.0 2026-10-07 10:53:15 +00:00
nvrl 274bfd0ca2 Merge pull request #27 from narrrl/feature/two-password-mode
Feature/two password mode
2026-10-07 12:47:05 +02:00
nvrl b8cfd30ff3 fix(core): give the kernel-keyring unlock owner read access
A new key grants its owner only "view"; read needs possession, which a
process has only when its session keyring links to the user keyring. A
systemd user service or a container does not guarantee that, so the
daemon could store the unlock and then fail to read it back (EACCES).
Set the permissions explicitly after add_key.
2026-10-07 08:17:50 +02:00
nvrl 9fa88ff397 feat(core,cli,gui): support two-password accounts without storing the mailbox password
A two-password account signed in with its login password as the mailbox
password, so the key chain never unlocked and the daemon retried forever.

Login now asks for the mailbox password and checks it against the account
(proton-sdk 0.8 `unlock`), so a typo is a retry. The password is never
stored: the keyring blob keeps the per-key passphrases instead, and for a
two-password account only when the user opts in (`--remember`, or the
"Keep unlocked on this computer" switch). By default they live in the
kernel keyring for the login session and the account is locked after a
reboot until `pdfs unlock` or the app supplies them again.

- core: kernelkey module, Error::Locked / WrongMailboxPassword, unlock,
  lock, key_state, ready; blobs written by 3.0.x are migrated on first
  start; token refresh updates only the tokens in the stored blob
- cli: `pdfs unlock [--remember]`, `pdfs lock [--forget]`; status and
  diagnose report the key state; the daemon waits while locked and is
  marked non-dumpable
- gui: mailbox password dialog at sign-in and on open, locked tray state
- po: all catalogs translated
- bump to 3.1.0, depend on proton-sdk and proton-drive-rs 0.8.0

Refs #26.
2026-10-07 08:17:50 +02:00
nvrl f105159186 fix(cli,fuse): keep answering the systemd watchdog while the daemon retries
The watchdog is armed when the process starts, but only the mount's supervisor
pinged it. A daemon waiting for a login or backing off after a failed mount
(#26: key unlock failing) was aborted after WatchdogSec and left a coredump
instead of its error. Sleep through idle_sleep in those stretches; the mounted
ping still has to pass the control-socket probe.
2026-10-07 08:17:50 +02:00
nvrl a14a63c94c ci: add an on-demand Fedora test build
Manual workflow that builds the RPM and the bare binaries in a Fedora
container, so a user can try a branch against Fedora's own GTK stack.
Uploads them as an artifact and, on request, as one rolling pre-release
per branch that needs no GitHub login to download.
2026-10-07 04:27:22 +02:00
84 changed files with 12144 additions and 6279 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
name: Nix
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
# from the Actions tab for any branch to get a build to try.
on:
push:
paths:
- 'flake.nix'
- 'flake.lock'
- 'packaging/nix/**'
- 'Cargo.lock'
- 'Cargo.toml'
- 'crates/**'
- 'po/**'
- '.github/workflows/nix.yml'
pull_request:
paths:
- 'flake.nix'
- 'flake.lock'
- 'packaging/nix/**'
- 'Cargo.lock'
- '.github/workflows/nix.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: cachix/install-nix-action@v31
- name: Check the flake
run: nix flake check --print-build-logs --no-build
- name: Build the package
run: nix build --print-build-logs .#proton-drive-linux
- name: Smoke test
run: |
./result/bin/pdfs --version
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
+121
View File
@@ -0,0 +1,121 @@
name: Test build
# On-demand Fedora RPM of any branch, for someone to try a fix before it ships.
# Run it from the Actions tab ("Run workflow", pick the branch). Built inside a
# Fedora container so the binaries link against Fedora's own GTK, libadwaita and
# WebKitGTK, not the Ubuntu ones the release RPM is built against.
#
# The RPM is unsigned and versioned `<Cargo version>-1.test.<sha>`, so it sorts
# below the release it precedes and `dnf` replaces it when that ships.
on:
workflow_dispatch:
inputs:
fedora:
description: 'Fedora release to build on'
required: false
default: '44'
type: string
prerelease:
description: 'Also publish a pre-release anyone can download without a GitHub login'
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
group: test-build-${{ github.ref }}
cancel-in-progress: true
jobs:
rpm:
name: Fedora ${{ inputs.fedora }} RPM
runs-on: ubuntu-24.04
container: fedora:${{ inputs.fedora }}
steps:
- name: Install build dependencies
run: |
dnf install -y --setopt=install_weak_deps=False \
git rpm-build gcc gcc-c++ make gettext curl \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
libsecret-devel dbus-devel glib2-devel webkitgtk6.0-devel
- name: Checkout repository
uses: actions/checkout@v7
# Fedora's packaged Rust can trail the workspace's minimum (1.96).
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
cache: false
- name: Cache Cargo target
uses: swatinem/rust-cache@v2
with:
shared-key: fedora-test-build
save-if: false
- name: Build release binaries
run: cargo build --release --locked
- name: Package RPM
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
SHORT=${GITHUB_SHA::7}
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
rpmbuild -bb \
--nodeps \
--define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "prebuilt 1" \
--define "version ${VERSION}" \
--define "dist .test.${SHORT}" \
--define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec
mkdir -p out
cp rpmbuild/RPMS/x86_64/*.rpm out/
# The bare binaries, for trying one without installing anything.
tar -czf "out/proton-drive-linux-${VERSION}-${SHORT}-fedora${{ inputs.fedora }}-x86_64.tar.gz" \
-C target/release pdfs pdfs-tray pdfs-app pdfs-prompt
ls -l out
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: proton-drive-linux-fedora${{ inputs.fedora }}-${{ github.sha }}
path: out/*
retention-days: 14
if-no-files-found: error
prerelease:
name: Publish pre-release
needs: rpm
if: inputs.prerelease
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download build
uses: actions/download-artifact@v8
with:
path: out
merge-multiple: true
# One rolling pre-release per branch: a new run replaces the last one, so
# the link a tester was given keeps pointing at the newest build.
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
TAG="test-$(echo "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._\n' '-')"
gh release delete "$TAG" --cleanup-tag --yes 2>/dev/null || true
gh release create "$TAG" out/* \
--target "$GITHUB_SHA" \
--prerelease \
--title "Test build of ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" \
--notes "Unsigned test build of branch \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA}. Not a release; replaced by the next test build of this branch."
+2
View File
@@ -3,3 +3,5 @@
*.pdb
.env
/rpmbuild
/result
/result-*
Generated
+14 -10
View File
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pdfs-cli"
version = "3.0.0"
version = "3.2.0"
dependencies = [
"anyhow",
"clap",
@@ -3485,13 +3485,15 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"zeroize",
]
[[package]]
name = "pdfs-core"
version = "3.0.0"
version = "3.2.0"
dependencies = [
"async-trait",
"base64 0.23.1",
"dbus-secret-service-keyring-store",
"directories",
"ignore",
@@ -3508,12 +3510,13 @@ dependencies = [
"thiserror 2.0.20",
"tokio",
"tracing",
"zeroize",
"zip",
]
[[package]]
name = "pdfs-fuse"
version = "3.0.0"
version = "3.2.0"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3535,7 +3538,7 @@ dependencies = [
[[package]]
name = "pdfs-gui"
version = "3.0.0"
version = "3.2.0"
dependencies = [
"anyhow",
"async-channel",
@@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pgp"
version = "0.20.0"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
dependencies = [
"aead 0.5.2",
"aes 0.8.4",
@@ -3775,9 +3778,9 @@ dependencies = [
[[package]]
name = "proton-drive-rs"
version = "0.7.2"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
checksum = "1e789cab811c5e73e47fd97810b78a513cb254f4dd763b4eb3cad680d6dd31da"
dependencies = [
"async-trait",
"base64 0.23.1",
@@ -3802,9 +3805,9 @@ dependencies = [
[[package]]
name = "proton-sdk"
version = "0.7.2"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
checksum = "ace260acbf88c26f5bd4aef8c8c9898c322163c56620bdef3b6dd7742bec7619"
dependencies = [
"aes-gcm 0.11.1",
"async-trait",
@@ -3831,6 +3834,7 @@ dependencies = [
"thiserror 2.0.20",
"tokio",
"tracing",
"zeroize",
]
[[package]]
+4 -3
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "3.0.0"
version = "3.2.0"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
categories = ["filesystem", "command-line-utilities"]
[workspace.dependencies]
proton-sdk = { version = "0.7.2" }
proton-drive-rs = { version = "0.7.2" }
proton-sdk = { version = "0.8.2" }
proton-drive-rs = { version = "0.8.2" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.23"
zeroize = "1"
thiserror = "2"
anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not
+1
View File
@@ -76,6 +76,7 @@ scriptable CLI.
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
| 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 |
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
```bash
+1
View File
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
clap.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+257 -35
View File
@@ -135,6 +135,24 @@ enum Command {
/// Account email; prompted if omitted.
#[arg(short, long)]
username: Option<String>,
/// For a two-password account, keep the mailbox password's unlock in the
/// system keyring so the daemon starts without asking after every login.
/// Without it the unlock lives in kernel memory for this session only.
#[arg(long)]
remember: bool,
},
/// Enter the mailbox password of a two-password account so the daemon can
/// unlock your files. Needed once per login session.
Unlock {
/// Keep the unlock in the system keyring instead of kernel memory only.
#[arg(long)]
remember: bool,
},
/// Forget the unlock held in kernel memory; the account is locked again.
Lock {
/// Also remove an unlock kept in the keyring with `--remember`.
#[arg(long)]
forget: bool,
},
/// Forget the stored session.
Logout,
@@ -610,6 +628,15 @@ enum DeviceCmd {
#[arg(long, conflicts_with = "uid")]
clear: bool,
},
/// Move another computer's folders into this computer's backup and sync
/// them here. The other computer stays registered, without those folders.
Migrate {
/// Device uid (from `devices list`).
uid: String,
/// Skip the confirmation and accept every proposed local path.
#[arg(long)]
yes: bool,
},
}
#[derive(Subcommand)]
@@ -803,7 +830,9 @@ fn main() -> Result<()> {
let cli = Cli::parse();
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
match cli.command {
Command::Login { username } => cmd_login(username),
Command::Login { username, remember } => cmd_login(username, remember),
Command::Unlock { remember } => cmd_unlock(remember),
Command::Lock { forget } => cmd_lock(forget),
Command::Logout => cmd_logout(),
Command::Status => cmd_status(),
Command::Locations => cmd_locations(),
@@ -947,6 +976,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
let uid = if clear { None } else { uid };
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
}
DeviceCmd::Migrate { uid, yes } => {
if !yes {
println!(
"Every folder that device backs up moves into this computer's backup.\n\
The device stays registered, without those folders. Stop Proton Drive\n\
on that computer first, or it sees its folders disappear."
);
confirm("Move them here?")?;
}
let response = control_request(CtlRequest::MigrateDevice { uid })?;
// A script gets the one reply and runs `sync restore` itself.
if emit_json(&response)? {
return Ok(());
}
ok_or_bail(response)?;
// The folders are this device's now, so the plain restore offers them.
cmd_sync_restore(yes, None)?
}
}
Ok(())
}
@@ -1781,7 +1828,7 @@ fn prompt_line(label: &str) -> Result<String> {
Ok(line.trim().to_owned())
}
fn cmd_login(username: Option<String>) -> Result<()> {
fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
let username = match username {
Some(u) => u,
None => prompt_line("Proton email")?,
@@ -1793,20 +1840,32 @@ fn cmd_login(username: Option<String>) -> Result<()> {
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
};
// Only a two-password account is ever asked for this.
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
// browser engine, so the user solves the page in their own browser and says
// when they are done; the retry then carries the challenge token.
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
println!(
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
Open this page in your browser and complete it:\n\n {}\n",
auth::browser_verification_url(&hv)
);
prompt_line("Press Enter once the page says you are verified")
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
Ok(auth::browser_verified(&hv))
};
let rt = tokio::runtime::Runtime::new()?;
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
// carrying. Point at the app that does rather than failing with the raw API
// message, which reads as "your login is broken".
rt.block_on(auth::login(&username, &password, get_totp))
.map_err(|e| match e {
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
Run `pdfs-app` and sign in there — it can show the verification page.\n\
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
your usual network often avoids it.)"
),
other => anyhow::Error::new(other).context("login failed"),
})?;
rt.block_on(auth::login_interactive(
&username,
&password,
get_totp,
get_mailbox,
get_hv,
))
.context("login failed")?;
// A daemon that is already up is parked in `wait_for_session`, sleeping
// between keyring checks; restart it so the mount appears now instead of
@@ -1818,9 +1877,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
}
println!("Logged in as {username}. Session stored in the system keyring.");
report_key_state();
Ok(())
}
/// Ask for the mailbox password of a two-password account, without echo.
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
if attempt == 0 {
eprintln!("This account has a separate mailbox password.");
} else {
eprintln!("That mailbox password did not unlock the account. Try again.");
}
let password = rpassword::prompt_password("Mailbox password: ")
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
Ok(auth::Mailbox {
password: zeroize::Zeroizing::new(password),
remember,
})
}
/// Say where the account's keys are held, and what that means for the next login.
fn report_key_state() {
match auth::key_state() {
Ok(auth::KeyState::Session) => println!(
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
),
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
_ => {}
}
}
fn cmd_unlock(remember: bool) -> Result<()> {
auth::load().context("not logged in (run `pdfs login` first)")?;
let rt = tokio::runtime::Runtime::new()?;
// Bounded like the login prompt: three wrong answers end the command.
let mut attempt = 0;
loop {
let mailbox = prompt_mailbox(attempt, remember)?;
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
Ok(()) => break,
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
}
}
// A daemon parked on the locked account would otherwise notice on its next
// poll; restarting it makes the mount appear now, as after `pdfs login`.
if service::is_active() {
service::restart();
}
println!("Unlocked.");
report_key_state();
Ok(())
}
fn cmd_lock(forget: bool) -> Result<()> {
auth::lock(forget)?;
println!(
"Locked. {}",
if forget {
"Unlock removed from memory and keyring."
} else {
"Run `pdfs unlock` to unlock again."
}
);
// The running daemon keeps its keys until it restarts; say so, since
// "locked" would otherwise suggest the mount stopped.
if service::is_active() {
println!(
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
);
}
Ok(())
}
/// `stored`, `session` or `locked`: where the account's keys are held.
fn key_state_name() -> &'static str {
match auth::key_state() {
Ok(auth::KeyState::Stored) => "stored",
Ok(auth::KeyState::Session) => "session",
_ => "locked",
}
}
fn cmd_logout() -> Result<()> {
auth::logout()?;
println!("Stored session cleared.");
@@ -1848,6 +1986,7 @@ fn cmd_status() -> Result<()> {
"username": session.as_ref().map(|s| s.username.clone()),
"user_id": session.as_ref().map(|s| s.user_id.clone()),
"scopes": session.as_ref().map(|s| s.scopes.clone()),
"keys": session.as_ref().map(|_| key_state_name()),
"mount": mount,
});
println!("{}", serde_json::to_string_pretty(&out)?);
@@ -1858,6 +1997,11 @@ fn cmd_status() -> Result<()> {
Ok(s) => {
println!("Logged in as {} (user {})", s.username, s.user_id);
println!("Scopes: {}", s.scopes.join(", "));
match key_state_name() {
"locked" => println!("Keys: locked — run `pdfs unlock`"),
"session" => println!("Keys: unlocked for this login session"),
_ => {}
}
}
Err(pdfs_core::Error::NotLoggedIn) => {
println!("Not logged in. Run `pdfs login`.");
@@ -1880,9 +2024,12 @@ fn cmd_status() -> Result<()> {
staged_oldest_secs,
paused,
paused_until,
session_expired,
tokens_unsaved,
..
}) => {
let state = match (online, paused) {
_ if session_expired => ", session expired: run `pdfs login`",
(_, true) => ", sync paused",
(false, false) => ", offline",
(true, false) => "",
@@ -1915,6 +2062,11 @@ fn cmd_status() -> Result<()> {
failing_error.as_deref().unwrap_or("no error recorded"),
);
}
if tokens_unsaved {
println!(
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
);
}
}
Ok(other) => println!("Mount: unexpected response {other:?}"),
Err(_) => println!("Mount: not running."),
@@ -1989,7 +2141,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
}
/// Block until a session is stored, backing off between checks.
/// Block until a session is stored and unlocked, backing off between checks.
///
/// `load` and `sleep` are parameters rather than direct calls so the backoff
/// schedule can be tested without a keyring and without real time passing.
@@ -2014,6 +2166,16 @@ where
sleep(delay);
attempts = attempts.saturating_add(1);
}
Err(pdfs_core::Error::Locked) => {
let delay = login_poll_delay(attempts);
if attempts == 0 {
tracing::info!("account is locked; waiting for `pdfs unlock`…");
} else {
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
}
sleep(delay);
attempts = attempts.saturating_add(1);
}
Err(e) => return Err(e.into()),
}
}
@@ -2024,25 +2186,31 @@ where
/// an external unmount triggers a remount; errors back off and retry. This is
/// the entry point for the systemd user service.
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 {
// 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.
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
match mount_once(mountpoint.clone()) {
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
tracing::info!("daemon stopping");
return Ok(());
}
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
tracing::info!("resuming the session stored in the keyring");
}
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
tracing::warn!("mount ended externally; remounting in 2s");
std::thread::sleep(std::time::Duration::from_secs(2));
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
}
Err(e) => {
// `{:#}` so the whole anyhow chain lands in the log — the top
// context alone ("mount failed") never says *why*.
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
std::thread::sleep(std::time::Duration::from_secs(5));
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
}
}
}
@@ -2104,9 +2272,14 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
},
);
// Clean unmount check (as a best-effort final backup).
if let Err(e) = rt.block_on(auth::persist(&session)) {
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
// Last chance for a rotation the keyring refused when it happened. Only
// that: by now the keyring may hold a newer login, the one this stop was
// restarted for, and the tokens of this session would overwrite it.
if let Err(e) = auth::persist(&session) {
tracing::error!(
error = %e,
"could not save the refreshed session tokens; the next start may need `pdfs login`"
);
}
// Dropping the runtime waits for every blocking task, however long; a call
// still stuck on Drive would hold the stop until systemd kills it (B165).
@@ -3224,6 +3397,19 @@ fn cmd_diagnose() -> Result<()> {
match pdfs_core::auth::load() {
Ok(session) => {
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
match key_state_name() {
"locked" => report.finding(
DiagnoseLevel::Fail,
" account keys",
"locked — run `pdfs unlock` (the daemon waits for it)",
),
"session" => report.finding(
DiagnoseLevel::Ok,
" account keys",
"unlocked for this login session",
),
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
}
}
Err(e) => {
// A locked keyring and an absent login look the same from here, so
@@ -3254,6 +3440,8 @@ fn cmd_diagnose() -> Result<()> {
Ok(CtlResponse::Status {
mountpoint,
online,
session_expired,
tokens_unsaved,
pending_uploads,
pending_changes,
..
@@ -3275,19 +3463,38 @@ fn cmd_diagnose() -> Result<()> {
|kind| format!("{mountpoint} ({kind})"),
),
);
report.finding(
if online {
DiagnoseLevel::Ok
} else {
DiagnoseLevel::Warn
},
" network",
if online {
"online"
} else {
"offline; cached data remains available"
},
);
// The keyring check above read the blob, which still holds the
// tokens the daemon could not replace.
if tokens_unsaved {
report.finding(
DiagnoseLevel::Warn,
" session tokens",
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
);
}
// A refused session reads as offline too, but no connection will
// bring it back, and the keyring check above cannot tell.
if session_expired {
report.finding(
DiagnoseLevel::Fail,
" session",
"Proton refused it (expired or revoked) — run `pdfs login`",
);
} else {
report.finding(
if online {
DiagnoseLevel::Ok
} else {
DiagnoseLevel::Warn
},
" network",
if online {
"online"
} else {
"offline; cached data remains available"
},
);
}
let pending = pending_uploads + pending_changes;
report.finding(
if pending == 0 {
@@ -3522,6 +3729,21 @@ mod login_wait_tests {
);
}
#[test]
fn a_locked_account_is_waited_on_until_it_is_unlocked() {
let mut checks = vec![
Ok(()),
Err(pdfs_core::Error::Locked),
Err(pdfs_core::Error::NotLoggedIn),
Err(pdfs_core::Error::Locked),
];
let mut slept = 0;
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
assert_eq!(slept, 3, "locked and signed-out are both waited out");
}
#[test]
fn a_stored_session_is_not_waited_for_at_all() {
let mut slept = 0;
+2
View File
@@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true
parking_lot.workspace = true
async-trait.workspace = true
libc.workspace = true
zeroize.workspace = true
base64.workspace = true
+1018 -120
View File
File diff suppressed because it is too large. Load diff
+12 -28
View File
@@ -2309,42 +2309,26 @@ mod tests {
/// call also needs.
#[test]
fn budget_check_is_free_when_under_budget() {
use std::time::Instant;
// Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096];
// Cost of a store against a near-empty index...
let t = Instant::now();
for i in 0..500u64 {
for i in 0..8u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let empty = t.elapsed() / 500;
// ...and against one with a few thousand entries in it.
for i in 500..2500u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let t = Instant::now();
for i in 2500..3000u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let full = t.elapsed() / 500;
// Plant an index row far over the cap behind the running total's back,
// and older than every real block. Any budget check that consults the
// index would see the pool over budget, evict, and reseed the total.
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
// A ratio, not a wall-clock bound: the claim is that the under-budget
// path does not grow with the index, and only a ratio tests that. An
// absolute threshold measures the machine's load instead, and this test
// runs alongside the rest of the suite.
assert!(
full < empty * 3,
"under-budget store should not scale with the index: \
{empty:?} at ~0 entries vs {full:?} at 2500"
);
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
.unwrap();
// Not a timing ratio: that measured the CI runner's load and flaked.
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
}
/// The running totals are a fast path, not a second source of truth: after
+106 -34
View File
@@ -3,7 +3,7 @@
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::error::{Error, Result};
use crate::menu::PromptConfig;
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Follows the release so Proton can tell releases apart and retire old ones
/// without retiring every one.
pub const APP_VERSION: &str = concat!(
"external-drive-linux@",
env!("CARGO_PKG_VERSION"),
"-stable"
);
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
/// back as "unset" so those configs follow the release instead of pinning it.
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
/// Keyring service name; one entry per credential kind keyed by username.
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
}
/// Configuration structure allowing the user to customize client identification headers.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AppConfig {
pub app_version: String,
pub user_agent: String,
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
/// the old fixed default reads as `None`, so it leaves the file on the
/// next save.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub app_version: Option<String>,
/// Override for [`USER_AGENT`], handled like `app_version`.
#[serde(
default,
deserialize_with = "identity_override",
skip_serializing_if = "Option::is_none"
)]
pub user_agent: Option<String>,
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
/// writes an explicit value here. Defaulted for configs predating the field.
@@ -216,31 +241,28 @@ impl FileSort {
}
}
impl Default for AppConfig {
fn default() -> Self {
Self {
app_version: APP_VERSION.to_string(),
user_agent: USER_AGENT.to_string(),
cache_budget: None,
upload_limit: None,
download_limit: None,
mountpoint: None,
ignore_patterns: None,
device_uid: None,
conflict_sweep: None,
open_with: None,
prompt: None,
proton_theme: None,
files_view: FilesView::default(),
tray_hidden: false,
language: None,
online_map: false,
local_first: None,
}
}
/// Reads an `app_version` or `user_agent` override, dropping the fixed
/// identity older releases stored as the default.
fn identity_override<'de, D: Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error> {
let value = Option::<String>::deserialize(deserializer)?;
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
}
impl AppConfig {
/// The `x-pm-appversion` sent to Proton: the user's override, or
/// [`APP_VERSION`].
pub fn resolved_app_version(&self) -> &str {
self.app_version.as_deref().unwrap_or(APP_VERSION)
}
/// The `User-Agent` sent to Proton: the user's override, or
/// [`USER_AGENT`].
pub fn resolved_user_agent(&self) -> &str {
self.user_agent.as_deref().unwrap_or(USER_AGENT)
}
/// The effective cache budget in bytes: the user's explicit choice, or
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
pub fn resolved_cache_budget(&self) -> u64 {
@@ -571,8 +593,8 @@ mod tests {
#[test]
fn test_app_config_serialization() {
let config = AppConfig {
app_version: "external-drive-test-client@1.0.0".to_string(),
user_agent: "test-agent/1.0".to_string(),
app_version: Some("external-drive-test-client@1.0.0".to_string()),
user_agent: Some("test-agent/1.0".to_string()),
cache_budget: Some(1234),
upload_limit: Some(500_000),
download_limit: None,
@@ -596,8 +618,11 @@ mod tests {
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
assert_eq!(decoded.user_agent, "test-agent/1.0");
assert_eq!(
decoded.resolved_app_version(),
"external-drive-test-client@1.0.0"
);
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
assert_eq!(decoded.proton_theme, Some(true));
assert!(decoded.tray_hidden);
assert!(decoded.online_map);
@@ -628,8 +653,55 @@ mod tests {
#[test]
fn test_default_app_config() {
let default_config = AppConfig::default();
assert_eq!(default_config.app_version, APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT);
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
}
#[test]
fn the_client_identity_follows_the_release() {
let version = env!("CARGO_PKG_VERSION");
assert_eq!(
APP_VERSION,
format!("external-drive-linux@{version}-stable")
);
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
}
#[test]
fn an_old_config_with_the_fixed_identity_follows_the_release() {
// Every config written up to 3.1.0 holds these two values, so they must
// not pin the identity once Proton retires 0.1.0.
let json = format!(
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
);
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
assert!(decoded.tray_hidden, "the rest of the config survives");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(!saved.contains("app_version"), "{saved}");
assert!(!saved.contains("user_agent"), "{saved}");
}
#[test]
fn a_hand_set_client_identity_is_kept() {
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(
decoded.resolved_app_version(),
"external-drive-linux@9.9.9-beta"
);
assert_eq!(decoded.resolved_user_agent(), "custom/1");
let saved = serde_json::to_string(&decoded).unwrap();
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
}
#[test]
fn a_config_without_a_client_identity_loads() {
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
}
#[test]
+26
View File
@@ -446,6 +446,13 @@ pub enum Request {
/// of registering a duplicate. `uid: None` clears the pin.
/// Replies with [`Response::Ok`].
AdoptDevice { uid: Option<String> },
/// Move every folder another device backs up into this machine's own
/// device, as server-side moves on the main volume. The other device stays
/// registered, without those folders. Sync folders here that already track
/// one of them follow it to its new share. Nothing is downloaded: the moved
/// folders are then offered by [`Request::ListRestorableFolders`].
/// Replies with [`Response::Ok`].
MigrateDevice { uid: String },
// ---- device folder sync ----------------------------------
/// Add a local folder to this machine's device, uploading its tree and
@@ -1218,6 +1225,12 @@ pub struct JobItem {
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
/// May *grow* mid-job as more work is discovered.
pub total: u64,
/// Housekeeping that changes nothing in the user's files: a mirror folder
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
/// ends may show it, but should not report the drive as busy over it. Absent
/// from older daemons, which never set it.
#[serde(default)]
pub background: bool,
}
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
@@ -1946,6 +1959,16 @@ pub enum Response {
/// resumed, or not paused.
#[serde(default)]
paused_until: Option<i64>,
/// True when Proton refused the session itself (refresh token refused,
/// account deleted or disabled). `online` is false then too, but no
/// connection brings it back: only signing in again does.
#[serde(default)]
session_expired: bool,
/// True while the keyring refuses the session's refreshed tokens. The
/// daemon keeps retrying; until it succeeds, a reboot signs the user
/// out.
#[serde(default)]
tokens_unsaved: bool,
},
/// A human-readable success message.
Ok { message: String },
@@ -3082,6 +3105,9 @@ mod tests {
uid: Some("dev-1".into()),
},
Request::AdoptDevice { uid: None },
Request::MigrateDevice {
uid: "dev-2".into(),
},
Request::ListRestorableFolders,
Request::RestoreSyncFolders {
items: vec![RestoreItem {
+27 -16
View File
@@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
const PARENT_NOW: &str =
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
/// The `WHERE` terms for a row a drain worker may claim once it falls due: not
/// claimed already, no other claimed op on the same node, and not under a
/// folder that has yet to land.
///
/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A
/// row the claim skips but the wait counts is due "now" for as long as it is
/// held, and every idle worker spun on it (`docs/BUGS.md` B200).
fn claimable() -> String {
format!(
"claimed_at = 0
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
AND (lid IS NULL OR lid NOT IN (
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
)
}
/// The volume id given to a node that exists only on this machine, so far. A
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
/// round-trips through the same `Display`/parse path as any other uid.
@@ -1127,15 +1146,9 @@ impl Db {
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at <= ?1
AND claimed_at = 0
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
AND (lid IS NULL OR lid NOT IN (
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))
WHERE next_attempt_at <= ?1 AND {claimable}
ORDER BY id LIMIT 1",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
claimable = claimable(),
))?;
stmt.query_row(params![now], |r| {
Ok(PendingOp {
@@ -1208,19 +1221,17 @@ impl Db {
/// debounced or backed-off op becomes eligible rather than waiting the full
/// idle-poll interval.
///
/// Claimed ops are excluded: another worker is already on them, so they are
/// not work this one is waiting for, and counting them would have an idle
/// worker spin on a row it cannot have.
/// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
/// count. A claimed op, or one held behind a claimed op on the same node,
/// is not work this worker is waiting for, and counting it would have an
/// idle worker spin on a row it cannot have.
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
let conn = self.read();
let ts: Option<i64> = conn
.query_row(
&format!(
"SELECT MIN(next_attempt_at) FROM pending_op \
WHERE claimed_at = 0 \
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
"SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
claimable = claimable(),
),
[],
|r| r.get(0),
+11
View File
@@ -139,6 +139,17 @@ impl Db {
Ok(())
}
/// Re-point a synced folder at the share that now holds its remote folder,
/// after the folder moved to another device without changing its uid.
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
params![id, share_id],
)?;
Ok(())
}
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
/// is safe to switch.
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
+52
View File
@@ -4940,6 +4940,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
assert_eq!(folder.pending_mode, None);
}
#[test]
fn moved_sync_folder_points_at_its_new_share() {
let db = Db::open_in_memory().unwrap();
let id = db
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
.unwrap();
let other = db
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
.unwrap();
db.sync_folder_set_share(id, "linux-share").unwrap();
let folder = db.sync_folder_get(id).unwrap().unwrap();
assert_eq!(folder.remote_share_id, "linux-share");
// A move keeps the uid, so nothing but the share changes.
assert_eq!(folder.remote_uid, "vol~folder");
assert_eq!(
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
"windows-share"
);
}
#[test]
fn mode_commit_clears_only_the_intent_it_satisfies() {
let db = Db::open_in_memory().unwrap();
@@ -6525,6 +6547,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
);
}
/// B200: a rename queued behind a long upload of the same node is due, but no
/// worker may claim it until the upload retires. Counted as due, it had every
/// idle worker wake at once, find nothing, and go round again.
#[test]
fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() {
let db = Db::open_in_memory().unwrap();
let root = uid("root").to_string();
let mut revision = bulk_op(0, &root);
revision.parent_uid = None;
db.enqueue_op(&revision).unwrap();
db.enqueue_op(&PendingOp {
kind: OP_RENAME.to_string(),
parent_uid: Some(root.clone()),
blob_path: None,
meta_json: None,
..bulk_op(0, &root)
})
.unwrap();
db.claim_next_due_op(10)
.unwrap()
.expect("the revision is due");
assert!(db.claim_next_due_op(10).unwrap().is_none());
assert_eq!(
db.earliest_due_at().unwrap(),
None,
"a row no worker can claim is not work an idle worker is waiting for"
);
}
/// Trashing a photo from the gallery must not leave it behind on an album page:
/// the row goes, its album membership goes with it, and nothing else moves.
#[test]
+43 -3
View File
@@ -32,6 +32,16 @@ pub enum Error {
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
/// The account has a separate mailbox password that has not been entered
/// since login, so its keys cannot be unlocked. The fix is
/// [`auth::unlock`](crate::auth::unlock), not a new login.
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
Locked,
/// The mailbox password was checked against the account and is wrong.
#[error("that mailbox password does not unlock your account")]
WrongMailboxPassword,
/// The login was gated behind human verification. Not a failure the user can
/// fix by retrying: the challenge has to be solved and the login restarted
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
@@ -199,9 +209,11 @@ impl From<serde_json::Error> for CoreError {
impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
ErrorKind::Denied
}
Error::NotLoggedIn
| Error::ReloginRequired
| Error::Locked
| Error::WrongMailboxPassword
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that never reached the API is a transport failure.
Error::Proton(_) => ErrorKind::Offline,
@@ -266,6 +278,23 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
}
}
/// Whether `e` says the session itself is gone: its refresh token was refused,
/// or the account was deleted or disabled.
///
/// Nothing but a new login helps then, and no retry will ever succeed, so a
/// caller must not take it for a lost connection and wait for it to come back
/// (issue #35: a revoked session was shown as "offline" for good).
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
use proton_sdk::api::ResponseCode as C;
match e {
proton_sdk::ProtonError::Api(api) => {
api.is_invalid_refresh_token()
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
}
_ => false,
}
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
@@ -297,6 +326,17 @@ mod tests {
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
}
#[test]
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
assert!(session_revoked(&api(C::InvalidRefreshToken)));
assert!(session_revoked(&api(C::AccountDeleted)));
assert!(session_revoked(&api(C::AccountDisabled)));
// One file the account may not touch says nothing about the session.
assert!(!session_revoked(&api(C::Forbidden)));
assert!(!session_revoked(&api(C::Timeout)));
assert!(!session_revoked(&ProtonError::KeysLocked));
}
/// 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
/// request that could never succeed.
+233
View File
@@ -0,0 +1,233 @@
//! A secret held in the Linux kernel keyring, for as long as the user is logged in.
//!
//! This is where the key passphrases of a two-password account live by default:
//! the mailbox password is never stored, and the secrets derived from it are
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
//! when the user's last session ends or the machine reboots. Both the daemon and
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
//! It is also where session tokens wait while the Secret Service refuses them
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
//!
//! Kernel keys are not paged to swap, are invisible to other users, and are
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
//! process's heap. Processes of the same user can still read them, exactly as
//! they could ptrace the daemon: this protects against disk, backup and other
//! users, not against code already running as you.
//!
//! The keyring may be unavailable (a container, a kernel built without
//! `CONFIG_KEYS`); every operation then reports it as an error and the caller
//! falls back to asking again, never to storing the secret somewhere weaker.
use std::io;
use zeroize::Zeroizing;
/// One secret in the user keyring, by its description. Descriptions are
/// namespaced so they cannot collide with another program's keys.
pub struct Key {
description: &'static [u8],
}
/// The account's key passphrases (see the module docs).
#[cfg(not(test))]
pub const PASSPHRASES: Key = Key {
description: b"pdfs:key-passphrases\0",
};
/// Tests must never touch a real unlock.
#[cfg(test)]
pub const PASSPHRASES: Key = Key {
description: b"pdfs:key-passphrases:test\0",
};
/// Session tokens the Secret Service refused to store, until it takes them.
/// A refresh token held only in a process's memory dies with the process.
#[cfg(not(test))]
pub const UNSAVED_TOKENS: Key = Key {
description: b"pdfs:unsaved-tokens\0",
};
#[cfg(test)]
pub const UNSAVED_TOKENS: Key = Key {
description: b"pdfs:unsaved-tokens:test\0",
};
const KEY_TYPE: &[u8] = b"user\0";
const KEY_SPEC_USER_KEYRING: i32 = -4;
const KEYCTL_REVOKE: libc::c_long = 3;
const KEYCTL_SETPERM: libc::c_long = 5;
const KEYCTL_SEARCH: libc::c_long = 10;
const KEYCTL_READ: libc::c_long = 11;
/// Possessor and owner may view, read, write, search, link and set attributes;
/// group and others get nothing.
///
/// A new key's default grants the owner only *view*, and read access to the
/// possessor. A process possesses the user keyring only when its session
/// keyring links to it, which a systemd user service or a container does not
/// guarantee — so with the default, the daemon could store a key it then
/// cannot read back. The owner is the same user that could ptrace the daemon
/// anyway, so granting it read access gives up nothing.
const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000;
fn last_error<T>() -> io::Result<T> {
Err(io::Error::last_os_error())
}
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
// SAFETY: both strings are NUL-terminated and outlive the call.
let id = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SEARCH,
KEY_SPEC_USER_KEYRING,
KEY_TYPE.as_ptr(),
description.as_ptr(),
0,
)
};
if id >= 0 {
return Ok(Some(id));
}
let err = io::Error::last_os_error();
match err.raw_os_error() {
// ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone.
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
_ => Err(err),
}
}
/// Stop this process from being core-dumped or ptraced by an unprivileged
/// process of the same user, so the secrets in its memory stay out of crash
/// reports. Call once at start of a long-lived process that holds them.
pub fn forbid_dumps() {
// SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process.
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if rc != 0 {
tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable");
}
}
impl Key {
/// Store `secret`, replacing any earlier one.
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
// describes `secret`, which outlives the call.
let id = unsafe {
libc::syscall(
libc::SYS_add_key,
KEY_TYPE.as_ptr(),
self.description.as_ptr(),
secret.as_ptr(),
secret.len(),
KEY_SPEC_USER_KEYRING,
)
};
if id < 0 {
return last_error();
}
// SAFETY: plain integer arguments.
let rc = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_SETPERM,
id,
PERM_OWNER_AND_POSSESSOR,
)
};
if rc < 0 {
// A key whose permissions are unknown is not one to leave behind.
let err = io::Error::last_os_error();
let _ = self.clear();
return Err(err);
}
Ok(())
}
/// Read the stored secret, or `None` when there is none.
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
let Some(id) = find(self.description)? else {
return Ok(None);
};
// Size first, then read; retry if the key was replaced in between.
for _ in 0..3 {
// SAFETY: a null buffer asks the kernel only for the payload length.
let len = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
std::ptr::null_mut::<u8>(),
0usize,
)
};
if len < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
_ => last_error(),
};
}
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
// SAFETY: `buf` is `len` writable bytes.
let read = unsafe {
libc::syscall(
libc::SYS_keyctl,
KEYCTL_READ,
id,
buf.as_mut_ptr(),
buf.len(),
)
};
if read < 0 {
return last_error();
}
if read as usize <= buf.len() {
buf.truncate(read as usize);
return Ok(Some(buf));
}
}
Err(io::Error::other("the key changed while it was being read"))
}
/// Remove the stored secret. Succeeds when there is none.
pub fn clear(&self) -> io::Result<()> {
let Some(id) = find(self.description)? else {
return Ok(());
};
// SAFETY: plain integer arguments.
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
if rc < 0 {
return match io::Error::last_os_error().raw_os_error() {
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
_ => last_error(),
};
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// The kernel keyring is process-wide state and a sandbox may not offer it,
// so one test covers the whole round trip and skips when it is unavailable.
#[test]
fn stored_secret_round_trips_and_clears() {
if PASSPHRASES.store(b"probe").is_err() {
eprintln!("kernel keyring unavailable; skipping");
return;
}
let saved = PASSPHRASES
.load()
.unwrap()
.expect("stored secret is readable");
assert_eq!(saved.as_slice(), b"probe");
PASSPHRASES.store(b"replaced").unwrap();
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
PASSPHRASES.clear().unwrap();
assert!(PASSPHRASES.load().unwrap().is_none());
PASSPHRASES.clear().unwrap();
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod config;
pub mod control;
pub mod db;
pub mod error;
pub mod kernelkey;
pub mod localindex;
pub mod menu;
pub mod mounts;
+12 -1
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::shutdown::Shutdown;
use pdfs_core::error::session_revoked;
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
/// entry is gone. Shared by the trash and delete paths, which differ only in
@@ -402,6 +403,12 @@ pub(super) async fn run_event_sync(
}
break Some(c.clone());
}
// Refused for good: retrying would be refused the same way
// for the life of the daemon. The next login restarts it.
Err(e) if session_revoked(&e) => {
core.mark_session_expired("seed event cursor", &e);
return;
}
Err(e) => {
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
tokio::time::sleep(delay).await;
@@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
debug!("no photos volume; photo event sync not started");
return;
}
Err(error) if session_revoked(&error) => {
core.mark_session_expired("read the photos root", &error);
return;
}
Err(error) => {
warn!(%error, "reading the photos root failed; retrying");
tokio::time::sleep(ONLINE_PROBE_MAX).await;
@@ -710,7 +721,7 @@ fn scan_local_once(
// The walk has no idea how many files it will find, so the job counts what it
// has seen and stays indeterminate.
let job = transfers.begin_job("Indexing this computer");
let job = transfers.begin_background_job("Indexing this computer");
job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) {
+7 -1
View File
@@ -334,6 +334,8 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
budget: core.cache.budget(),
pins,
online: core.online.load(Ordering::Relaxed),
session_expired: core.session_expired(),
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
pending_uploads: queued.uploads.max(0) as u64 + landing,
pending_changes: queued.changes.max(0) as u64,
}
@@ -369,7 +371,7 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
RefreshScope::Photos { .. } => &[],
},
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
R::AddSyncFolder { .. }
| R::RemoveSyncFolder { .. }
| R::SetSyncFolderMode { .. }
@@ -1297,6 +1299,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
Ok(message) => CtlResponse::Ok { message },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AddSyncFolder { local_path }) => {
// Registering the device and uploading a folder tree far outlasts the
// socket read timeout, so ack immediately and work on a background
+180 -9
View File
@@ -19,6 +19,7 @@ use pdfs_core::control::{
};
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::mounts::MountMode;
use pdfs_core::profile::PROFILE_DIR_NAME;
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node};
@@ -114,6 +115,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
.map_err(|e| CoreError::internal(format!("write config: {e}")))
}
/// The name a folder moved in from `device` takes when `name` is already
/// taken under this device's root, or `None` when it can keep its own.
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
if free(name) {
return None;
}
let base = format!("{name} ({device})");
if free(&base) {
return Some(base);
}
(2..)
.map(|n| format!("{base} {n}"))
.find(|candidate| free(candidate))
}
impl Core {
// ---- devices ----------------------------------------------------------
@@ -260,6 +277,107 @@ impl Core {
})
}
/// Move every folder another device backs up into this machine's own
/// device, so this machine carries on backing them up as itself. The other
/// device stays registered, without those folders.
///
/// Device roots all live on the main volume, so each folder is one
/// server-side move: nothing is downloaded or uploaded, and no quota is
/// used twice. A folder whose name is taken here keeps its content under
/// "<name> (<device>)". A failed folder does not stop the others, and a
/// second run picks up whatever is still left on the other device.
///
/// Nothing is synced to this disk here. The moved folders are now this
/// device's, so the ordinary restore offers them. Only folders that were
/// already synced here from the other device are touched: their uid
/// survives the move but their share does not.
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
let source = self.restore_source(Some(uid))?;
let target = self.ensure_device()?;
if source.uid == target.uid {
return Err(CoreError::invalid(format!(
"{} is this computer's own device",
source.name
)));
}
let bad_root = |d: &StoredDevice| {
parse_uid(&d.root_uid)
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
};
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
if source_root.volume_id != target_root.volume_id {
return Err(CoreError::invalid(
"the two devices are on different Proton Drive volumes; copy it instead",
));
}
let mut taken: Vec<String> = self
.device_root_nodes(&target_root)?
.into_iter()
.filter(|n| !n.trashed)
.map(|n| n.name)
.collect();
let synced = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let mut moved = 0usize;
let mut renamed: Vec<String> = Vec::new();
let mut left: Vec<String> = Vec::new();
let mut failed: Vec<String> = Vec::new();
for node in self.device_root_nodes(&source_root)? {
if node.trashed || node.name == PROFILE_DIR_NAME {
continue;
}
// A device root only ever holds folders (B68); anything else was
// put there by hand and is left for the user to deal with.
if !node.is_folder() {
left.push(node.name);
continue;
}
let new_name = migrated_name(&node.name, &source.name, &taken);
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
warn!(name = %node.name, error = %e, "migrate: could not move folder");
failed.push(format!("{}: {e}", node.name));
continue;
}
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
if new_name.is_some() {
renamed.push(format!("{} -> {landed}", node.name));
}
taken.push(landed);
moved += 1;
let uid = node.uid.to_string();
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
}
self.sync_now(Some(folder.id));
}
}
self.invalidate_children_of(&source_root);
self.invalidate_children_of(&target_root);
let mut message = format!("moved {moved} folder(s) from {}", source.name);
if !renamed.is_empty() {
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
}
if !left.is_empty() {
message.push_str(&format!("; left behind: {}", left.join(", ")));
}
if !failed.is_empty() {
message.push_str(&format!("; failed: {}", failed.join(", ")));
}
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
"device migrated");
if moved == 0 && !failed.is_empty() {
return Err(CoreError::conflict(message));
}
Ok(message)
}
// ---- device folder sync -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
@@ -360,21 +478,25 @@ impl Core {
root_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
Ok(self
.device_root_nodes(root_uid)?
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
}
/// Everything directly under a device root, trashed nodes included.
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
let uids = self
.rt
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(None);
return Ok(Vec::new());
}
let nodes = self
.rt
self.rt
.block_on(self.drive.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
Ok(nodes
.into_iter()
.find(|n| n.is_folder() && !n.trashed && n.name == name)
.map(|n| n.uid))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
}
/// Add a local folder to this device's sync set: register the device if
@@ -535,12 +657,16 @@ impl Core {
} else {
p.total.max(p.done) as u64
},
// Most passes find nothing; only one that queues work is
// the drive syncing.
background: true,
},
SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"),
detail: p.current.clone(),
done: p.done as u64,
total: p.total.max(p.done) as u64,
background: false,
},
});
}
@@ -842,6 +968,23 @@ impl Core {
if folder.state != "idle" {
return Err(SwitchBlocked::NotNow);
}
// `idle` only says the last pass applied everything it tried.
// It never tries an ignored path, a symlink or a folder it
// cannot read, it defers a file open for writing, and a file can
// change after it ends. The eviction below deletes all of those
// for good, so it waits until nothing here is missing from Drive
// (B198).
let baseline = self
.db
.sync_entries(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
if let Some(reason) =
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
{
return Err(SwitchBlocked::Failed(format!(
"going online-only would delete what is only on this computer: {reason}"
)));
}
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?;
@@ -1107,10 +1250,38 @@ mod tests {
use pdfs_core::db::StoredSyncFolder;
use super::{
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
restore_snapshot_matches, select_mode_transition,
};
#[test]
fn migrated_folder_keeps_its_name_when_it_is_free() {
let taken = vec!["Pictures".to_string()];
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
}
#[test]
fn migrated_folder_is_named_after_its_device_on_a_clash() {
let taken = vec!["Documents".to_string()];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1)")
);
}
#[test]
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
let taken = vec![
"Documents".to_string(),
"Documents (DESKTOP-1)".to_string(),
"Documents (DESKTOP-1) 2".to_string(),
];
assert_eq!(
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
Some("Documents (DESKTOP-1) 3")
);
}
#[test]
fn unknown_mode_cannot_select_a_destructive_transition() {
let mut unmounts = 0;
+47 -19
View File
@@ -347,17 +347,7 @@ impl Core {
// its writer has to be noticed.
self.sweep_parked_creates();
}
// Paused, nothing due changes that: sleep until a resume
// wakes the worker (or the idle poll notices a timed pause
// ran out), not until the next op falls due — that is
// "now" for everything already queued.
if paused {
self.wait_for_drain_work();
continue;
}
// A debounced or backed-off op may be waiting: sleep only
// until it becomes due rather than the full idle-poll.
self.wait_for_drain_work_or_due();
self.wait_for_drain_work_or_due(online && !paused);
continue;
}
};
@@ -732,14 +722,16 @@ impl Core {
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
/// checks whether a debounced or backed-off op is due before the idle-poll
/// ceiling. A 2-second debounce must not sleep 30 seconds.
fn wait_for_drain_work_or_due(&self) {
let timeout = match self.db.earliest_due_at() {
Ok(Some(ts)) => {
let remaining = (ts - now_millis()).max(0) as u64;
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL)
}
_ => DRAIN_IDLE_POLL,
///
/// `claiming` is false while the worker claims nothing (offline or
/// paused); see [`idle_wait`].
fn wait_for_drain_work_or_due(&self, claiming: bool) {
let earliest = if claiming {
self.db.earliest_due_at().ok().flatten()
} else {
None
};
let timeout = idle_wait(claiming, earliest, now_millis());
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
}
@@ -2678,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot
/// is, or `Some(None)` for a failure that is not a refusal.
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
let proton = proton_error(e)?;
if is_network_error(proton) {
// A refused session refuses everything, so it says nothing about this
// op either; the mount is offline for it (`Core::lost_link`).
if is_network_error(proton) || pdfs_core::error::session_revoked(proton) {
return None;
}
let ProtonError::Api(api) = proton else {
@@ -2833,6 +2827,23 @@ fn sleep_for_drain_work(
*woken = false;
}
/// How long an idle drain worker sleeps: until the earliest queued op falls
/// due, at most the idle poll.
///
/// A worker that claims nothing — offline or paused — sleeps the full idle
/// poll whatever is due. Every op already queued is due "now" for it, so
/// sleeping until then was a zero wait, and all the workers spun on the queue
/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a
/// resume wakes the drain, so nothing waits the full poll for it.
fn idle_wait(claiming: bool, earliest_due: Option<i64>, now: i64) -> Duration {
match earliest_due {
Some(ts) if claiming => {
Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL)
}
_ => DRAIN_IDLE_POLL,
}
}
fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option<NodeUid>)> {
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
@@ -2858,6 +2869,23 @@ mod tests {
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() {
// The op fell due long ago, and offline nobody attempts it, so it
// stays due: waiting for it was a zero wait, forever (B200).
assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL);
}
#[test]
fn a_claiming_worker_waits_only_until_the_next_op_falls_due() {
assert_eq!(
idle_wait(true, Some(12_000), 10_000),
Duration::from_secs(2)
);
assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO);
assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL);
}
fn pending(kind: &str) -> PendingOp {
PendingOp {
id: 0,
+5 -1
View File
@@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
used,
budget,
online,
session_expired,
tokens_unsaved,
pending_uploads,
pending_changes,
..
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
);
let key = format!(
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
{used}/{budget}/{online}"
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
);
(key, queue)
}
@@ -325,6 +327,8 @@ mod tests {
budget: 0,
pins: Vec::new(),
online: true,
session_expired: false,
tokens_unsaved: false,
pending_uploads: 1,
pending_changes: 0,
};
+25 -6
View File
@@ -1267,8 +1267,12 @@ impl ProtonFs {
//
// Complete pending blobs are the common case and can be copied without
// any network access. An incomplete blob still has gaps referring to
// the remote base; stacking another write on it cannot be represented
// safely by WriteHandle, so fail the open rather than risk corruption.
// the remote base, and stacking another write on it cannot be
// represented by WriteHandle, so the copy's gaps are filled from that
// base first, as the drain fills them before an upload. Only when that
// fails (offline, or the base is gone) does the open fail: refusing
// every such open failed a create over a file whose partial edit had
// not drained yet (`docs/BUGS.md` B204).
//
// Reading that blob and installing the handle cannot be done under one
// lock — the copy is the size of the file — so the base is re-checked at
@@ -1302,10 +1306,6 @@ impl ProtonFs {
}
}
let pending_base = self.core.pending.lock().get(&uid).cloned();
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
error!(%uid, "refusing write over incomplete queued revision");
break 'install Err(Errno::EIO);
}
let (file, path) = match self.core.cache.create_scratch() {
Ok(x) => x,
Err(e) => {
@@ -1325,6 +1325,25 @@ impl ProtonFs {
"copy queued revision into write scratch failed");
break 'install Err(Errno::EIO);
}
if !pending.meta.complete {
let mut authored = Intervals::default();
for &(s, e) in &pending.meta.authored {
authored.add(s, e);
}
if let Err(error) = self.core.fill_gaps(
&uid,
&file,
pending.meta.len,
pending.meta.base_mtime,
pending.meta.base_size,
&authored,
) {
let _ = std::fs::remove_file(&path);
error!(%uid, ?error, "cannot complete the queued revision a write open is based on");
break 'install Err(error);
}
debug!(%uid, "write open completed an incomplete queued revision");
}
initial_written.add(0, pending.meta.len);
}
// Sampled *before* the state lock, never under it: no site in
+7 -1
View File
@@ -123,6 +123,7 @@ use mount::{
};
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
pub use systemd::idle_sleep;
use tracing::{debug, error, info, warn};
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
use r#virtual::{
@@ -743,6 +744,9 @@ struct Core {
/// uploading that same tree — the engine would upload files as they vanish
/// and then walk the FUSE mount as if it were local.
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
/// Per-sync-folder paths the last pass could not read, so the activity feed
/// says so when the set changes rather than on every poll (B196).
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
/// Nodes this daemon changed on the remote itself, and how many echoes of
/// those changes the event feed still owes us.
///
@@ -1030,7 +1034,9 @@ impl Core {
let core = self.clone();
std::thread::spawn(move || {
let job = core.transfers.begin_job("Checking the cache database");
let job = core
.transfers
.begin_background_job("Checking the cache database");
job.detail("Verifying every page");
let problems = match core.db.integrity_check() {
Ok(problems) => problems,
+65 -3
View File
@@ -9,10 +9,11 @@
//! queued path, and the probe thread here flips it back when Proton answers.
use std::future::Future;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use parking_lot::{Condvar, Mutex};
use pdfs_core::error::session_revoked;
use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
use tracing::{debug, info, warn};
@@ -54,6 +55,26 @@ pub(crate) struct Link {
changed: Condvar,
/// Folders listed from the DB alone while offline, to relist once back.
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
/// Proton refused the session itself ([`session_revoked`]). The mount is
/// offline then, but no probe brings it back: only a new login does, and
/// that restarts the daemon. Read by front-ends through `Response::Status`,
/// so they can ask the user to sign in again rather than say "offline".
pub(crate) session_expired: AtomicBool,
/// Proton refused the session, but only because another process rotated
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
/// ends the mount for the daemon to mount again from the keyring.
pub(crate) resume_stored: AtomicBool,
}
impl Link {
/// The link of a mount that comes up with Proton refusing its session, or
/// not.
pub(crate) fn starting(session_expired: bool) -> Self {
Self {
session_expired: AtomicBool::new(session_expired),
..Default::default()
}
}
}
/// Whether a failed call failed because Proton could not be reached, rather
@@ -161,10 +182,20 @@ impl Core {
self.rt.block_on(bounded_by(limit, call))
}
/// Whether Proton refused the session itself, so nothing reaches it until
/// the user signs in again.
pub(crate) fn session_expired(&self) -> bool {
self.link.session_expired.load(Ordering::Relaxed)
}
/// Report a failed remote call. Returns whether it failed for want of a
/// network, in which case the mount is now offline and the caller should
/// take its queued path instead of failing.
/// network, or of a session, in which case the mount is now offline and the
/// caller should take its queued path instead of failing.
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
if session_revoked(e) {
self.mark_session_expired(what, e);
return true;
}
if !is_network_error(e) {
return false;
}
@@ -172,6 +203,29 @@ impl Core {
true
}
/// Flip the mount offline for good: Proton refused the session, and only a
/// new login brings it back. Changes stay queued for the daemon that login
/// starts. Only the first caller logs it.
///
/// Unless the keyring holds a newer session than the refused one: then
/// the mount ends, for the daemon to mount again on that one.
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
if self.link.resume_stored.load(Ordering::Relaxed) {
return self.mark_offline(what, error);
}
if pdfs_core::auth::stored_session_is_newer() {
info!(during = what, %error,
"Proton refused a session another process has since refreshed; resuming it");
self.link.resume_stored.store(true, Ordering::Relaxed);
return self.mark_offline(what, error);
}
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
warn!(during = what, %error,
"Proton refused the session; run `pdfs login` to sign in again");
}
self.mark_offline(what, error);
}
/// Flip the mount offline and wake the probe. Only the first caller of an
/// outage logs it; the rest are the same outage.
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
@@ -276,6 +330,9 @@ impl Core {
/// Probe until Proton answers, then flip the mount online. Returns `false`
/// if the daemon is stopping.
///
/// A refused session is not probed: every probe would be refused the same
/// way, forever (issue #35). The thread waits for the stop instead.
fn probe_until_online(&self) -> bool {
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
@@ -287,6 +344,10 @@ impl Core {
if !self.shutdown.sleep(delay) {
return false;
}
if self.session_expired() {
delay = ONLINE_PROBE_MAX;
continue;
}
let probe = self.rt.block_on(async {
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
Ok(result) => result,
@@ -308,6 +369,7 @@ impl Core {
self.mark_online();
return true;
}
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
Err(e) => {
debug!(error = %e, ?delay, "online probe failed; still offline");
delay = next_probe_delay(delay, started.elapsed());
+53 -5
View File
@@ -12,6 +12,10 @@ pub enum MountOutcome {
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
/// Proton refused the session, and the keyring holds a newer one: another
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
/// unmounted ourselves; the caller should mount again, which resumes it.
ResumeStored,
}
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
@@ -210,30 +214,60 @@ fn fetch_or_recall_root(
drive: &dyn DriveApi,
rt: &tokio::runtime::Handle,
db: &Db,
) -> std::io::Result<(Node, bool)> {
) -> std::io::Result<(Node, Start)> {
let err = match rt.block_on(drive.get_my_files_folder()) {
Ok(root) => {
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
warn!(error = %e, "persist root uid failed");
}
return Ok((root, true));
return Ok((root, Start::Online));
}
Err(e) => e,
};
// Still mounted from the cache, so cached files stay readable and changes
// queue for the daemon the next login starts.
let start = if pdfs_core::error::session_revoked(&err) {
Start::SessionExpired
} else {
Start::Offline
};
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
return Err(std::io::Error::other(format!(
"Proton refused a session another process has since refreshed ({err}); resuming it"
)));
}
let cached = db
.state_str(ROOT_UID_KEY)
.ok()
.flatten()
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
match cached {
Some(root) if start == Start::SessionExpired => {
warn!(error = %err,
"Proton refused the session; mounting from cache until `pdfs login`");
Ok((root, start))
}
Some(root) => {
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
Ok((root, false))
Ok((root, start))
}
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
"Proton refused the session ({err}); run `pdfs login`"
))),
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
}
}
/// How a mount comes up, from what fetching its root said.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Start {
Online,
/// Proton could not be reached: serve the cache and probe for the link.
Offline,
/// Proton refused the session: serve the cache until a new login.
SessionExpired,
}
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
/// caller, so `mount` itself never reads config or the environment.
pub struct MountOptions {
@@ -393,7 +427,8 @@ pub(crate) fn mount_with(
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
// so the age it reports is the daemon's own.
super::diagnostics::start_clock();
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
let online = start == Start::Online;
let scope = root.tree_event_scope_id();
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
@@ -429,7 +464,7 @@ pub(crate) fn mount_with(
shared_generation: Arc::new(AtomicU64::new(0)),
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
online: Arc::new(AtomicBool::new(online)),
link: Arc::new(Default::default()),
link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
pending: Arc::new(Mutex::new(HashMap::new())),
hidden: Arc::new(Mutex::new(HashSet::new())),
@@ -475,6 +510,7 @@ pub(crate) fn mount_with(
sync_tx,
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
states: Arc::new(StateRegistry::default()),
};
// Before anything can queue work against it: the drain thread below reaches
@@ -718,6 +754,18 @@ pub(crate) fn mount_with(
}
break MountOutcome::Shutdown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if core.link.resume_stored.load(Ordering::Relaxed) =>
{
info!("unmounting to resume the stored session");
core.mounts.lock().close();
if let Err(e) = teardown_session(&core.session_live, || {
umount_session_unblocked(bg, main_conn)
}) {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::ResumeStored;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
+20 -12
View File
@@ -660,6 +660,12 @@ impl Core {
/// the shared uids; the per-node detail is then gathered best-effort — a single
/// node racing with an unshare drops from the list rather than failing the whole
/// request.
///
/// A share with no member, no invitation and no link is left out: it is
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
/// cannot delete a share, and Drive keeps listing it until its backend gets
/// round to removing it, minutes later. A share whose details could not all
/// be read stays listed, so a failed lookup never hides a live share.
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
@@ -678,24 +684,26 @@ impl Core {
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
.map(|m| m.len());
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
.map(|i| i.len());
let external_invites = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.ok()
.flatten()
.map(public_link_info);
.map(|i| i.len());
let link = self.rt.block_on(self.client.get_public_link(&uid));
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
(&members, &proton_invites, &external_invites, &link)
{
debug!(%uid, "shared by me: skipping an emptied share");
continue;
}
let members = members.unwrap_or(0);
let proton_invites = proton_invites.unwrap_or(0);
let external_invites = external_invites.unwrap_or(0);
let link = link.ok().flatten().map(public_link_info);
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
+30
View File
@@ -451,6 +451,36 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
// Nothing here ever read the file, so the release of a partial write
// had no cached base to fill its gaps from and queued an incomplete
// revision. The next write open on it failed with EIO (B204).
let drive = FakeDrive::new();
drive.device().write("f.txt", b"0123456789");
let dir = scratch("partial-write-unread-base");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let path = daemon.mountpoint.join("f.txt");
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
write_at(&path, 2, b"ab").unwrap();
write_at(&path, 6, b"cd").unwrap();
assert!(
wait_until(Duration::from_secs(30), || {
daemon.pending().is_ok_and(|items| items.is_empty())
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
}),
"{:?} {:?}",
daemon.pending(),
drive.tree()
);
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
+569 -100
View File
@@ -50,7 +50,7 @@ mod planner;
use engine::SYNC_CONCURRENCY;
pub(crate) use engine::{SyncMsg, spawn};
#[cfg(test)]
use engine::{classify, settle_with};
use engine::{classify, event_target, settle_with};
#[cfg(test)]
use local_scan::is_write_mode;
use local_scan::open_for_write_set;
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
#[cfg(test)]
use planner::conflict_path;
use planner::{
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
tracked_baseline, unchanged_remote_size,
};
/// What a [`Pending`] op did, so the reconcile can update shared state on the
/// engine thread (never inside a task).
@@ -187,6 +188,7 @@ impl Core {
"idle"
};
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
self.report_unscanned(folder.id, name, &outcome.unscanned);
// Summarise a pass that actually moved something. A poll over an
// unchanged folder does nothing and says nothing — otherwise the
// feed would fill with "synced, 0 changes" every two minutes.
@@ -243,6 +245,37 @@ impl Core {
}
}
/// Tell the activity feed what a pass over this folder had to leave out
/// because it cannot be read, once each time that changes. Every other path
/// in the folder still synced, so this does not make the folder's state an
/// error; but a folder the user has no permission for is never going to
/// reach Drive, and they should hear that once rather than not at all
/// (B196).
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
let previous = {
let mut last = self.sync_unscanned.lock();
if unscanned.is_empty() {
last.remove(&folder_id)
} else {
last.insert(folder_id, unscanned.to_vec())
}
};
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
return;
}
const SHOWN: usize = 3;
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
if unscanned.len() > SHOWN {
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
}
self.log_activity(
ActivityKind::Sync,
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
detail,
false,
);
}
/// Compile the ignore rules for a synced folder: the global patterns from
/// the config, plus any `.pdfsignore` at `local_root`.
///
@@ -250,7 +283,7 @@ impl Core {
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
/// silently uploading those because a config read failed is the outcome this
/// feature exists to prevent.
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
let globals = match pdfs_core::config::AppDirs::new() {
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
Err(e) => {
@@ -313,15 +346,23 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len());
let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root);
self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local,
folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?;
// The guard compares against the paths this pass could still see: an
// ignored path is absent from `local` by rule, not by loss, and counting
// those as missing would trip the guard on every pass — wedging the
// folder's sync permanently — the moment a rule covers the whole tree.
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
// ignored or unreadable path is absent from `local` by rule or by
// failure, not by loss, and counting those as missing would trip the
// guard on every pass — wedging the folder's sync permanently — the
// moment a rule covers the whole tree.
let tracked = tracked_baseline(&baseline, rules, &unscanned);
guard_local_wipe(&tracked, &local)?;
// The remote folder uids come from the baseline instead of a walk: every
// directory this device has synced recorded its uid there when it was
@@ -391,6 +432,7 @@ impl Core {
let mut outcome = Outcome {
deferred: local.values().filter(|item| item.open_for_write).count(),
unscanned: unscanned.report(),
..Default::default()
};
let mut order: Vec<&String> = local.keys().collect();
@@ -515,13 +557,8 @@ impl Core {
// showing the folder's remote contents — otherwise everything they deleted
// comes back the moment the switch lands. Shallowest first, skipping anything
// under a folder already trashed (its children went with it).
let mut missing: Vec<&String> = baseline
.keys()
.filter(|rel| !local.contains_key(*rel))
.collect();
missing.sort_by_key(|p| p.matches('/').count());
let mut trashed: Vec<String> = Vec::new();
for rel in missing {
for rel in &local_deletions(&tracked, &local) {
if trashed
.iter()
.any(|dir| rel.starts_with(&format!("{dir}/")))
@@ -586,12 +623,18 @@ impl Core {
self.progress_scan_total(folder_id, baseline.len() * 2);
let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut unscanned = Unscanned::default();
let writing = open_for_write_set(local_root);
self.walk_local(
folder_id, local_root, local_root, rules, &writing, &mut local,
folder_id,
local_root,
rules,
&writing,
&mut local,
&mut unscanned,
)?;
// See `push_pass`: the guard must not count rule-excluded paths as lost.
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
// See `push_pass`: the guard must not count excluded paths as lost.
guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
@@ -605,9 +648,15 @@ impl Core {
&baseline,
)?;
let order = classification_order(&local, &remote, &baseline, rules);
let mut order = classification_order(&local, &remote, &baseline, rules);
// A path the scan could not read is no more a local deletion than an
// ignored one, and a remote path under it has nowhere to be written.
order.retain(|rel| !unscanned.covers(rel));
let mut outcome = Outcome::default();
let mut outcome = Outcome {
unscanned: unscanned.report(),
..Default::default()
};
// Folders to delete, collected here and removed deepest-first at the end
// so a parent is never removed before its children.
let mut delete_local_dirs: Vec<String> = Vec::new();
@@ -873,19 +922,19 @@ impl Core {
Ok(outcome)
}
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
/// path. Symlinks and other special files are skipped. Reports each entry to the
/// pass's scan progress.
/// Recursively walk a local tree into `out`, keyed by `/`-joined relative
/// path. Symlinks and other special files are skipped, and paths that cannot
/// be read go to `unscanned`. Reports each entry to the pass's scan progress.
fn walk_local(
&self,
folder_id: i64,
root: &Path,
dir: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
) -> Result<(), String> {
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
self.progress_scanned(folder_id, base_name(rel))
})
}
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
}
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
/// local tree under `root` into `out`, reporting each entry to `scanned`.
///
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
/// on disk. Reconciliation reads a path present in the baseline and absent from
/// the scan as "the user deleted this", and propagates that to Drive. So an
/// unreadable subdirectory would trash its remote counterpart, and the deeper
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
/// whole pass keeps the previous baseline and leaves the remote alone; the next
/// pass retries once the transient condition clears.
/// **A path the scan cannot read is left out and recorded in `unscanned`, never
/// silently dropped.** Every pass drops what `unscanned` covers from
/// classification, so a baseline row under it is neither a local deletion nor a
/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
/// permission for, an entry that vanished between the listing and its `stat`,
/// and a name that is not UTF-8. Failing the whole pass on any of them, as this
/// did before, left one root-owned folder stopping everything else in the tree
/// from syncing, on every pass, for good (B196).
///
/// The root is the exception, and so is any other I/O error: an absent or
/// unreadable root fails the pass, since leaving the root out would leave
/// nothing in, and an error this cannot name a cause for is not one to guess
/// about.
///
/// Split out from the method so exactly that can be tested against a real tree
/// with a real unreadable directory in it, which needs no `Core` at all.
fn walk_local_tree(
root: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str),
) -> Result<(), String> {
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
}
/// Why [`walk_local_dir`] could not finish a folder.
enum ScanError {
/// An I/O error on `what`, which the caller may leave out of the pass.
Io { what: String, error: std::io::Error },
/// Anything else, which fails the pass.
Failed(String),
}
impl std::fmt::Display for ScanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
ScanError::Failed(message) => f.write_str(message),
}
}
}
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
/// that fails the pass. Permission denied is reported to the user; an entry
/// that vanished mid-scan is not, since the next pass sees it settled.
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
let ScanError::Io { what, error } = &failure else {
return Err(failure);
};
match error.kind() {
std::io::ErrorKind::PermissionDenied => {
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
unscanned.unreadable(rel.to_string(), &error.to_string());
Ok(())
}
std::io::ErrorKind::NotFound => {
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
unscanned.vanished(rel.to_string());
Ok(())
}
_ => Err(failure),
}
}
fn walk_local_dir(
root: &Path,
dir: &Path,
rules: &IgnoreRules,
writing: &HashSet<PathBuf>,
out: &mut HashMap<String, LocalItem>,
unscanned: &mut Unscanned,
scanned: &mut dyn FnMut(&str),
) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
) -> Result<(), ScanError> {
let io = |what: String| move |error| ScanError::Io { what, error };
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
for entry in entries {
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
let path = entry.path();
let meta = std::fs::symlink_metadata(&path)
.map_err(|e| format!("stat {}: {e}", path.display()))?;
let stripped = path.strip_prefix(root).map_err(|e| {
format!(
ScanError::Failed(format!(
"local path {} escaped sync root {}: {e}",
path.display(),
root.display()
)
})?;
let rel = stripped.to_str().ok_or_else(|| {
format!(
"local path {} is not valid UTF-8; refusing a destructive sync pass",
path.display()
)
))
})?;
let Some(rel) = stripped.to_str() else {
// Nothing can be keyed by this name: not the baseline, which only
// ever held paths this scan produced, and not Drive, whose names are
// UTF-8. Leaving it out cannot read as a deletion of anything.
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
unscanned.unreadable(
stripped.to_string_lossy().into_owned(),
"name is not valid UTF-8",
);
continue;
};
// Ignore our own in-flight download temp files.
if rel.contains(".pdfs-tmp-") {
continue;
}
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
// Gone since the listing, or in a folder that lists but does not
// allow `stat`. Either way this pass cannot say what it is.
Err(error) => {
let what = format!("stat {}", path.display());
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
continue;
}
};
// Ignored paths are dropped here as well as from the classification
// union — not for correctness (the union filter is what makes this
// safe) but so an ignored `node_modules/` is never descended into or
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
},
);
scanned(rel);
walk_local_tree(root, &path, rules, writing, out, scanned)?;
if let Err(failure) =
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
{
leave_out(rel, failure, unscanned)?;
// What the folder did list before failing goes with it: a
// half-read folder is as unknown as an unread one.
let prefix = format!("{rel}/");
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
}
} else if meta.is_file() {
// Check whether any process holds this file open for writing.
// The canonical path is used because /proc/*/fd links resolve
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
baseline: &HashMap<String, StoredSyncEntry>,
) -> Option<String> {
let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
return Some(reason);
}
let prefix = format!("{rel}/");
@@ -1695,45 +1821,93 @@ pub(crate) fn mirror_subtree_unsynced(
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
}
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
/// holds something Proton Drive does not, or `None` when evicting it loses
/// nothing. The switch to on-demand asks this before it empties the folder.
///
/// A push pass that ends `idle` is not enough on its own. It never uploads an
/// ignored path, a symlink or a folder it cannot read, it defers a file open
/// for writing, and a file can change after it finishes; the eviction deleted
/// all of those for good (B198).
///
/// A tracked path deleted here but still in the baseline counts, since the
/// mount would show it again. An ignored one does not: ignoring never carries
/// a deletion to Drive, so its row stays in the baseline for good, and holding
/// the switch on it would hold it forever.
pub(crate) fn mirror_tree_unsynced(
root: &Path,
baseline: &HashMap<String, StoredSyncEntry>,
rules: &IgnoreRules,
) -> Option<String> {
let mut seen = HashSet::new();
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
return Some(reason);
}
let mut missing: Vec<String> = filter_baseline(baseline, rules)
.into_keys()
.filter(|r| !seen.contains(r.as_str()))
.collect();
missing.sort_unstable();
missing
.first()
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
}
/// The walk behind both checks. `rules`, when given, only names an ignored
/// path as such in the reason; it is still something only this disk has.
fn unsynced_local(
root: &Path,
rel: &str,
baseline: &HashMap<String, StoredSyncEntry>,
rules: Option<&IgnoreRules>,
seen: &mut HashSet<String>,
) -> Option<String> {
let path = root.join(rel_to_path(rel));
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(e) => return Some(format!("cannot read {rel}: {e}")),
let path = if rel.is_empty() {
root.to_path_buf()
} else {
root.join(rel_to_path(rel))
};
let Some(base) = baseline.get(rel) else {
return Some(format!("{rel} is not on Proton Drive yet"));
};
seen.insert(rel.to_string());
if meta.is_file() {
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
}
if !meta.is_dir() {
return Some(format!("{rel} is not a regular file or folder"));
// The folder itself has no baseline row; only what is in it does.
if !rel.is_empty() {
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(e) => return Some(format!("cannot read {rel}: {e}")),
};
let Some(base) = baseline.get(rel) else {
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
return Some(format!(
"{rel} is excluded from sync, so it is only on this computer"
));
}
return Some(format!("{rel} is not on Proton Drive yet"));
};
seen.insert(rel.to_string());
if meta.is_file() {
return (!LocalSig::from(&meta).same_content(&LocalSig::from(base)))
.then(|| format!("{rel} has changes that are not on Proton Drive yet"));
}
if !meta.is_dir() {
return Some(format!("{rel} is not a regular file or folder"));
}
}
let shown = if rel.is_empty() { "the folder" } else { rel };
let entries = match std::fs::read_dir(&path) {
Ok(entries) => entries,
Err(e) => return Some(format!("cannot read {rel}: {e}")),
Err(e) => return Some(format!("cannot read {shown}: {e}")),
};
for entry in entries {
let name = match entry {
Ok(entry) => entry.file_name(),
Err(e) => return Some(format!("cannot read {rel}: {e}")),
Err(e) => return Some(format!("cannot read {shown}: {e}")),
};
let Some(name) = name.to_str() else {
return Some(format!("{rel} holds a name that is not valid UTF-8"));
return Some(format!("{shown} holds a name that is not valid UTF-8"));
};
// A download the engine left half done; it is the engine's to clean up.
if name.contains(".pdfs-tmp-") {
continue;
}
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
return Some(reason);
}
}
@@ -1923,6 +2097,32 @@ mod tests {
(ids, started.elapsed())
}
#[test]
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
let watched = vec![
(PathBuf::from("/home/me/Docs"), 1),
(PathBuf::from("/home/me/Photos"), 2),
(PathBuf::from("/home/me/Photos/Raw"), 3),
];
let target = |paths: &[&str]| {
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
match event_target(&watched, &paths) {
SyncMsg::Reconcile(id) => Some(id),
SyncMsg::ReconcileAll => None,
_ => panic!("unexpected message"),
}
};
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
// A rename within one folder.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
// The deepest root wins.
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
// Across folders, outside them all, or no path at all: walk everything.
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
assert_eq!(target(&["/home/me/Docsx/a"]), None);
assert_eq!(target(&[]), None);
}
/// The bug this replaced a fixed sleep for: a save that keeps writing past
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
/// real revision. The window has to restart on every event, so settling
@@ -2154,7 +2354,15 @@ mod tests {
let writing = HashSet::new();
let mut local: HashMap<String, LocalItem> = HashMap::new();
let mut progress = no_progress();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
assert!(local.is_empty(), "the replacement root is genuinely empty");
for size in 1..=3 {
@@ -2170,7 +2378,15 @@ mod tests {
// remounted folder resumes rather than staying wedged.
std::fs::write(root.join("file0.txt"), b"back").unwrap();
let mut local: HashMap<String, LocalItem> = HashMap::new();
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
walk_local_tree(
&root,
&rules,
&writing,
&mut local,
&mut Unscanned::default(),
&mut progress,
)
.unwrap();
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
@@ -2475,33 +2691,176 @@ mod tests {
|_: &str| {}
}
/// A directory the pass cannot read must fail the pass. Omitting it would
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
/// having deleted that subtree and propagates to Drive (B55).
/// Scan `root` with no rules, returning what it found and left out.
fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
let mut out = HashMap::new();
let mut unscanned = Unscanned::default();
let mut progress = no_progress();
walk_local_tree(
root,
&rules_for(&[]),
&HashSet::new(),
&mut out,
&mut unscanned,
&mut progress,
)?;
Ok((out, unscanned))
}
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
/// B196, the report in #33: a folder the user may not read, such as a
/// container's root-owned cache, is left out of the pass and named, and
/// everything around it still scans. It used to fail the whole pass, so
/// nothing else in the tree ever synced again.
#[test]
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-eacces");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
let locked = root.join("locked");
std::fs::create_dir_all(root.join("docker/cache")).unwrap();
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
let locked = root.join("docker/cache/locked");
std::fs::create_dir(&locked).unwrap();
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
let result = scan(&root);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("an unreadable directory must abort the pass");
assert!(
error.contains("locked"),
"the error should name the subtree it could not read: {error}"
let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
assert_eq!(
sorted_keys(&out),
vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
);
assert!(unscanned.covers("docker/cache/locked"));
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
assert!(!unscanned.covers("docker/cache"));
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
}
/// A folder with read but no search permission lists its names but cannot
/// `stat` them. Whatever it did list must go with it: a half-read folder
/// is as unknown as an unread one.
#[test]
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
use std::os::unix::fs::PermissionsExt as _;
let root = sync_test_dir("scan-no-search");
std::fs::write(root.join("keep.txt"), b"a").unwrap();
let half = root.join("half");
std::fs::create_dir(&half).unwrap();
std::fs::write(half.join("one.txt"), b"1").unwrap();
std::fs::write(half.join("two.txt"), b"2").unwrap();
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
let result = scan(&root);
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
let (out, unscanned) = result.unwrap();
// Each entry fails its own `stat` and is left out by name.
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
assert!(unscanned.covers("half/one.txt"));
assert!(unscanned.covers("half/two.txt"));
}
/// The other half of B196: what was left out is not a deletion, and not a
/// download target either. Its baseline rows leave the wipe guard's set
/// and a push pass's deletions, and its paths leave classification.
#[test]
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
let mut baseline = baseline_with("keep.txt", 1, 1);
baseline.extend(baseline_with("locked", 0, 0));
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
baseline.extend(baseline_with("gone.txt", 1, 1));
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
.into_iter()
.collect();
let mut unscanned = Unscanned::default();
unscanned.unreadable("locked".to_string(), "Permission denied");
let rules = rules_for(&[]);
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
// Every tracked path but one is gone, and the guard still lets the
// pass run: one survivor proves the tree is there.
assert!(guard_local_wipe(&tracked, &local).is_ok());
// With everything readable left out, nothing is tracked, so nothing
// can be lost and the guard has nothing to object to.
let mut all = Unscanned::default();
all.unreadable("keep.txt".to_string(), "Permission denied");
all.unreadable("locked".to_string(), "Permission denied");
all.vanished("gone.txt".to_string());
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
let remote: HashMap<String, RemoteItem> = HashMap::new();
let mut order = classification_order(&local, &remote, &baseline, &rules);
order.retain(|rel| !unscanned.covers(rel));
order.sort_unstable();
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
}
#[test]
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
let mut unscanned = Unscanned::default();
unscanned.unreadable("a/locked".to_string(), "Permission denied");
unscanned.vanished("b.txt".to_string());
assert!(unscanned.covers("a/locked"));
assert!(unscanned.covers("a/locked/x/y.txt"));
assert!(unscanned.covers("b.txt"));
assert!(!unscanned.covers("a"));
assert!(!unscanned.covers("a/locked2"));
assert!(!unscanned.covers("b.txt.bak"));
// A file that only vanished mid-scan is not worth telling anyone about.
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
}
/// B197: a push pass read its deletions from the raw baseline, so a path
/// synced before it became ignored, which the walk skips, was trashed on
/// Drive, and the switch the push pass ran for then evicted the local copy.
#[test]
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
let mut baseline = baseline_with("notes.md", 1, 1);
baseline.extend(baseline_with("node_modules", 0, 0));
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
baseline.extend(baseline_with("old.md", 1, 1));
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
.into_iter()
.collect();
let rules = rules_for(&["node_modules/"]);
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
}
/// Deletions come out shallowest first, so a trashed folder can take its
/// children with it.
#[test]
fn local_deletions_come_out_shallowest_first() {
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
baseline.extend(baseline_with("d", 0, 0));
baseline.extend(baseline_with("d/e", 0, 0));
let local: HashMap<String, LocalItem> = HashMap::new();
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
assert_eq!(
local_deletions(&tracked, &local),
vec!["d", "d/e", "d/e/f.txt"]
);
}
@@ -2593,6 +2952,94 @@ mod tests {
assert_eq!(reason, None);
}
// ---- B198: going online-only never evicts what Drive does not have -----
#[test]
fn a_fully_synced_mirror_folder_may_go_online_only() {
let (root, baseline) = synced_subtree("evict-synced");
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(reason, None);
}
/// The case that lost data: `.git/` is ignored by default, so no pass ever
/// uploads it, and the eviction deleted it with the rest.
#[test]
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-ignored");
std::fs::create_dir(root.join(".git")).unwrap();
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
std::fs::remove_dir_all(&root).unwrap();
let reason = reason.expect("an ignored folder is only on this disk");
assert!(
reason.contains(".git") && reason.contains("excluded"),
"{reason}"
);
}
/// An ignored row stays in the baseline for good once its file is gone, so
/// counting it as a deletion not carried up would hold the switch forever.
#[test]
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
baseline.insert(
"build/out.o".to_string(),
StoredSyncEntry {
rel_path: "build/out.o".to_string(),
..baseline["dir/a.txt"].clone()
},
);
let rules = rules_for(&["build/"]);
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(ignored, None);
assert!(tracked.unwrap().contains("build/out.o"));
}
#[test]
fn an_edit_after_the_last_pass_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-edit");
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub/b.txt"));
}
#[test]
fn a_new_file_at_the_root_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-new");
std::fs::write(root.join("new.txt"), b"n").unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("new.txt"));
}
/// The folder B196 now leaves out of the pass instead of failing it is,
/// by the same token, something the eviction must not delete.
#[test]
fn an_unreadable_folder_holds_the_switch_back() {
use std::os::unix::fs::PermissionsExt as _;
let (root, baseline) = synced_subtree("evict-eacces");
let locked = root.join("dir/sub");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("dir/sub"));
}
#[test]
fn a_symlink_holds_the_switch_back() {
let (root, baseline) = synced_subtree("evict-symlink");
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
std::fs::remove_dir_all(&root).unwrap();
assert!(reason.unwrap().contains("link"));
}
/// The same rule one level up: if the sync root itself cannot be read, the
/// pass sees nothing, and "nothing" must never mean "delete everything".
#[test]
@@ -2607,7 +3054,14 @@ mod tests {
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
let result = walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress,
);
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::remove_dir_all(&root).unwrap();
@@ -2628,30 +3082,40 @@ mod tests {
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
assert!(
walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut progress
)
.is_err()
);
assert!(out.is_empty());
}
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
/// cannot be compared against the baseline either. Refusing the pass is the
/// only safe reading: skipping it would classify the file as deleted.
/// A name that is not valid UTF-8 cannot be keyed into the scan, but it
/// cannot be in the baseline or on Drive either, so leaving it out reads as
/// the deletion of nothing. It used to fail the pass (B196).
#[test]
fn a_non_utf8_name_fails_the_scan() {
fn a_non_utf8_name_is_left_out_and_reported() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let root = sync_test_dir("scan-non-utf8");
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
std::fs::write(root.join("good.txt"), b"y").unwrap();
let rules = rules_for(&[]);
let writing = HashSet::new();
let mut out = HashMap::new();
let mut progress = no_progress();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
let result = scan(&root);
std::fs::remove_dir_all(&root).unwrap();
let error = result.expect_err("a name the scan cannot key must abort the pass");
assert!(error.contains("UTF-8"), "{error}");
let (out, unscanned) = result.unwrap();
assert_eq!(sorted_keys(&out), vec!["good.txt"]);
let report = unscanned.report();
assert_eq!(report.len(), 1);
assert!(report[0].contains("UTF-8"), "{report:?}");
}
/// The complement: a scan that *can* read everything reports everything, so
@@ -2670,9 +3134,14 @@ mod tests {
let writing = HashSet::new();
let mut out = HashMap::new();
let mut seen: Vec<String> = Vec::new();
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
seen.push(rel.to_string())
});
let result = walk_local_tree(
&root,
&rules,
&writing,
&mut out,
&mut Unscanned::default(),
&mut |rel| seen.push(rel.to_string()),
);
std::fs::remove_dir_all(&root).unwrap();
result.unwrap();
+29 -3
View File
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
// Paths the watcher currently covers, mapped to their folder id, so an event
// path can be resolved back to the folder it belongs to.
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
let tx_events = core.sync_tx.clone();
let watched_events = watched.clone();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let _ = tx_events.send(SyncMsg::ReconcileAll);
let msg = event_target(&watched_events.lock(), &event.paths);
let _ = tx_events.send(msg);
}) {
Ok(w) => w,
Err(e) => {
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
}
}
/// The reconcile a watcher event asks for: the one mirror folder all its paths
/// lie in, so an edit in one folder does not walk every other one too. An event
/// that spans folders, or that no watched folder claims (a watch list a moment
/// out of date), falls back to every folder.
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
let mut ids = paths.iter().map(|path| {
watched
.iter()
.filter(|(root, _)| path.starts_with(root))
// The deepest root, should one mirror folder sit inside another.
.max_by_key(|(root, _)| root.components().count())
.map(|(_, id)| *id)
});
match ids.next() {
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
_ => SyncMsg::ReconcileAll,
}
}
/// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones.
///
/// The watch calls run on a copy, outside the lock: the watcher's event
/// callback takes the same lock, and a backend that hands a watch request to
/// its event thread and waits for the answer would deadlock against it.
fn rewatch(
core: &Core,
watcher: &mut notify::RecommendedWatcher,
@@ -274,7 +299,7 @@ fn rewatch(
return;
}
};
let mut have = watched.lock();
let mut have = watched.lock().clone();
// Drop watches no longer wanted.
have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) {
@@ -294,6 +319,7 @@ fn rewatch(
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
}
}
*watched.lock() = have;
}
// ---- reconcile ------------------------------------------------------------
+5
View File
@@ -98,6 +98,11 @@ pub(super) struct Outcome {
pub(super) errors: usize,
/// Files skipped because another process held them open for writing.
pub(super) deferred: usize,
/// Paths the local scan could not read, left out of the pass. Not part of
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
/// unreadable from one poll to the next, so the pass reports it once, when
/// the set changes, rather than on every poll.
pub(super) unscanned: Vec<String>,
}
impl Outcome {
+90
View File
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
.collect()
}
/// The paths a local scan left out because it could not read them: a folder
/// it has no permission for, an entry that vanished between the listing and
/// its `stat`, a name that is not UTF-8.
///
/// A left-out path is not a deleted one. A pass drops everything an entry
/// covers from classification, exactly as it drops ignored paths, so a
/// baseline row under an unreadable folder is neither read as a local deletion
/// nor answered with a download into a folder the pass cannot write. Failing
/// the whole pass instead is what B55 did, and it left one root-owned folder
/// stopping every other file in the tree from syncing (B196).
#[derive(Default)]
pub(super) struct Unscanned {
covered: HashSet<String>,
/// The subset worth telling the user about, with why: an entry that only
/// vanished mid-scan is not, since the next pass sees it settled.
unreadable: Vec<String>,
}
impl Unscanned {
/// Leave out `rel`, which vanished while the scan looked at it.
pub(super) fn vanished(&mut self, rel: String) {
self.covered.insert(rel);
}
/// Leave out `rel`, which cannot be read for `reason`.
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
self.unreadable.push(format!("{rel} ({reason})"));
self.covered.insert(rel);
}
/// Whether `rel` is a left-out path or lies under one.
pub(super) fn covers(&self, rel: &str) -> bool {
if self.covered.is_empty() {
return false;
}
let mut at = rel;
loop {
if self.covered.contains(at) {
return true;
}
match at.rfind('/') {
Some(i) => at = &at[..i],
None => return false,
}
}
}
/// The unreadable paths with their reasons, sorted, for the activity feed.
pub(super) fn report(&self) -> Vec<String> {
let mut lines = self.unreadable.clone();
lines.sort_unstable();
lines
}
}
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
/// checks and the set a push pass reads deletions from, since a path outside
/// it is absent from the local walk by rule or by failure, never by loss.
pub(super) fn tracked_baseline<'a, B>(
baseline: &'a HashMap<String, B>,
rules: &IgnoreRules,
unscanned: &Unscanned,
) -> HashMap<String, &'a B> {
let mut tracked = filter_baseline(baseline, rules);
tracked.retain(|rel, _| !unscanned.covers(rel));
tracked
}
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
/// from the local walk. Shallowest first, so a trashed folder takes its
/// children with it.
///
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
/// it raw trashed every synced path that later became ignored, because the walk
/// skips those, and the switch to on-demand that the push pass runs for then
/// deleted the local copy too (B197).
pub(super) fn local_deletions<B, L>(
tracked: &HashMap<String, &B>,
local: &HashMap<String, L>,
) -> Vec<String> {
let mut missing: Vec<String> = tracked
.keys()
.filter(|rel| !local.contains_key(*rel))
.cloned()
.collect();
missing.sort_by_key(|p| p.matches('/').count());
missing
}
/// Refuse to run a pass whose local side has vanished in its entirety.
///
/// When every baseline path is absent locally, the likely cause is an unavailable
+20
View File
@@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() {
let _ = notify("WATCHDOG=1\n");
}
/// Sleep for `duration`, pinging the watchdog on the way.
///
/// For the stretches where the daemon is alive but has no mount, and so no
/// supervisor: waiting for a login, or backing off after a failed mount. The
/// manager arms the watchdog when the process starts, so without these pings a
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
/// coredump instead of the error it was logging. Never use this while mounted:
/// there the ping must prove the control socket answers.
pub fn idle_sleep(duration: Duration) {
const CHUNK: Duration = Duration::from_secs(10);
let mut left = duration;
watchdog_ping();
while !left.is_zero() {
let step = left.min(CHUNK);
std::thread::sleep(step);
left -= step;
watchdog_ping();
}
}
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
/// for a hang by the watchdog it is racing.
pub(crate) fn stopping() {
+17 -1
View File
@@ -46,6 +46,8 @@ struct JobEntry {
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
/// Housekeeping that moves nothing; see [`JobItem::background`].
background: bool,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
@@ -160,12 +162,25 @@ impl TransferRegistry {
/// that carries its progress and deregisters the job when dropped. The job
/// starts indeterminate; give it a `total` once one is known.
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), false)
}
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
/// for and that changes nothing in their files, such as an index rebuild or
/// a cache check. It is reported, but front ends don't call the drive busy
/// over it.
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
self.register_job(title.into(), true)
}
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
title,
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
background,
});
self.jobs.lock().insert(id, entry.clone());
JobGuard {
@@ -191,6 +206,7 @@ impl TransferRegistry {
detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
background: e.background,
}
})
.collect()
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 441 B

@@ -14,7 +14,6 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
+6 -1
View File
@@ -18,7 +18,9 @@
.sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
/* The account row is the main menu's button: a row, not a pushbutton, so
* the name keeps the text weight. */
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge {
min-width: 10px;
@@ -48,6 +50,9 @@
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
+34 -28
View File
@@ -34,6 +34,7 @@ use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme};
use widgets::compat::*;
use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
@@ -119,6 +120,9 @@ struct Ui {
/// keyring (a DBus round-trip). Populated at startup and updated only on
/// login / logout. `None` = signed out.
session: RefCell<Option<auth::StoredSession>>,
/// Whether this run already asked for the mailbox password of a locked
/// account, so a refresh never re-opens a dialog the user cancelled.
unlock_offered: Cell<bool>,
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
/// (which need the daemon to evict + re-hydrate) and every mutating action.
mounted: RefCell<bool>,
@@ -199,12 +203,20 @@ impl Ui {
}
fn main() -> glib::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
let logs = tracing_subscriber::fmt().with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
);
// The verification window's stdout carries its token back to the app.
if verify {
logs.with_writer(std::io::stderr).init();
} else {
logs.init();
}
i18n::init();
if verify {
return run_verification_window();
}
// The tray launches the app with arguments ("--page locations",
// "--confirm-stop"), which a running instance must receive too.
@@ -300,6 +312,11 @@ fn build_window(app: &adw::Application) {
return;
}
};
// On a fresh install the daemon has not run yet to make these, and the
// Takeout page keeps its staged archives in the state dir (B194).
if let Err(e) = dirs.ensure() {
tracing::warn!("cannot create the app's directories: {e}");
}
let stack = adw::ViewStack::new();
let (login_page, login_widgets) = build_login_page();
@@ -362,6 +379,7 @@ fn build_window(app: &adw::Application) {
busy: Cell::new(0),
opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()),
unlock_offered: Cell::new(false),
mounted: RefCell::new(false),
sidebar: sidebar_list.clone(),
nav: split.clone(),
@@ -561,6 +579,7 @@ fn build_window(app: &adw::Application) {
detail: locations_widgets.card_detail.clone(),
pause: locations_widgets.pause.clone(),
paused: Cell::new(false),
sign_in: locations_widgets.card_sign_in.clone(),
},
queue: QueueState {
group: locations_widgets.queue_group.clone(),
@@ -813,7 +832,6 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
@@ -1136,25 +1154,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
}
}
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
/// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
.build()
}
/// Back the primary and account menus' entries with window actions.
/// Back the main menu's entries with window actions.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone();
@@ -1206,7 +1206,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let dialog = adw::AboutDialog::builder()
.application_name(gettext("Proton Drive for Linux"))
.application_icon("io.narl.proton-drive-linux")
.version(pdfs_core::config::APP_VERSION)
.version(env!("CARGO_PKG_VERSION"))
.developer_name("Nils Pukropp")
.website("https://proton-drive.narl.io")
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
@@ -1291,6 +1291,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
if ui_page.session.borrow().is_none() {
return;
}
// Signed in, but Proton refused the session (the tray's "Sign In
// Again…"): the login page, with the rest of the app still reachable.
if page == "login" {
show_login(&ui_page);
return;
}
// Activity lives on as the Sync page's History view.
if page == "activity" {
ui_page.locations.views.set_visible_child_name("history");
@@ -1346,8 +1352,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
format!(
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
pdfs_core::config::APP_VERSION,
pdfs_core::config::USER_AGENT,
config.resolved_app_version(),
config.resolved_user_agent(),
adw::major_version(),
adw::minor_version(),
adw::micro_version(),
+2 -9
View File
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
));
let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
ui.browser.crumb.append(&sep);
ui.browser.crumb.append(&crumb_separator());
acc = if acc.is_empty() {
seg.to_string()
} else {
@@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
/// flat button that navigates to `target` (clearing any active search first).
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
+67 -9
View File
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
menu.item(&gettext("Restore to This Computer…"), move || {
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());
menu.item(&gettext("Move to This Computer…"), move || {
prompt_migrate_device(&ui_c, &uid_c, &name_c)
});
menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || {
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
dialog.present(win.as_ref());
}
/// Ask before moving another computer's folders into this computer's backup,
/// then offer them for syncing here.
///
/// Destructive-looking on purpose: the other computer keeps its device but
/// loses its folders, and a copy of Proton Drive still running there would
/// watch them vanish.
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Move Backup to This Computer?"))
// Translators: {name} is the name of the other computer whose folders move to this one.
.body(gettext_f(
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here"));
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
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 computer whose folders moved to this one.
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "move" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::MigrateDevice { uid: 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 move that backup");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The folders are this computer's now, so its own restore
// picker is where the user chooses their local paths.
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());
}
/// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui.busy_begin();
+28 -5
View File
@@ -46,6 +46,8 @@ pub(crate) struct SyncCard {
pub(crate) pause: adw::SplitButton,
/// Whether the last status said paused, so the button knows which way to go.
pub(crate) paused: Cell<bool>,
/// Shown only while Proton refuses the session; opens the sign-in page.
pub(crate) sign_in: gtk4::Button,
}
/// What a folder row's controls are built from. The subtitle and progress bar
@@ -108,6 +110,7 @@ pub(crate) struct LocationsWidgets {
pub(crate) card_icon: gtk4::Image,
pub(crate) card_title: gtk4::Label,
pub(crate) card_detail: gtk4::Label,
pub(crate) card_sign_in: gtk4::Button,
pub(crate) pause: adw::SplitButton,
pub(crate) queue_group: adw::PreferencesGroup,
pub(crate) retry_all: gtk4::Button,
@@ -134,7 +137,7 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
.build();
add_folder.add_css_class("flat");
let transfers_group = build_transfers_group();
let (card, card_icon, card_title, card_detail) = build_sync_card();
let (card, card_icon, card_title, card_detail, card_sign_in) = build_sync_card();
let pause = build_pause_button();
let (queue_group, retry_all) = build_queue_group();
let conflicts_group = adw::PreferencesGroup::builder()
@@ -253,6 +256,7 @@ pub(crate) fn build_locations_page(history: &gtk4::Widget) -> (gtk4::Widget, Loc
card_icon,
card_title,
card_detail,
card_sign_in,
pause,
queue_group,
retry_all,
@@ -290,6 +294,11 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: &gtk4::Button, add_folder: &gtk
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
wire_pause(ui);
let ui_sign_in = ui.clone();
ui.locations
.card
.sign_in
.connect_clicked(move |_| show_login(&ui_sign_in));
let ui_retry = ui.clone();
ui.locations
.queue
@@ -379,9 +388,15 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
state.banner.set_revealed(away && title.is_some());
}
/// The status hero: a large state icon, the state in words, and what it
/// means.
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
/// The status hero: a large state icon, the state in words, what it means,
/// and the way out when there is one only the user can take.
fn build_sync_card() -> (
gtk4::Box,
gtk4::Image,
gtk4::Label,
gtk4::Label,
gtk4::Button,
) {
let icon = gtk4::Image::builder()
.icon_name("pdfs-sync-symbolic")
.pixel_size(48)
@@ -408,9 +423,17 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
card.add_css_class("card");
card.add_css_class("sync-card");
let sign_in = gtk4::Button::builder()
.label(gettext("Sign In Again"))
.valign(gtk4::Align::Center)
.visible(false)
.build();
sign_in.add_css_class("pill");
sign_in.add_css_class("suggested-action");
card.append(&icon);
card.append(&text);
(card, icon, title, detail)
card.append(&sign_in);
(card, icon, title, detail, sign_in)
}
/// Pause/Resume, with timed pauses in its menu.
+286 -32
View File
@@ -1,5 +1,5 @@
use crate::*;
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
pub(crate) struct LoginState {
// Login page.
@@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
set_signing_in(&ui, true);
ui.login.login_status.set_text(&gettext("Signing in…"));
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
let LoginChannels {
result: rx,
totp: totp_req_rx,
hv: hv_req_rx,
mailbox: mailbox_req_rx,
} = spawn_login(username, password);
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
// the account has 2FA enabled). The worker blocks until the dialog feeds
@@ -145,15 +150,40 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
});
// Same shape for human verification: only fires when the API gates the
// sign-in, and the worker is blocked on the token until it does.
// sign-in, and the worker is blocked on the credential until it does.
let ui_hv = ui.clone();
glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
ui_hv
.login
.login_status
.set_text(&gettext("Complete the verification to continue…"));
prompt_human_verification(&ui_hv, &url, token_tx);
// Anything but a credential drops the sender, which the worker
// reads as a cancelled sign-in.
match verify_human(hv.verification_url()).await {
Verification::Solved(token) => {
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
}
Verification::Cancelled => {}
// The web view could not start, most likely its sandbox
// (B195): the user's browser can show the page instead.
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
}
}
});
// And for the mailbox password, which only a two-password account has.
// The worker asks again after a wrong answer, so this loops too.
let ui_mailbox = ui.clone();
glib::spawn_future_local(async move {
while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await {
ask_mailbox(&ui_mailbox, attempt > 0, move |answer| {
// A cancelled dialog drops the sender, which the worker
// reads as a cancelled login.
if let Some(answer) = answer {
let _ = answer_tx.send(answer);
}
});
}
});
@@ -209,6 +239,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
gettext("Sign-in cancelled: no two-factor code was entered.")
}
pdfs_core::Error::Other(message) if message.contains("mailbox") => {
gettext("Sign-in cancelled: no mailbox password was entered.")
}
pdfs_core::Error::WrongMailboxPassword => {
gettext("That mailbox password didn't unlock your account.")
}
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
),
@@ -220,26 +256,34 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
}
}
/// What the login worker asks the UI for, and how it reports back.
pub(crate) struct LoginChannels {
/// The final outcome, once.
pub(crate) result: async_channel::Receiver<Result<(), String>>,
/// Fires only if the account needs a 2FA code; carries the sender for it.
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
/// Fires only if the sign-in is gated: the challenge, and the sender for the
/// credential the user earns by solving it.
pub(crate) hv: async_channel::Receiver<(
HumanVerification,
std::sync::mpsc::Sender<HumanVerificationCredential>,
)>,
/// Fires only for a two-password account: how many answers were already
/// wrong, and the sender for the password and the "remember" choice.
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
}
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
/// runtime. Returns two channels: the first yields the final login result once;
/// the second fires *only if* the account needs a 2FA code, carrying a
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
/// closure blocks the worker on that sender until the dialog answers, so the
/// code is requested lazily and can't expire before the password proof.
#[allow(clippy::type_complexity)]
pub(crate) fn spawn_login(
username: String,
password: String,
) -> (
async_channel::Receiver<Result<(), String>>,
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
) {
/// runtime. The returned [`LoginChannels`] carry the final result and the lazy
/// prompts. Each login closure blocks the worker on a sender the UI answers, so
/// a code is requested lazily and can't expire before the password proof.
pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
let (tx, rx) = async_channel::bounded(1);
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
// for a code on each attempt and a single slot would deadlock the worker.
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -267,19 +311,30 @@ pub(crate) fn spawn_login(
.recv()
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
},
|hv| {
// Gated: hand the UI the page to show, block on the token the
// user earns by solving it.
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
hv_req_tx
.send_blocking((hv.verification_url(), token_tx))
.map_err(|_| {
pdfs_core::Error::Other("verification prompt closed".into())
})?;
let token = token_rx
|attempt| {
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
mailbox_req_tx
.send_blocking((attempt, answer_tx))
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
let (password, remember) = answer_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
Ok(HumanVerificationCredential::captcha(token))
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
Ok(auth::Mailbox {
password: password.into(),
remember,
})
},
|hv| {
// Gated: hand the UI the challenge, block on the credential
// the user earns by solving it.
let (credential_tx, credential_rx) =
std::sync::mpsc::channel::<HumanVerificationCredential>();
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
pdfs_core::Error::Other("verification prompt closed".into())
})?;
credential_rx
.recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
},
)
.await
@@ -287,7 +342,143 @@ pub(crate) fn spawn_login(
});
let _ = tx.send_blocking(result);
});
(rx, totp_req_rx, hv_req_rx)
LoginChannels {
result: rx,
totp: totp_req_rx,
hv: hv_req_rx,
mailbox: mailbox_req_rx,
}
}
/// Ask for the mailbox password of a two-password account.
///
/// `done` gets the password and the "keep unlocked" choice, or `None` when the
/// dialog was cancelled. `retry` says the last answer was wrong. The choice
/// defaults to off: storing the mailbox unlock in the keyring trades away what
/// a separate mailbox password is for, so it is something to opt into.
pub(crate) fn ask_mailbox(
ui: &Rc<Ui>,
retry: bool,
done: impl FnOnce(Option<(String, bool)>) + 'static,
) {
let body = if retry {
gettext("That mailbox password didn't unlock your account. Try again.")
} else {
gettext("This account has a separate mailbox password. Enter it to unlock your files.")
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Mailbox Password"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::PasswordEntryRow::builder()
.title(gettext("Mailbox password"))
.activates_default(true)
.build();
let remember = adw::SwitchRow::builder()
.title(gettext("Keep unlocked on this computer"))
.subtitle(gettext(
"Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.",
))
.build();
group.add(&entry);
group.add(&remember);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("unlock", &gettext("Unlock"));
dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("unlock"));
dialog.set_close_response("cancel");
let done = RefCell::new(Some(done));
dialog.connect_response(None, move |_, resp| {
let Some(done) = done.borrow_mut().take() else {
return;
};
let password = entry.text().to_string();
if resp == "unlock" && !password.is_empty() {
done(Some((password, remember.is_active())));
} else {
done(None);
}
});
let parent = ui_window(ui).map(|w| w.upcast::<gtk4::Window>());
dialog.present(parent.as_ref());
}
/// If the signed-in account is locked, ask for its mailbox password — once per
/// run; after a cancel, a toast keeps an Unlock button within reach.
///
/// The key state is a keyring and kernel-keyring read, so it runs on a worker.
pub(crate) fn offer_unlock(ui: &Rc<Ui>) {
if ui.unlock_offered.get() {
return;
}
let ui = ui.clone();
glib::spawn_future_local(async move {
let state = gio::spawn_blocking(|| auth::key_state().ok()).await;
if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) {
prompt_unlock(&ui, false);
}
});
}
enum UnlockOutcome {
Unlocked,
Wrong,
Failed(String),
}
/// Ask for the mailbox password and unlock the account with it.
fn prompt_unlock(ui: &Rc<Ui>, retry: bool) {
let ui_done = ui.clone();
ask_mailbox(ui, retry, move |answer| {
let Some((password, remember)) = answer else {
toast_action(
&ui_done,
&gettext("Proton Drive is locked. Enter your mailbox password to unlock it."),
&gettext("Unlock"),
|ui| prompt_unlock(ui, false),
);
return;
};
glib::spawn_future_local(async move {
// Verifying talks to Proton and the restart talks to systemd, so
// both run on a worker, never on the main loop.
let outcome = gio::spawn_blocking(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return UnlockOutcome::Failed(e.to_string()),
};
match rt.block_on(auth::unlock(&password, remember)) {
Ok(()) => {
service::restart();
UnlockOutcome::Unlocked
}
Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong,
Err(e) => UnlockOutcome::Failed(login_error_message(&e)),
}
})
.await;
match outcome {
Ok(UnlockOutcome::Unlocked) => {
toast(&ui_done, &gettext("Unlocked."));
refresh(&ui_done);
}
Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true),
Ok(UnlockOutcome::Failed(message)) => {
toast_error(&ui_done, &gettext("Couldn't unlock"), &message)
}
Err(_) => {}
}
});
});
}
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
@@ -330,6 +521,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
dialog.present(parent.as_ref());
}
/// Let the user solve the challenge in their own browser, for when the
/// verification window could not come up. The page cannot hand its token back
/// to us, so the user says when they are done, and the retry carries the
/// challenge token, which the solved page made good (see
/// [`auth::browser_verified`]).
fn verify_in_browser(
ui: &Rc<Ui>,
hv: &HumanVerification,
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
) {
let dialog = adw::AlertDialog::builder()
.heading(gettext("Verify in Your Browser"))
.body(gettext(
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
))
.build();
let url = auth::browser_verification_url(hv);
let open = gtk4::Button::builder()
.label(gettext("Open Verification Page"))
.halign(gtk4::Align::Center)
.build();
open.add_css_class("pill");
dialog.set_extra_child(Some(&open));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("continue", &gettext("Continue"));
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
// Continuing before the page was even opened would only fail the retry.
dialog.set_response_enabled("continue", false);
dialog.set_default_response(Some("continue"));
dialog.set_close_response("cancel");
let dialog_weak = dialog.downgrade();
open.connect_clicked(move |_| {
open_uri(&url);
if let Some(dialog) = dialog_weak.upgrade() {
dialog.set_response_enabled("continue", true);
}
});
let credential = auth::browser_verified(hv);
let credential_tx = RefCell::new(Some(credential_tx));
dialog.connect_response(None, move |_, resp| {
// On cancel/close we take + drop `tx` without sending, so the worker's
// recv errors out and the login is reported as cancelled.
if let Some(tx) = credential_tx.borrow_mut().take()
&& resp == "continue"
{
let _ = tx.send(credential.clone());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Sign out, after a confirmation: disable+stop the mount service (so the
/// daemon isn't left running without credentials), forget the stored session,
/// and drop back to the login page.
@@ -374,6 +622,12 @@ mod tests {
assert!(login_error_message(&hv).contains("verification wasn't completed"));
}
#[test]
fn a_cancelled_mailbox_prompt_is_reported_as_such() {
let error = pdfs_core::Error::Other("mailbox entry cancelled".into());
assert!(login_error_message(&error).contains("no mailbox password"));
}
#[test]
fn an_unmapped_error_keeps_its_text() {
let error = pdfs_core::Error::Other("something odd".into());
+2 -9
View File
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` folders below the top level.
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
dialog.connect_response(Some("stop"), move |_, _| {
let done = done.clone();
let stopped = uids.clone();
run_requests(
&ui_c,
uids.iter()
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
.collect(),
gettext_noop("Couldn't stop sharing"),
move |ui| {
forget_shared_by_me(ui, &stopped);
reload_listing(ui);
toast(ui, &done);
},
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
dialog.present(ui_window(ui).as_ref());
}
/// Take items whose sharing just stopped off the page now, rather than when
/// the reload that confirms it comes back: that reload asks after every share
/// in turn and takes a while with many of them.
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
let items = {
let mut items = ui.shared_by_me.items.borrow_mut();
items.retain(|item| !uids.contains(&item.uid));
items.clone()
};
repaint_shared_by_me(ui, &items);
}
/// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
+70 -14
View File
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
// The app's one menu, on the account row the way Proton's own apps keep it:
// app-wide entries first, the account's last. The whole row opens it, and
// F10 too, as the primary menu.
let app_section = gio::Menu::new();
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
app_section.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
let account_section = gio::Menu::new();
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
let account_menu = gio::Menu::new();
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
let sign_out = gio::Menu::new();
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
account_menu.append_section(None, &sign_out);
let account_button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text(gettext("Account"))
account_menu.append_section(None, &app_section);
account_menu.append_section(None, &account_section);
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.append(&avatar);
row.append(&account_name);
row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu)
.valign(gtk4::Align::Center)
.direction(gtk4::ArrowType::Up)
.primary(true)
.build();
account_button.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("flat");
account.add_css_class("sidebar-account");
account.append(&avatar);
account.append(&account_name);
account.append(&account_button);
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer");
@@ -825,6 +836,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
}
/// Show the sign-in page while a session is still stored, because Proton has
/// refused it. The sidebar stays, so the user can go back to cached files; a
/// login moves on to the files as from any sign-in ([`refresh`]).
pub(crate) fn show_login(ui: &Rc<Ui>) {
ui.stack.set_visible_child_name("login");
}
/// Repaint the window from the cached login identity, then ask the daemon for
/// its status and transfers. Runs at startup, after a login and after a service
/// restart; the daemon's event feed keeps both current in between. The identity
@@ -860,6 +878,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
}
}
offer_unlock(ui);
refresh_status(ui);
refresh_transfers(ui);
}
@@ -1209,6 +1228,8 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
failing_error,
paused,
paused_until,
session_expired,
tokens_unsaved,
..
} = status
else {
@@ -1219,7 +1240,11 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
// in it: it is why a file that looks saved is not on the remote
// yet, and offline is usually the reason it is still queued.
let queued = i18n::pending_summary(pending_uploads, pending_changes);
let state = if paused {
let state = if session_expired {
SyncState::SessionExpired { queued }
} else if tokens_unsaved {
SyncState::TokensUnsaved
} else if paused {
SyncState::Paused {
until: paused_until,
queued,
@@ -1282,6 +1307,14 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
/// What the sidebar's status strip reports, most urgent first.
pub(crate) enum SyncState {
/// Proton refused the session: nothing reaches it until the user signs in
/// again. Outranks a pause, which it makes moot.
SessionExpired {
queued: Option<String>,
},
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
/// a reboot before the keyring takes them signs the user out.
TokensUnsaved,
/// The user paused syncing; nothing goes up until it resumes. Outranks
/// everything else, because it is the reason for everything else.
Paused {
@@ -1316,7 +1349,29 @@ pub(crate) enum SyncState {
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
let paused = matches!(state, SyncState::Paused { .. });
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
let expired = matches!(state, SyncState::SessionExpired { .. });
let (icon, class, title, detail) = match state {
SyncState::SessionExpired { queued } => (
"dialog-password-symbolic",
Some("error"),
gettext("Session expired"),
Some(match queued {
// Translators: {queued} says what is waiting, such as "3 uploads
// queued".
Some(queued) => {
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
}
None => gettext("Sign in again to sync"),
}),
),
SyncState::TokensUnsaved => (
"dialog-warning-symbolic",
Some("warning"),
gettext("Sign-in not saved"),
Some(gettext(
"Unlock your keyring, or the next reboot signs you out",
)),
),
SyncState::Paused { until, queued } => (
"media-playback-pause-symbolic",
Some("warning"),
@@ -1387,6 +1442,7 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
if let Some(class) = class {
image.add_css_class(class);
}
ui.locations.card.sign_in.set_visible(expired);
ui.status.status_title.set_label(&title);
ui.status.status_detail.set_visible(detail.is_some());
ui.status
+168 -40
View File
@@ -1,4 +1,4 @@
//! The human-verification (CAPTCHA) dialog.
//! The human-verification (CAPTCHA) window.
//!
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
@@ -8,10 +8,26 @@
//! The page is hosted in a `WebKitWebView` rather than handed to the system
//! browser because it reports completion by posting a message to its host, not
//! by redirecting: a browser has nowhere to post it back to.
//!
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
//! on hardened systems that keep bubblewrap from the journal socket
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
//! The URL goes to the child on stdin, so it never shows in a process list, and
//! the token comes back on stdout.
use crate::*;
use std::io::{BufRead, BufReader, Write as _};
use std::process::{ExitStatus, Stdio};
use webkit6::prelude::*;
/// The argument that makes `pdfs-app` the verification window instead of the app.
pub(crate) const VERIFY_ARG: &str = "--human-verification";
/// Marks the token among whatever else lands on the child's stdout: GLib and
/// WebKit write debug output there.
const TOKEN_PREFIX: &str = "hv-token ";
/// Bridge the verification page's `postMessage` to a handler this side can read.
///
/// The page targets its embedding host (`window.parent`), which in a webview is
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
});
"#;
/// Present the challenge and hand the solved token back through `token_tx`.
///
/// Closing the dialog without solving drops the sender, which the waiting login
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
pub(crate) fn prompt_human_verification(
ui: &Rc<Ui>,
url: &str,
token_tx: std::sync::mpsc::Sender<String>,
) {
/// How the verification window ended.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Verification {
Solved(String),
/// Closed without solving.
Cancelled,
/// Never came up, or died: most likely WebKit could not start its sandbox.
Failed,
}
/// Show the challenge in the verification window and wait until it closes.
pub(crate) async fn verify_human(url: String) -> Verification {
match gio::spawn_blocking(move || run_verification_child(&url)).await {
Ok(Ok(verification)) => verification,
Ok(Err(e)) => {
tracing::error!("cannot run the verification window: {e}");
Verification::Failed
}
Err(_) => Verification::Failed,
}
}
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
let mut child = Command::new(own_exe())
.arg(VERIFY_ARG)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
// A failed write means the child is already gone; its exit status says
// how. Dropping `stdin` closes it.
let _ = writeln!(stdin, "{url}");
}
// Stops reading at the token rather than at EOF: WebKit's helper processes
// inherit the pipe and may hold it open a moment after the child quits.
let token = child
.stdout
.take()
.and_then(|out| find_token(BufReader::new(out)));
let status = child.wait()?;
Ok(outcome(token, status))
}
/// This very binary, so a development build opens its own window. After an
/// upgrade replaced the file, `current_exe` names a deleted path, and the
/// installed `pdfs-app` is the next best.
fn own_exe() -> PathBuf {
std::env::current_exe()
.ok()
.filter(|path| path.is_file())
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
}
fn find_token(out: impl BufRead) -> Option<String> {
out.lines()
.map_while(Result::ok)
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
}
/// A token wins over the exit status: the user solved the page, even if
/// WebKit's teardown crashed after it.
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
match token {
Some(token) => Verification::Solved(token),
None if status.success() => Verification::Cancelled,
None => {
tracing::error!(%status, "the verification window failed");
Verification::Failed
}
}
}
/// Run as the verification window: read the page's URL from stdin, show it, and
/// print the token once the user has solved it. Closing the window exits
/// without a token, which the app reads as a cancelled sign-in.
pub(crate) fn run_verification_window() -> glib::ExitCode {
let mut url = String::new();
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
return glib::ExitCode::FAILURE;
}
let url = url.trim().to_owned();
// Not unique: the app that started it already holds APP_ID.
let app = adw::Application::builder()
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
app.connect_activate(move |app| build_verification_window(app, &url));
// GApplication would refuse VERIFY_ARG as an unknown option.
let argv0 = std::env::args().next().unwrap_or_default();
app.run_with_args(&[argv0])
}
fn build_verification_window(app: &adw::Application, url: &str) {
let content = webkit6::UserContentManager::new();
content.add_script(&webkit6::UserScript::new(
BRIDGE,
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
.hexpand(true)
.build();
let dialog = adw::Dialog::builder()
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&adw::HeaderBar::new());
body.append(&webview);
let window = adw::ApplicationWindow::builder()
.application(app)
.title(gettext("Verification"))
.content_width(420)
.content_height(560)
.icon_name(APP_ID)
.default_width(420)
.default_height(560)
.content(&body)
.build();
let header = adw::HeaderBar::new();
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
body.append(&header);
body.append(&webview);
dialog.set_child(Some(&body));
// Taken on the first success *or* on close, so the token is sent at most
// once and a cancelled dialog always drops the sender.
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
let tx = token_tx.clone();
let dlg_weak = dialog.downgrade();
let app = app.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| {
let Some(token) = extract_token(&value.to_str()) else {
// Every `postMessage` on the page reaches here, most of them the
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
// is simply not ours.
return;
};
if let Some(tx) = tx.borrow_mut().take() {
let _ = tx.send(token);
let mut stdout = std::io::stdout().lock();
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
tracing::error!("cannot hand the verification token back: {e}");
}
if let Some(dlg) = dlg_weak.upgrade() {
dlg.close();
if let Some(app) = app.upgrade() {
app.quit();
}
});
// Covers the close button, Escape, and the user giving up.
dialog.connect_closed(move |_| {
token_tx.borrow_mut().take();
});
webview.load_uri(url);
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
// Returns immediately; the login worker stays blocked on the channel until
// the handler above sends, or the dialog closes and drops the sender.
dialog.present(parent.as_ref());
window.present();
}
/// Pull the verification token out of a message posted by the verification page.
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::extract_token;
use super::{Verification, extract_token, find_token, outcome};
use std::os::unix::process::ExitStatusExt as _;
use std::process::ExitStatus;
/// The wait status of a process killed by SIGABRT.
const ABORTED: i32 = 6;
#[test]
fn a_completion_message_yields_its_token() {
@@ -163,4 +256,39 @@ mod tests {
assert!(extract_token(raw).is_none(), "accepted: {raw}");
}
}
#[test]
fn the_token_is_found_among_other_output() {
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
assert_eq!(find_token("only chatter\n".as_bytes()), None);
}
#[test]
fn a_window_closed_without_a_token_cancels() {
assert_eq!(
outcome(None, ExitStatus::from_raw(0)),
Verification::Cancelled
);
}
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
/// failure the app explains, not as the user giving up.
#[test]
fn a_window_that_died_fails() {
assert_eq!(
outcome(None, ExitStatus::from_raw(ABORTED)),
Verification::Failed
);
let exited_1 = ExitStatus::from_raw(1 << 8);
assert_eq!(outcome(None, exited_1), Verification::Failed);
}
#[test]
fn a_token_counts_even_if_the_window_died_after_it() {
assert_eq!(
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
Verification::Solved("tok-1".into())
);
}
}
+21
View File
@@ -0,0 +1,21 @@
use crate::*;
/// The "›" between two breadcrumb segments.
pub(crate) fn crumb_separator() -> gtk4::Label {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
sep
}
/// The last breadcrumb segment, the folder on screen: a heading rather than a
/// button. It carries a flat button's side padding (`.crumb-current`), so the
/// "›" before it sits as far from it as from the button before.
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
l.add_css_class("crumb-current");
l.upcast()
}
+6 -1
View File
@@ -132,8 +132,13 @@ impl FileList {
// and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that
// happen to have spaces.
// happen to have spaces. Pango marks such a break with a hyphen
// that is not in the name ("SteamSetup.e-xe"); a file name is
// not prose, so break it bare, as Nautilus does.
let no_hyphens = gtk4::pango::AttrList::new();
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
let label = gtk4::Label::builder()
.attributes(&no_hyphens)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center)
.max_width_chars(13)
+23 -9
View File
@@ -128,20 +128,34 @@ impl ActionMenu {
}
}
/// Where a menu opened over `anchor` should live in the widget tree: the
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
/// selectors reach through a popover to its parent, and themes style the
/// insides of list items (`gridview > child box { margin: 12px }` in
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
/// margin around every section. Under the view itself it is no item's child.
/// Where a menu opened over `anchor` should live in the widget tree.
///
/// Not inside a list item: CSS descendant selectors reach through a popover to
/// its parent, and themes style the insides of list items (`gridview > child
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// rules up and puts a wide margin around every section. So the search starts
/// at the outermost grid or list view around `anchor`.
///
/// Not on a widget without a layout manager either: a popover is sized and
/// placed again when its parent's layout manager allocates it. The list views
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
/// first given, often too small, and opened scrolling. The host is the first
/// widget from there up that has one.
fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut host = anchor.clone();
let mut start = anchor.clone();
let mut widget = anchor.parent();
while let Some(w) = widget {
widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
host = w;
start = w;
}
}
host
let mut host = Some(start.clone());
while let Some(w) = host {
if w.layout_manager().is_some() {
return w;
}
host = w.parent();
}
start
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod compat;
pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
+71 -51
View File
@@ -127,6 +127,26 @@ impl Drop for Busy {
}
}
/// A section's action, such as Send Invitations. It goes under the section's
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
/// list reads as a setting, and a button alone in one looks misplaced.
fn group_button(label: &str) -> gtk4::Button {
let button = gtk4::Button::builder()
.label(label)
.halign(gtk4::Align::End)
.margin_top(12)
.build();
button.add_css_class("suggested-action");
button
}
/// A section's only row while the share is being read.
fn loading_row() -> adw::ActionRow {
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
row.add_prefix(&spinner());
row
}
/// How an open Share dialog addresses its node.
///
/// A node the browser is showing has a mountpoint-relative path. A node reached
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
role_wrap.add_suffix(&role_drop);
// The role goes with the addresses it applies to, at the end of their row.
role_drop.set_tooltip_text(Some(&gettext("Role")));
email_row.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)"))
.build();
let invite_btn = gtk4::Button::builder()
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
let invite_btn = group_button(&gettext("Send Invitations"));
invite_btn.set_sensitive(false);
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
.build();
invite_group.add(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row);
invite_group.add(&invite_wrap);
invite_group.add(&invite_btn);
let people = adw::PreferencesGroup::builder()
.title(gettext("People with access"))
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.title(gettext("Public link"))
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&invite_group);
content.append(&people);
content.append(&link_group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
// Until the share is read, each section says so rather than standing
// empty under its title.
let people_loading = loading_row();
people.add(&people_loading);
let link_loading = loading_row();
link_group.add(&link_loading);
let content = adw::PreferencesPage::new();
content.add(&invite_group);
content.add(&people);
content.add(&link_group);
toolbar.set_content(Some(&content));
let dialog = adw::Dialog::builder()
.title(pgettext("verb", "Share"))
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
target,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
link_rows: RefCell::new(Vec::new()),
people_rows: RefCell::new(vec![people_loading.upcast()]),
link_rows: RefCell::new(vec![link_loading.upcast()]),
});
// Check the addresses as they are typed: a malformed one marks the row as
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
share_dialog_unread(&state);
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
}
_ => toast_error(
&state.ui,
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
),
_ => {
share_dialog_unread(&state);
toast_error(
&state.ui,
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
)
}
}
});
}
/// The share couldn't be read: what the sections showed (rows still loading,
/// or ones that may now be stale) gives way to one line that says so.
fn share_dialog_unread(state: &Rc<ShareDialog>) {
for row in state.people_rows.borrow_mut().drain(..) {
state.people.remove(&row);
}
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let row = dim_row(&gettext("Couldn't load sharing"));
state.people.add(&row);
state.people_rows.borrow_mut().push(row.upcast());
}
/// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) {
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
return;
}
for entry in entries {
// A member is the plain case and needs no word; an invitation says
// it is still waiting on the other side.
let subtitle = match entry.kind {
ShareEntryKind::Member => gettext("Member"),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
ShareEntryKind::Member => String::new(),
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
// External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown.
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder()
.label(gettext("Create Public Link"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
create.add_css_class("suggested-action");
let create_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&create)
.build();
let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link.
let btn = create.clone();
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&expiry_row);
state.link_group.add(&create_wrap);
state.link_group.add(&create);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast());
rows.push(create.upcast());
}
}
*state.link_rows.borrow_mut() = rows;
+139 -23
View File
@@ -45,6 +45,8 @@ enum Phase {
Offline,
/// Changes keep failing; the user has to look.
Attention,
/// Mounted, but Proton refused the session: only signing in again helps.
SessionExpired,
/// Signed in, but no mount daemon is running.
Disconnected,
SignedOut,
@@ -60,6 +62,7 @@ impl Phase {
Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic",
Phase::SessionExpired => "dialog-password-symbolic",
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
}
}
@@ -88,8 +91,9 @@ struct DriveState {
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
/// running job speaks for itself — a scan or an index rebuild is still work
/// going on, and the menu saying nothing there reads as "finished". The icon is
/// [`busy`]'s call, not this line's.
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
@@ -143,12 +147,12 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line.
let sync = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
// Same daemon is up, so a cheap follow-up request says what moves.
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => (Vec::new(), Vec::new()),
};
status_state(&status, sync, default_mountpoint)
status_state(&status, &items, &jobs, default_mountpoint)
}
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint),
@@ -166,6 +170,15 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
// No daemon: describe login state instead so the menu is still useful.
Err(_) => {
let (line, phase) = match auth::load() {
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
// Translators: {username} is the Proton account name. The
// account has a mailbox password that has to be entered.
gettext_f(
"Signed in as {username} — locked",
&[("username", &s.username)],
),
Phase::Attention,
),
Ok(s) => (
// Translators: {username} is the Proton account name.
gettext_f(
@@ -208,9 +221,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
}
}
/// The state a running daemon's [`Response::Status`] describes, with `sync`
/// saying what it is moving right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
/// The state a running daemon's [`Response::Status`] describes, with `items`
/// and `jobs` saying what it is working on right now.
fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status {
mountpoint,
pinned,
@@ -219,6 +237,8 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
pending_changes,
failing_ops,
paused,
session_expired,
tokens_unsaved,
..
} = status
else {
@@ -226,14 +246,25 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
};
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let phase = phase_of(
paused,
failing_ops,
online,
queued.is_some() || !sync.is_empty(),
);
let phase = if *session_expired {
Phase::SessionExpired
} else if *tokens_unsaved {
Phase::Attention
} else {
phase_of(
paused,
failing_ops,
online,
queued.is_some() || busy(items, jobs),
)
};
DriveState {
line: match (online, queued) {
// Outranks everything: it is why nothing else moves, and the one
// thing that fixes it is the user's to do.
_ if *session_expired => gettext("Session expired — sign in again"),
// 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 paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f(
"{n} change needs attention",
@@ -269,11 +300,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync,
sync: sync_line(items, jobs),
failing: failing_ops,
}
}
/// Whether the daemon is changing something: bytes moving, or a job the user
/// is waiting on. A mirror folder's routine scan and the like are background
/// jobs; they still get the menu line, but the icon spinning every two minutes
/// for a scan that finds nothing reads as constant syncing.
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
!items.is_empty() || jobs.iter().any(|j| !j.background)
}
/// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -342,7 +381,7 @@ impl Tray for DriveTray {
fn status(&self) -> Status {
match self.state.phase {
Phase::Attention => Status::NeedsAttention,
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
_ => Status::Active,
}
}
@@ -400,6 +439,9 @@ impl Tray for DriveTray {
// Nothing to mount without a session; the app's sign-in page is the
// way in.
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
open_manager(&["--page", "login"])
})),
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
}
@@ -476,6 +518,10 @@ fn main() -> Result<()> {
return Ok(());
}
// The socket lives in the state dir, which nothing has made yet when the app
// starts the tray on a fresh install (B194).
dirs.ensure().context("create the app's directories")?;
// Ensure only one instance of the tray runs.
let tray_sock = dirs.tray_socket();
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
@@ -534,11 +580,7 @@ fn main() -> Result<()> {
heard.take(feed);
}
let st = match &heard.status {
Some(status) => status_state(
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
// Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint),
};
@@ -583,6 +625,80 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
}
fn status(extra: &str) -> Response {
serde_json::from_str(&format!(
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true{extra}}}}}"#
))
.unwrap()
}
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
/// good, and nothing said that signing in again was the way out.
#[test]
fn a_refused_session_outranks_everything_else() {
let state = status_state(
&status(r#","session_expired":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::SessionExpired);
assert_eq!(state.line, "Session expired — sign in again");
}
/// B61: the keyring refusing the refreshed tokens was only in the log.
#[test]
fn a_sign_in_the_keyring_refused_needs_attention() {
let state = status_state(
&status(r#","tokens_unsaved":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
}
#[test]
fn a_daemon_that_does_not_say_has_a_live_session() {
let state = status_state(&status(""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Paused);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
detail: String::new(),
done: 0,
total: 0,
background,
}
}
#[test]
fn a_routine_scan_is_not_busy_but_a_sync_is() {
assert!(!busy(&[], &[]));
assert!(!busy(&[], &[job("Checking Documents", true)]));
assert!(busy(
&[],
&[
job("Checking Documents", true),
job("Syncing Photos", false)
]
));
assert!(busy(&[], &[job("Uploading files", false)]));
}
#[test]
fn a_job_from_an_older_daemon_counts_as_busy() {
let job: JobItem = serde_json::from_str(
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
)
.unwrap();
assert!(!job.background);
}
#[test]
fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View");
+1
View File
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
"checkbox-symbolic",
"computer-symbolic",
"dialog-error-symbolic",
"dialog-password-symbolic",
"dialog-warning-symbolic",
"document-edit-symbolic",
"document-open-recent-symbolic",
+22 -7
View File
@@ -310,8 +310,10 @@ its changes go through the queue like My files.
A filesystem watcher (`notify`) and a remote poll every 120 s feed a debounced pass (2 s, at most
30 s). Passes are serialized per daemon. A pass:
1. **Walks the local tree.** Any `readdir`, metadata or permission failure marks the scan
incomplete, and an incomplete scan cannot delete anything.
1. **Walks the local tree.** A path the user may not read, an entry that vanishes mid-scan and a
name that is not UTF-8 are left out, and the pass drops everything under them, as it drops
ignored paths, so a left-out path is never read as deleted. An unreadable root and any other
I/O error fail the pass.
2. **Walks the remote tree.**
3. **Loads the baseline**, the `sync_entry` rows: both sides as of the last successful pass.
4. **Classifies each path** by comparing local, remote and baseline, with `(mtime, size)` as the
@@ -377,6 +379,12 @@ Restore lists the device's folders, proposes a local path from the profile, and
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).
`Core::migrate_device` moves another device's top-level folders under this machine's device root.
Device roots are separate shares on the main volume, so each folder is one server-side move that
keeps its uid. A name already taken here gets the other device's name as a suffix. A synced folder
that already tracked one of them keeps its `remote_uid` and only has its `remote_share_id`
re-pointed. The moved folders are then offered by the ordinary restore.
---
## 8. Control protocol
@@ -542,7 +550,7 @@ sequenceDiagram
actor User
participant UI as pdfs-app
participant Auth as pdfs-core auth
participant Web as WebKitGTK dialog
participant Web as pdfs-app --human-verification
participant API as Proton API
User->>UI: email and password
@@ -550,9 +558,9 @@ sequenceDiagram
Auth->>API: SRP handshake
API-->>Auth: 9001, challenge URL
Auth-->>UI: HumanVerificationRequired
UI->>Web: load challenge
UI->>Web: challenge URL (stdin)
User->>Web: solve it
Web-->>UI: token (postMessage)
Web-->>UI: token (stdout)
UI->>Auth: login_verified(token)
Auth->>API: new SRP handshake with the token
API-->>Auth: session
@@ -561,8 +569,15 @@ sequenceDiagram
- The challenge used up the first SRP handshake, so the retry starts a new one with the token
attached.
- The page may post the token as an object or as a JSON string; both are accepted.
- The CLI has no web view. It fails with a message to sign in once with `pdfs-app`; the stored
session then works for the CLI and the service.
- The page runs in a WebKitGTK view inside a child `pdfs-app`. WebKit aborts its process when its
sandbox cannot start, as on hardened systems such as secureblue (B195). In the child, that ends
the web view, not the app.
- When the child fails, the app offers the page in the user's own browser instead, as Proton Mail
Bridge does. A browser tab cannot post the token back, so the user says when they are done, and
the retry carries the challenge token itself, which the solved page made good. Its type header
lists every offered method (`auth::browser_verified`).
- The CLI has no web view, so it always takes the browser path: it prints the page's URL and waits
for Enter.
---
+367 -3
View File
@@ -47,7 +47,7 @@ original wording.
it in the index in the same change.
```markdown
### B194 — What the user sees, in one line
### B196 — What the user sees, in one line
**Status:** Open
**Found:** 2026-10-06, how it was found
@@ -73,7 +73,6 @@ original wording.
| [B67](#b67--fresh-state-restore-can-silently-select-a-new-device-high-22) | Fresh-state restore can silently select a new device (HIGH-22) | An ambiguous device after a loss of local state is not asked about |
| [B66](#b66--local-only-pending-data-is-not-protected-at-shutdown-high-21) | Local-only pending data is not protected at shutdown (HIGH-21) | No shutdown protection and no `pdfs sync flush` |
| [B65](#b65--release-artifacts-lack-supply-chain-verification-med-11) | Release artifacts lack supply-chain verification (MED-11) | No SBOM, provenance or checksums |
| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) | Keyring write failures are only logged |
| [B15](#b15--an-empty-but-listed-folder-and-duplicated-folder-uids) | An empty-but-`listed` folder, and duplicated folder uids | Cause not attributed |
| [B10](#b10--glib-critical-when-the-launcher-closes-over-an-in-flight-open) | GLib critical when the launcher closes over an in-flight open | |
@@ -81,6 +80,7 @@ original wording.
| Entry | Title | What is left |
|---|---|---|
| [B199](#b199--a-stopped-share-stays-on-the-shared-by-me-page-for-minutes) | A stopped share stays on the Shared by me page for minutes | The share itself is not deleted; the SDK has no call for it |
| [B88](#b88--pdfs-pin-rename-move-delete-and-sharing-still-cannot-name-a-path-under-a-secondary-mount) | `pdfs pin`, `rename`, `move`, `delete` and sharing still cannot name a path under a secondary mount | `pin`, `unpin`, opening a file, thumbnails, uploads, sharing and versions |
| [B72](#b72--pdfs-trash-never-returns) | `pdfs trash` never returns | The slowness of the first refresh is unmeasured |
| [B71](#b71--conflict-sweep-is-not-production-ready-auto-trash-loop-crit-13) | Conflict sweep is not production-ready (auto-trash loop, CRIT-13) | Items 5 to 10 of the entry |
@@ -100,6 +100,15 @@ a live account since.
| Entry | Title |
|---|---|
| [B204](#b204--a-write-over-a-partial-edit-whose-base-was-never-read-fails-with-eio) | A write over a partial edit whose base was never read fails with `EIO` |
| [B203](#b203--pdfs-unlock-leaves-a-running-daemon-a-spent-refresh-token) | `pdfs unlock` leaves a running daemon a spent refresh token |
| [B202](#b202--a-refused-session-shows-as-offline-forever-and-nothing-says-to-sign-in-again) | A refused session shows as offline forever, and nothing says to sign in again |
| [B201](#b201--after-signing-in-again-the-old-daemon-puts-its-spent-tokens-back) | After signing in again, the old daemon puts its spent tokens back |
| [B200](#b200--offline-with-changes-queued-the-daemon-keeps-several-cores-busy) | Offline with changes queued, the daemon keeps several cores busy |
| [B198](#b198--switching-to-online-only-deletes-local-files-that-never-reached-drive) | Switching to online-only deletes local files that never reached Drive |
| [B197](#b197--switching-to-online-only-trashes-synced-files-that-became-ignored) | Switching to online-only trashes synced files that became ignored |
| [B196](#b196--one-unreadable-folder-stops-the-whole-synced-folder-from-syncing) | One unreadable folder stops the whole synced folder from syncing |
| [B195](#b195--the-app-crashes-when-proton-asks-for-a-captcha-on-secureblue) | The app crashes when Proton asks for a CAPTCHA on secureblue |
| [B193](#b193--deleting-a-file-whose-create-failed-can-trash-a-file-of-the-users-on-drive) | Deleting a file whose create failed can trash a file of the user's on Drive |
| [B191](#b191--a-file-deleted-after-its-create-failed-and-was-written-again-can-stay-on-drive) | A file deleted after its create failed and was written again can stay on Drive |
| [B190](#b190--a-file-saved-just-before-the-daemon-stops-can-keep-its-old-content-on-drive) | A file saved just before the daemon stops can keep its old content on Drive |
@@ -184,6 +193,7 @@ a live account since.
| [B81](#b81--prompt-labels-every-drive-hit-my-files-including-device-folders) | Prompt labels every Drive hit "My files", including device folders |
| [B80](#b80--29-of-the-account-is-absent-from-the-search-index) | 29% of the account is absent from the search index |
| [B62](#b62--debian-artifact-omits-required-service-and-autostart-units-high-19) | Debian artifact omits required service and autostart units (HIGH-19) |
| [B61](#b61--rotated-single-use-credentials-can-be-lost-high-18) | Rotated single-use credentials can be lost (HIGH-18) |
| [B60](#b60--config-writes-are-non-atomic-and-parse-errors-are-overwritten-high-17) | Config writes are non-atomic and parse errors are overwritten (HIGH-17) |
| [B59](#b59--private-state-permission-enforcement-fails-open-high-16) | Private state permission enforcement fails open (HIGH-16) |
| [B58](#b58--control-socket-has-unbounded-frames-and-connections-high-15) | Control socket has unbounded frames and connections (HIGH-15) |
@@ -214,6 +224,7 @@ a live account since.
| Entry | Title |
|---|---|
| [B194](#b194--on-a-fresh-install-the-tray-exits-and-the-takeout-page-cannot-remember-its-archives) | On a fresh install the tray exits and the Takeout page cannot remember its archives |
| [B192](#b192--a-new-file-closed-empty-reaches-drive-only-half-a-minute-later) | A new file closed empty reaches Drive only half a minute later |
| [B181](#b181--the-app-shows-a-queued-edit-kept-as-a-conflict-copy-in-english) | The app shows a queued edit kept as a conflict copy in English |
| [B180](#b180--a-stop-waits-10-s-for-a-drain-worker-that-slept-through-it) | A stop waits 10 s for a drain worker that slept through it |
@@ -296,6 +307,335 @@ a live account since.
Newest first.
### B204 — A write over a partial edit whose base was never read fails with `EIO`
**Status:** Fixed, not verified live
**Found:** 2026-10-09, by CI on 2a86830 (`one_client_on_a_good_link`, seed 1, which did not replay
locally). A partial write to `x/f.txt`, a file renamed twice before, was renamed over `z/d`. A
create over `z/d` a moment later failed with `EIO`, and the daemon logged "refusing write over
incomplete queued revision".
**Where:** `ProtonFs::serve_open` in `crates/pdfs-fuse/src/filesystem.rs`
**Repro:** Write at an offset into a file this client never read, then open it for writing again
before the drain uploads the first write.
**Cause.** The release of a partial write fills its gaps from the content cache only. When the
cache does not hold the base, the queued revision stays incomplete until the drain fills it from
Drive, and a write open over an incomplete revision was refused. B123, B157, B169 and B170 each
closed one way the cache could miss the base. Any other miss, such as a file never read here,
still failed the next write open for the length of the drain debounce.
**Fix.** A write open over an incomplete queued revision copies the blob and fills its gaps from
the remote base with `Core::fill_gaps`, as the drain does before an upload. The open fails only
when that fill fails, for example offline.
**Test:** `a_partial_write_over_bytes_never_read_takes_another_write_at_once` in `sim/daemon.rs`.
Without the fix the second write fails with `EIO`.
---
### B203 — `pdfs unlock` leaves a running daemon a spent refresh token
**Status:** Fixed, not verified live
**Found:** 2026-10-09, reading the code for issue #35 after the B201 fix
**Where:** `crates/pdfs-core/src/auth.rs`, `stored_session_is_newer`; `crates/pdfs-fuse/src/link.rs`,
`Core::mark_session_expired`; `crates/pdfs-fuse/src/mount.rs`; `crates/pdfs-cli/src/main.rs`,
`cmd_daemon`
**Repro:** With the daemon running, run `pdfs unlock` while the stored access token has expired.
Unlock resumes the same session, refreshes it and stores the new tokens. The daemon still holds
the old refresh token. If it refreshes before unlock restarts it, Proton refuses the spent token,
and since B202 the mount reports the session expired, although the keyring holds a live one.
**Cause.** Two processes ran the same session, and only one of them could hold its live refresh
token. B201 stopped the daemon from writing its spent token over the live one, but nothing made the
daemon pick up the live one.
**Fix.** Before a refused session counts as expired, `auth::stored_session_is_newer` compares the
keyring with what this process last stored. When the keyring holds a newer rotation of the same
session, or another login, the mount ends with `MountOutcome::ResumeStored`, and the daemon mounts
again at once from the keyring. At startup the same check turns the refusal into a mount error,
which the daemon retries. A rotation that another process already wrote into the blob is now
taken as this session's, so the daemon keeps writing its later rotations.
**Test:** `auth::tests::a_session_rotated_by_another_process_is_newer_in_the_keyring` and
`auth::tests::a_rotation_already_in_the_blob_is_taken`.
---
### B202 — A refused session shows as offline forever, and nothing says to sign in again
**Status:** Fixed, not verified live
**Found:** 2026-10-09, issue #35: the tray and `pdfs diagnose` said "offline, 3 queued", and
diagnose also said "keyring session: ok", while every call in the log failed with
`InvalidRefreshToken (http 400)`. `pdfs sync now` and restarting the service changed nothing.
**Where:** `crates/pdfs-fuse/src/link.rs`, `Core::lost_link` and `Core::probe_until_online`;
`crates/pdfs-fuse/src/mount.rs`, `fetch_or_recall_root`; `crates/pdfs-fuse/src/background.rs`;
`crates/pdfs-core/src/control.rs`, `Response::Status`; the tray, the app's status, `pdfs status`
and `pdfs diagnose`
**Repro:** Make the stored refresh token invalid (B201 is one way), then start the daemon. It
mounts from the cache and says offline; the probe retries every 30 s, then every 5 minutes, and
logs the refusal only at debug. A daemon that started online and loses its session later does not
even go offline: its failures show up as EIO and drain backoff.
**Cause.** Every failure to fetch the root at startup counted as offline, and nothing told a
refused session apart from a lost connection. No status field could carry it, so no front end
could say it.
**Fix.** `pdfs_core::error::session_revoked` names the errors only a new login fixes:
`InvalidRefreshToken`, `AccountDeleted`, `AccountDisabled`. When the root fetch, the probe, the
drain, an event poll, the event seed or the photos root sees one, the mount goes offline with
`session_expired` set, logs one warning naming `pdfs login`, and stops probing and retrying.
Cached files stay readable and changes stay queued for the daemon the next login starts.
`Response::Status` carries `session_expired`. The tray shows "Session expired — sign in again"
with **Sign In Again…**, which opens the app's sign-in page; the app's status and Sync card say the
same, with a **Sign In Again** button. `pdfs status` and `pdfs diagnose` report it, the latter as
a FAIL. A refused session is not recorded as a sync issue of the op that ran into it.
**Test:** `error::tests::only_a_refused_refresh_or_a_closed_account_revokes_the_session`, the
tray's `tests::a_refused_session_outranks_everything_else` and
`tests::a_daemon_that_does_not_say_has_a_live_session`.
---
### B201 — After signing in again, the old daemon puts its spent tokens back
**Status:** Fixed, not verified live
**Found:** 2026-10-09, reading the code for issue #35, where every call failed with
`InvalidRefreshToken` and signing in again did not obviously help.
**Where:** `crates/pdfs-core/src/auth.rs`, `persist`, `register_refresh_handler`, `unlock` and
`migrate_legacy`; `crates/pdfs-cli/src/main.rs`, `cmd_daemon`
**Repro:** With the daemon running, run `pdfs login`. It stores the new session and restarts the
unit. The old daemon's stop runs `auth::persist`, which wrote its own access and refresh tokens
into the blob, now the new session's. The new daemon starts with a refresh token of another
session, and the first refresh fails with `InvalidRefreshToken`. `pdfs unlock` had the same
shape: its session could rotate the tokens, and the restarted daemon's stop put the spent refresh
token back. `unlock` and `migrate_legacy` could do it themselves, writing a copy of the blob read
before verifying the mailbox password, which may have rotated the tokens.
**Cause.** Proton refresh tokens are single-use. `store_tokens` loaded whatever blob was stored
and replaced its tokens, without checking that the blob was still this session's.
**Fix.** Each session writes its rotations through a `TokenSink`. It writes only into a blob with
its session id that still holds the refresh token the session last left there; a blob that moved
on, to a new login or to another session's rotation, is left alone. The stop no longer writes the
session's tokens, only a rotation the keyring refused when it happened. `unlock` and
`migrate_legacy` read the blob again after verifying. A failed final write is an error in the log,
not a debug line claiming there was nothing to save.
**Test:** `auth::tests::tokens_of_another_session_are_not_written`,
`auth::tests::tokens_are_not_written_over_a_newer_rotation_of_the_same_session`,
`auth::tests::a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login`.
---
### B200 — Offline with changes queued, the daemon keeps several cores busy
**Status:** Fixed, not verified live
**Found:** 2026-10-09, issue #35: "offline, 3 queued", and systemd counted 17 minutes of CPU in
2 m 16 s of wall time, 10 hours over a longer run.
**Where:** `crates/pdfs-fuse/src/drain.rs`, `Core::run_pending_drain` and `idle_wait`;
`crates/pdfs-core/src/db/ops.rs`, `Db::earliest_due_at`
**Repro:** Queue a write, then cut the link (`nmcli networking off`). `pidstat -t -p $(pgrep -x
pdfs) 5` shows the drain threads using most of several cores for as long as the outage lasts.
**Cause.** Offline, a drain worker claims nothing, and then sleeps until the earliest queued op
falls due. Every queued op was due already, and with no attempts nothing moves its
`next_attempt_at`, so the sleep was zero. All 16 workers ran a query on every pass, the primary
one its idle chores as well. The same spin happened online when a due op sat behind a claimed op
on the same node, such as a rename queued behind a long upload: `earliest_due_at` counted it,
`claim_next_due_op` skipped it.
**Fix.** A worker that claims nothing, offline or paused, sleeps the full idle poll; a reconnect
or a resume wakes it. `earliest_due_at` now uses the claim's own exclusions, one SQL fragment for
both.
**Test:** `drain::tests::an_offline_worker_waits_the_idle_poll_for_a_past_due_op`,
`db::tests::earliest_due_at_skips_an_op_held_behind_a_claimed_one`.
---
### B199 — A stopped share stays on the Shared by me page for minutes
**Status:** Partly fixed. The share itself is still not deleted.
**Found:** 2026-10-09, reported from the app: after **Stop Sharing**, the row stayed, and an
older share showed with no people and no link.
**Where:** `crates/pdfs-fuse/src/sharing.rs`, `Core::stop_sharing_for_uid` and
`Core::list_shared_by_me`; `crates/pdfs-gui/src/app/pages/shared_by_me.rs`, `prompt_stop_sharing`
**Repro:** Share a folder with a person, then choose **Stop Sharing…** on the Shared by me page.
The row stays, now with "—" for people and link, until a reload some minutes later.
**Cause.** The Rust SDK has no call that deletes a share. Stop Sharing removes the public link, the
invitations and the members one by one, and leaves an empty share behind. Drive deletes an empty
share on its own, but only after a while, and until then `GET v2/volumes/{vid}/shares` still lists
it. The JS SDK ends `unshareNode` with `DELETE drive/shares/{shareId}?Force=1` for exactly this
reason.
**Fix.** The daemon leaves a share with no member, no invitation and no link out of the Shared by
me listing, unless one of those lookups failed. The app also drops a stopped item from the page as
soon as the request succeeds, before the confirming reload. Left: a `delete_share` call in
`proton-drive-rs`, made at the end of `stop_sharing_for_uid`, so the share is really gone.
**Test:** none automated; the listing needs a live account.
---
### B198 — Switching to online-only deletes local files that never reached Drive
**Status:** Fixed (unverified).
**Found:** 2026-10-08, reading the switch while fixing B196.
**Where:** `crates/pdfs-fuse/src/devices.rs`, `Core::apply_sync_folder_mode`;
`crates/pdfs-fuse/src/sync.rs`, `mirror_tree_unsynced`
**Repro:** Mirror a project folder with a `.git/` in it, then switch it to **Online only**.
`.git/` is gone from the disk and was never on Drive.
**Cause.** The switch evicts the whole local tree once the folder is `idle`. `idle` only says the
last pass applied everything it tried, and a pass never tries an ignored path (`.git/` is ignored
by default), a symlink, or a folder it cannot read (B196). It also defers a file that is open for
writing without leaving `idle`, and a file can change after the pass ends. The eviction deleted
all of those.
**Fix.** Before the mode is written and anything is evicted, the switch walks the folder against
the baseline, as a move out of a mirror folder already does, and refuses when something is only on
this disk. The message names the path; an ignored one is called excluded from sync. A tracked file
deleted here but not yet on Drive also refuses. An ignored one does not, since its baseline row
stays for good and would hold the switch forever.
**Test:** unit tests `a_fully_synced_mirror_folder_may_go_online_only`,
`an_ignored_folder_only_this_disk_has_holds_the_switch_back`,
`a_deleted_ignored_path_does_not_hold_the_switch_back`,
`an_edit_after_the_last_pass_holds_the_switch_back`, `a_new_file_at_the_root_holds_the_switch_back`,
`an_unreadable_folder_holds_the_switch_back` and `a_symlink_holds_the_switch_back`. The switch
itself has not been driven against a live account.
---
### B197 — Switching to online-only trashes synced files that became ignored
**Status:** Fixed (unverified).
**Found:** 2026-10-08, reading the push pass while fixing B196.
**Where:** `crates/pdfs-fuse/src/sync.rs`, `Core::push_pass`; `crates/pdfs-fuse/src/sync/planner.rs`,
`local_deletions`
**Repro:** Mirror a folder until `build/` is on Drive, add `build/` to `.pdfsignore`, then switch
the folder to **Online only**. `build/` goes to the Drive trash, and the switch then deletes the
local copy.
**Cause.** The push pass that runs before the switch read its deletions as "in the baseline, not in
the local walk". The walk skips ignored paths, so every synced path that later became ignored read
as deleted. A full pass does not do this: it drops ignored paths from classification, which is
what the user guide promises ("Ignoring never deletes anything").
**Fix.** The push pass reads its deletions from the same filtered baseline the wipe guard checks:
without ignored paths, and without paths the scan could not read (B196).
**Test:** unit tests `a_push_pass_never_reads_an_ignored_path_as_deleted` and
`local_deletions_come_out_shallowest_first`.
---
### B196 — One unreadable folder stops the whole synced folder from syncing
**Status:** Fixed (unverified). The reporter has not run the fix yet.
**Found:** 2026-10-08, [#33](https://github.com/narrrl/proton-drive-linux/issues/33). A synced
`~/Documents` held a restic cache a Docker container had created, which the user may not read.
Every pass failed with `Couldn't sync Documents: read …/restic/cache/…/snapshots: Permission
denied`, and nothing else in `Documents` synced.
**Where:** `crates/pdfs-fuse/src/sync.rs`, `walk_local_tree`; `crates/pdfs-fuse/src/sync/planner.rs`,
`Unscanned`
**Repro:** In a mirror folder, `sudo mkdir locked && sudo chmod 700 locked`, then sync.
**Cause.** B55 made every scan failure fatal to the pass, because a path left out of the scan read
as a local deletion and was trashed on Drive. That was safe, but a folder the user may not read is
not transient: the pass failed on every poll, for good. The same held for a file deleted between
the listing and its `stat`, which a busy folder hits now and then, and for a name that is not UTF-8.
**Fix.** The scan leaves such a path out and records it, and the pass drops everything under it
from classification, the wipe guard and the push pass's deletions, as it already did for ignored
paths. A left-out path is then neither deleted on Drive nor downloaded into. A folder that lists
but does not allow `stat` is left out whole. An unreadable or absent root, and every other I/O
error, still fails the pass. The activity feed names the unreadable paths once each time the set
changes, and the folder stays `idle`. Adding the folder to `.pdfsignore` was already a workaround
and still silences the report.
**Test:** unit tests `an_unreadable_subdirectory_is_left_out_and_the_rest_scans`,
`a_listable_but_unsearchable_folder_is_left_out_whole`,
`a_path_the_scan_left_out_is_never_a_local_deletion`,
`unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings` and
`a_non_utf8_name_is_left_out_and_reported`. `an_unreadable_root_fails_the_scan` and
`an_absent_root_fails_the_scan` still hold.
---
### B195 — The app crashes when Proton asks for a CAPTCHA on secureblue
**Status:** Fixed (unverified). On 2026-10-08 the reporter confirmed that the app survives, but
the CAPTCHA still could not be solved; the browser fallback below has not run on secureblue yet.
**Found:** 2026-10-07, [#29](https://github.com/narrrl/proton-drive-linux/issues/29). On
secureblue 44 (a hardened Fedora Atomic), `pdfs-app` aborted a few seconds after the sign-in, with
`bwrap: Can't get type of source /run/systemd/journal/socket: Permission denied` and
`Failed to fully launch dbus-proxy`.
**Where:** `crates/pdfs-gui/src/app/pages/verify.rs`, `crates/pdfs-gui/src/app/pages/login.rs`,
`crates/pdfs-core/src/auth.rs`, `crates/pdfs-cli/src/main.rs`
**Repro:** Sign in on secureblue from a network Proton asks a CAPTCHA for, such as a VPN.
**Cause.** The CAPTCHA page runs in a WebKitGTK view (B8). Before it loads anything, WebKit starts
its sandbox: bubblewrap, with the journal socket bound in, and `xdg-dbus-proxy`. secureblue keeps
bubblewrap from the socket, the proxy does not start, and WebKit ends its process with `g_error`.
WebKit binds the socket with `--ro-bind-try`, but bubblewrap skips only a missing path, not one it
may not read, and WebKit has no switch to leave the socket out.
The view was in the app's own process, so the whole app went down, and nothing in our code can
catch that.
**Fix.** The view runs in a child, `pdfs-app --human-verification`, that reads the URL on stdin
and prints the token on stdout. A child that exits without a token after a crash or a non-zero
status no longer ends the sign-in: the app offers the page in the user's own browser, as Proton
Mail Bridge does. The tab cannot post the token back, so the user says when they are done, and the
retry carries the challenge token, typed with every offered method. `pdfs login` takes the same
path. The sandbox is not switched off by the app: users choose a hardened system for that
isolation. TROUBLESHOOTING.md keeps the one-time workaround as a last resort.
**Test:** unit tests `a_window_that_died_fails`, `a_window_closed_without_a_token_cancels`,
`a_token_counts_even_if_the_window_died_after_it`, `the_token_is_found_among_other_output`,
`the_browser_gets_the_full_verification_page` and
`a_browser_verification_retries_with_the_challenge_token`. The browser retry has not run against a
live challenge: Proton only asks for one from a network it distrusts.
On 2026-10-08 the child ran under real WebKit on Arch against a local page that posts a completion,
and handed back its token.
---
### B194 — On a fresh install the tray exits and the Takeout page cannot remember its archives
**Status:** Fixed.
**Found:** 2026-10-07, in the log of [#29](https://github.com/narrrl/proton-drive-linux/issues/29):
`failed to bind tray single-instance socket: No such file or directory` and
`cannot remember staged Takeout archives: No such file or directory`.
**Where:** `crates/pdfs-gui/src/main.rs`, `build_window` in `crates/pdfs-gui/src/app/main.rs`
**Repro:** Start `pdfs-app` with no `~/.local/state/proton-drive-linux`, as on a fresh install. The
tray is gone, and staging a Takeout archive logs the warning.
**Cause.** Both write to the state directory, and only the daemon and the CLI made it
(`AppDirs::ensure`). The app starts the tray before the first sign-in, so before the daemon has
ever run.
**Fix.** `pdfs-tray` and `pdfs-app` call `AppDirs::ensure` at start, which also makes the
directories owner-only (B6).
**Verified:** 2026-10-08, `pdfs-tray` with `XDG_STATE_HOME`, `XDG_CONFIG_HOME` and
`XDG_CACHE_HOME` pointing at an empty directory. It exited with the error before the fix, and
afterwards ran with `tray.sock` in a `0700` state directory.
---
### B193 — Deleting a file whose create failed can trash a file of the user's on Drive
**Status:** Fixed (unverified).
@@ -4639,7 +4979,7 @@ daemon/tray/FUSE, upgrade, rollback, and uninstall without deleting user state.
### B61 — Rotated single-use credentials can be lost (HIGH-18)
**Status:** Open — 1.0 authentication/recovery blocker
**Status:** Fixed, not verified live
**Found:** 2026-07-22, 1.0 authentication audit
**Where:** `crates/pdfs-core/src/auth.rs`, refresh callback
@@ -4651,6 +4991,26 @@ keyring retains an invalid single-use refresh token, so restart loses login.
notify the user, and define a safe re-login/shutdown path. Test locked/full/
unavailable keyrings, callback races, repeated rotations, death, and restart.
**Fix (2026-10-09, issue #35).** A rotation the keyring refuses is kept as unsaved and retried
in the background after 1 s, 5 s, 30 s and then every 5 minutes, until it is written or the blob
has moved on (B201). The refusal is an error in the log naming `pdfs login`, and the daemon's stop
writes a rotation still unsaved, logging an error if that fails too. The re-login path is B201's:
an old session never writes into a newer login.
Until it is written, the rotation also waits in the kernel keyring (`kernelkey::UNSAVED_TOKENS`),
so a crash or SIGKILL before a retry lands no longer loses it. The next resume, by the daemon or
`pdfs unlock`, puts it back in when the blob still holds the refresh token it spent, and writes
it. One that belongs to an older login is dropped. The kernel keyring lasts until the user's last
session ends, so a refused write followed by a crash and a logout still loses the rotation.
While a rotation waits, `Response::Status` carries `tokens_unsaved`. The tray says "Sign-in not
saved — unlock your keyring" and needs attention, the app's status says the same, `pdfs status`
prints a `Keyring` line and `pdfs diagnose` warns.
**Test:** `auth::tests::a_rotation_the_keyring_refused_is_written_once_it_takes_it` and
`auth::tests::an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for`, and the tray's
`tests::a_sign_in_the_keyring_refused_needs_attention`.
---
### B60 — Config writes are non-atomic and parse errors are overwritten (HIGH-17)
@@ -4766,6 +5126,10 @@ subdirectory holding a file, asserting the error names the subtree),
strictness must not cost a working pass, so a healthy tree still reports every entry and still
skips symlinks by design.
B196 later relaxed this: a path the user may not read, an entry that vanishes mid-scan and a name
that is not UTF-8 are left out of the pass instead of failing it, and the pass drops everything
under them from classification, so the B55 guarantee holds without the wedge.
Not covered: transient I/O errors, and an entry that disappears between `read_dir` and `stat`.
Both are races that would need a filesystem shim to force; the code path they take is the same
`symlink_metadata` failure the permission test drives.
+131
View File
@@ -11,6 +11,129 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased]
### Added
- **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
[Install → NixOS](INSTALL.md#nixos-flake).
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
### Fixed
- **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
over the file, failed until the first one reached Drive. It now fetches the rest of the file
and goes ahead.
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open 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.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
## [3.2.0] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
read, such as a cache a container created, failed every pass, so nothing else in the synced
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
longer fail the pass either.
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
deleted with the local copy. The switch now refuses and names what is only on this computer.
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
the user guide says.
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
the page at once, and an emptied share no longer shows with no people and no link.
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
folder also no longer rescans all the others.
- **Right-click menus sometimes opened too short and had to be scrolled.**
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
row of their own. The role is chosen beside the addresses, people show with avatars, and both
sections show that they are loading.
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
have.**
### Changed
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
it.
### Added
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
account, without those folders.
## [3.1.2] — 2026-10-08
Schema: **42**, unchanged.
### Fixed
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
cannot start the sandbox for the verification page and ends its process, which took the app
down with it. The page now opens in a separate window process: if that process fails, the
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
directory themselves instead of waiting for the service to do it. This also lets the Takeout
page remember staged archives before the first mount.
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
## [3.1.1] — 2026-10-07
Schema: **42**, unchanged.
### Changed
- **The client identifies itself with its release.** Sign-in and every API call used to send the
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
version, every release would fail to sign in at once and no update could fix it. The client now
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
kept.
- The About dialog shows the release version, and its debug info shows the identity that is
actually sent.
## [3.1.0] — 2026-10-07
Schema: **42**, unchanged.
### Added
- **Two-password accounts.** An account with a separate mailbox password now signs in: the app
and `pdfs login` ask for it after the login password and check it against the account, so a
typo is a retry and not a daemon that never mounts. The mailbox password is never stored.
What it unlocks is kept in kernel memory for the login session, so it is asked for once per
session and is gone after a reboot or when you log out of the machine.
- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked
account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login`
and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in
the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes
it. Off by default: it gives up the separation a second password is for.
- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting
for its mailbox password, and the app asks for it when it opens.
### Changed
- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases
instead of the mailbox password, and a blob written by 3.0.x is converted on the first start.
A stored password the account rejects (a two-password account that was signed in with only
its login password) leaves the account locked instead of failing every start.
- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user
`ptrace` are off for it.
- **The offline cache is re-keyed** from the new secret, so the first start after the update
reads it as cold and fetches the tree again once.
### Fixed
- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a
mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after
two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog
while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26))
- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token
rotation now updates only the tokens in the stored blob.
## [3.0.0] — 2026-10-05
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
@@ -1599,6 +1722,14 @@ 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
by a new POSIX compliance suite for the filesystem.
[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.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
+1
View File
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
| `pdfs devices rename UID NAME` | Rename a computer. |
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
| `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). |
## Trash
+5 -1
View File
@@ -89,7 +89,11 @@ fix it.
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
Leave them out unless a support case asks for a different value. The fixed
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
here are treated as absent and removed on the next save.
### `ignore_patterns`
+33
View File
@@ -82,6 +82,39 @@ paru -S proton-drive-for-linux # or yay, or any other AUR helper
The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
### NixOS (flake)
The repository is a flake. Add it as an input and enable the module, which installs the package,
links the user service into every graphical session and turns on the `fusermount3` wrapper and
GNOME Keyring for you:
```nix
{
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux";
outputs = { nixpkgs, proton-drive-linux, ... }: {
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
modules = [
proton-drive-linux.nixosModules.default
{ services.proton-drive-linux.enable = true; }
];
};
};
}
```
To try it without changing your system, run the app straight from the flake:
```bash
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
```
The mount itself needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default
with the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg and
with it video thumbnails, override the package with `withFfmpeg = false`. The recipe lives in
[`packaging/nix/`](../packaging/nix/).
### Other distributions
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
+23
View File
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
The folders stay part of the other computer's backup and sync in both directions. This machine
does not take over that computer's backup.
### Moving another computer's backup here
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
computer's row, or:
```bash
pdfs devices list # find the old computer's UID
pdfs devices migrate UID
```
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
twice. A folder whose name this computer already uses gets the old computer's name added, for
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
The old computer stays in your account, without those folders. Unlike
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
> [!WARNING]
> Stop Proton Drive on the old computer first. A client still running there sees its folders
> disappear, and may upload them again as new.
## 3. How the restore works
This section is for the curious and for anyone debugging a restore.
+18 -2
View File
@@ -69,8 +69,24 @@ pdfs status
## Signing in
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
the stored session then works for the CLI too.
unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
complete it, then press Enter. The app shows the page in its own window.
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
page in your browser, then choose **Continue**. If that sign-in is refused too, check that `bwrap`
and `xdg-dbus-proxy` are installed. As a last resort, sign in once with the sandbox switched off
for this one run:
```bash
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
```
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
so use this only for the sign-in. The stored session keeps working without it. Signing in from
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
+9 -1
View File
@@ -96,7 +96,9 @@ app and in the web app.
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
of the folder, so the files stay visible but take no disk space until opened. Switch with the
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies.
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
Drive, and refuses while the folder holds something that never goes there: an ignored path such
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
### Moving between synced folders and My files
@@ -146,6 +148,9 @@ places, and both apply:
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
simply no longer tracked, and its copy on Proton Drive stays.
A folder you may not read, such as a cache a container created as root, is skipped the same way,
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
## Changes waiting for Proton Drive
A change you made in the Proton Drive folder exists only on this computer until it reaches
@@ -278,6 +283,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
**Restore to This Computer…** downloads its folders here.
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
example when you switch from Windows to Linux, and then offers them for syncing here. The other
computer stays in the list, without those folders.
## Search launcher
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1791456040,
"narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+41
View File
@@ -0,0 +1,41 @@
{
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
packages = forAllSystems (pkgs: rec {
proton-drive-linux = pkgs.callPackage ./packaging/nix/package.nix { };
default = proton-drive-linux;
});
overlays.default = final: _: {
proton-drive-linux = final.callPackage ./packaging/nix/package.nix { };
};
nixosModules.default = import ./packaging/nix/module.nix self;
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux ];
packages = with pkgs; [
cargo
clippy
rustfmt
rust-analyzer
];
};
});
formatter = forAllSystems (pkgs: pkgs.nixfmt);
};
}
+1 -1
View File
@@ -9,7 +9,7 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=3.0.0
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-bin
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
pkgver = 3.0.0
pkgver = 3.2.0
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -21,21 +21,21 @@ pkgbase = proton-drive-for-linux-bin
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.0.0
provides = proton-drive-linux=3.0.0
provides = proton-drive-for-linux=3.2.0
provides = proton-drive-linux=3.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
conflicts = proton-drive-for-linux-git
options = !strip
options = !debug
source = proton-drive-linux-3.0.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.0.0/proton-drive-linux-3.0.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.0.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.0.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.0.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/packaging/proton-drive.service
source = LICENSE-3.0.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.0.0/LICENSE
sha256sums = 4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed
source = proton-drive-linux-3.2.0-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v3.2.0/proton-drive-linux-3.2.0-x86_64.tar.gz
source = io.narl.proton-drive-linux-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.desktop
source = io.narl.proton-drive-linux-tray-3.2.0.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux-tray.desktop
source = io.narl.proton-drive-linux-3.2.0.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/io.narl.proton-drive-linux.svg
source = proton-drive-3.2.0.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/packaging/proton-drive.service
source = LICENSE-3.2.0::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v3.2.0/LICENSE
sha256sums = 3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-bin
pkgver=3.0.0
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
arch=('x86_64')
@@ -34,7 +34,7 @@ source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$_repo/releases/download/$_ta
"io.narl.proton-drive-linux-$pkgver.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
"proton-drive-$pkgver.service::$_raw/packaging/proton-drive.service"
"LICENSE-$pkgver::$_raw/LICENSE")
sha256sums=('4d72420f287e0a9b1d65dfc2954779b2df1bf367cb41ffe28519b2791451b6ed'
sha256sums=('3d498c9ea366c94e77e6f3c81b804b8b3497faecfa74480274d6ec9b5121665b'
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux-git
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
pkgver = 3.0.0.r0.g2177383
pkgver = 3.2.0.r0.g4c498d5
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -24,8 +24,8 @@ pkgbase = proton-drive-for-linux-git
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-for-linux=3.0.0.r0.g2177383
provides = proton-drive-linux=3.0.0.r0.g2177383
provides = proton-drive-for-linux=3.2.0.r0.g4c498d5
provides = proton-drive-linux=3.2.0.r0.g4c498d5
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux-git
pkgver=3.0.0.r0.g2177383
pkgver=3.2.0.r0.g4c498d5
pkgrel=1
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)"
arch=('x86_64')
@@ -1,6 +1,6 @@
pkgbase = proton-drive-for-linux
pkgdesc = Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray
pkgver = 3.0.0
pkgver = 3.2.0
pkgrel = 1
url = https://proton-drive.narl.io
arch = x86_64
@@ -23,13 +23,13 @@ pkgbase = proton-drive-for-linux
optdepends = xdg-utils: "Open folder" action in the tray menu
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
provides = proton-drive-linux=3.0.0
provides = proton-drive-linux=3.2.0
provides = pdfs
conflicts = proton-drive-linux
conflicts = proton-drive-for-linux-bin
conflicts = proton-drive-for-linux-git
options = !lto
source = proton-drive-for-linux-3.0.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.0.0.tar.gz
sha256sums = 3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5
source = proton-drive-for-linux-3.2.0.tar.gz::https://github.com/narrrl/proton-drive-linux/archive/refs/tags/v3.2.0.tar.gz
sha256sums = a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8
pkgname = proton-drive-for-linux
@@ -1,7 +1,7 @@
# Maintainer: Nils Pukropp <contact@narl.io>
pkgname=proton-drive-for-linux
pkgver=3.0.0
pkgver=3.2.0
pkgrel=1
pkgdesc="Unofficial Proton Drive client: files-on-demand FUSE mount, CLI, GTK4 app and tray"
arch=('x86_64')
@@ -24,7 +24,7 @@ conflicts=('proton-drive-linux' 'proton-drive-for-linux-bin' 'proton-drive-for-l
# LTO has broken GTK/Rust links for this workspace; the release profile keeps it off.
options=('!lto')
source=("$pkgname-$pkgver.tar.gz::$_repo/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=('3a9ca82adae6b03e2b33af520ec49f702a3b4c83151ccc70ae3c21645b719ab5')
sha256sums=('a15ddb37808dc01bde3a3d092b03b5047cdecca37efd14d906c9bf353d242bd8')
_srcname="proton-drive-linux-$pkgver"
+42
View File
@@ -0,0 +1,42 @@
# NixOS module: installs the package and runs the mount daemon in every
# graphical session, as the other packages' user service does.
#
# services.proton-drive-linux.enable = true;
self:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.proton-drive-linux;
in
{
options.services.proton-drive-linux = {
enable = lib.mkEnableOption "the Proton Drive for Linux mount daemon, app and tray";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux;
defaultText = lib.literalExpression "proton-drive-linux.packages.\${system}.proton-drive-linux";
description = "The package to use.";
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
# The unit ships in the package; this links it and pulls it into the
# graphical session, which is what `systemctl --user enable` would do.
systemd.packages = [ cfg.package ];
systemd.user.services.proton-drive = {
wantedBy = [ "graphical-session.target" ];
# The mount runs fusermount3 from PATH, and it has to be the setuid
# wrapper; the PATH NixOS gives a unit does not include it.
path = [ "/run/wrappers" ];
};
programs.fuse.enable = lib.mkDefault true;
# Session tokens are kept in the Secret Service.
services.gnome.gnome-keyring.enable = lib.mkDefault true;
};
}
+127
View File
@@ -0,0 +1,127 @@
# Nix package for the workspace, built from the checkout this file lives in.
# The flake at the repository root exposes it; `nix build` builds it.
{
lib,
rustPlatform,
adwaita-icon-theme,
pkg-config,
gettext,
glib,
wrapGAppsHook4,
fuse3,
glib-networking,
gtk4,
libadwaita,
webkitgtk_6_0,
libsecret,
dbus,
exiftool,
ffmpeg-headless,
withFfmpeg ? true,
}:
let
cargoToml = lib.importTOML ../../Cargo.toml;
in
rustPlatform.buildRustPackage {
pname = "proton-drive-linux";
inherit (cargoToml.workspace.package) version;
# Only what the build reads, so editing docs or CI does not rebuild.
src = lib.fileset.toSource {
root = ../..;
fileset = lib.fileset.unions [
../../Cargo.toml
../../Cargo.lock
../../crates
../../po
../../LICENSE
../proton-drive.service
../io.narl.proton-drive-linux.desktop
../io.narl.proton-drive-linux-tray.desktop
../io.narl.proton-drive-linux.svg
];
};
cargoLock.lockFile = ../../Cargo.lock;
cargoBuildFlags = [
"--bin"
"pdfs"
"--bin"
"pdfs-tray"
"--bin"
"pdfs-app"
"--bin"
"pdfs-prompt"
];
nativeBuildInputs = [
pkg-config
gettext # msgfmt, for po/build.sh
glib # glib-compile-resources, for the GUI's build.rs
wrapGAppsHook4
];
buildInputs = [
fuse3
# TLS for WebKitGTK's GIO: without it the CAPTCHA page fails with "TLS
# support is not available". wrapGAppsHook4 adds it to GIO_EXTRA_MODULES.
glib-networking
# The stock icons the app asks for, such as the Photos and Computers
# sidebar entries. GTK ships only a few itself, and a session without
# Adwaita installed showed the rest as missing. wrapGAppsHook4 adds it to
# XDG_DATA_DIRS.
adwaita-icon-theme
gtk4
libadwaita
webkitgtk_6_0
libsecret
dbus
];
# The app looks for its catalogs here instead of /usr/share/locale.
env.PDFS_LOCALEDIR = "${placeholder "out"}/share/locale";
# The suite mounts FUSE, talks to D-Bus and the kernel keyring, and checks
# permissions a build sandbox does not have; CI runs it on every push.
doCheck = false;
postInstall = ''
install -Dm644 packaging/io.narl.proton-drive-linux.desktop \
$out/share/applications/io.narl.proton-drive-linux.desktop
install -Dm644 packaging/io.narl.proton-drive-linux-tray.desktop \
$out/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
install -Dm644 packaging/io.narl.proton-drive-linux.svg \
$out/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
substituteInPlace $out/share/applications/io.narl.proton-drive-linux.desktop \
--replace-fail "Exec=pdfs-app" "Exec=$out/bin/pdfs-app"
substituteInPlace $out/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop \
--replace-fail "Exec=pdfs-tray" "Exec=$out/bin/pdfs-tray"
install -Dm644 packaging/proton-drive.service \
$out/lib/systemd/user/proton-drive.service
substituteInPlace $out/lib/systemd/user/proton-drive.service \
--replace-fail "ExecStart=/usr/bin/pdfs" "ExecStart=$out/bin/pdfs"
patchShebangs po/build.sh
po/build.sh $out/share/locale
'';
# Thumbnails shell out to exiftool, and to ffmpeg for video. Suffixed, so a
# user's own builds on PATH win. fusermount3 comes from the system: on NixOS
# it is the setuid wrapper in /run/wrappers/bin.
preFixup = ''
gappsWrapperArgs+=(--suffix PATH : ${
lib.makeBinPath ([ exiftool ] ++ lib.optional withFfmpeg ffmpeg-headless)
})
'';
meta = {
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
homepage = "https://proton-drive.narl.io";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
mainProgram = "pdfs";
};
}
+802 -621
View File
File diff suppressed because it is too large. Load diff
+794 -621
View File
File diff suppressed because it is too large. Load diff
+801 -621
View File
File diff suppressed because it is too large. Load diff
+804 -621
View File
File diff suppressed because it is too large. Load diff
+804 -621
View File
File diff suppressed because it is too large. Load diff
+800 -621
View File
File diff suppressed because it is too large. Load diff
+765 -626
View File
File diff suppressed because it is too large. Load diff
+802 -621
View File
File diff suppressed because it is too large. Load diff
+800 -621
View File
File diff suppressed because it is too large. Load diff