Compare commits

..
72 Commits
Author SHA1 Message Date
nvrl ea56818a80 prompt refactor 2026-07-22 11:27:57 +02:00
nvrl 39ab4aae48 prompt refactor 2026-07-22 09:50:42 +02:00
nvrl 6cba77d3cb Merge pull request #15 from narrrl/fix/ioctl-handler
Fix/ioctl handler
2026-07-22 03:07:30 +02:00
nvrl 26375885ed updated workflow 2026-07-22 03:04:59 +02:00
nvrl 544d0913e8 added fuse compliance testing 2026-07-22 03:04:40 +02:00
nvrl 714b3048dc Merge pull request #14 from narrrl/fix/ioctl-handler
filesystem fixes, critical + feature implementations
2026-07-22 02:30:57 +02:00
nvrl bef39f75cf sync refactor 2026-07-22 02:22:40 +02:00
nvrl 0a1fa95eb4 refactor 2026-07-22 02:17:19 +02:00
nvrl 242a28bb8f refactor from monolithic structure 2026-07-22 02:02:26 +02:00
nvrl bd17d506f8 fixed rm before online del, mirror bug 2026-07-22 01:52:10 +02:00
nvrl 1cd6a26be3 fix(fuse,core): remediate audit bugs B23, B26-B29, B33 and update BUGS tracking
- B23: Generate synthetic StagedWrite sidecars in rescue_scratch when sidecar is corrupted
- B26: Add open_count and unlinked state fields to defer node removal and cache eviction on unlinked open files until release
- B27: Discard orphaned write revisions for local UIDs unlinked prior to upload
- B28: Add emergency_evict to ContentCache for LRU blob eviction upon ENOSPC
- B29: Implement FUSE forget handler and forget_lookup in state to prune unreferenced inodes
- B33: Quarantine failing drain ops on HTTP 40x / max retries to unblock offline sync queue
- docs: Track audit items B18-B36 in docs/BUGS.md with fix details and verification results
2026-07-22 01:18:35 +02:00
nvrl fe75998cea fix(filesystem): resolve critical data loss, POSIX non-compliance, and deadlock vulnerabilities
- Standardize POSIX unlink (EISDIR) and rmdir (ENOTDIR/ENOTEMPTY) checks
- Defer local directory eviction in mode switch until FUSE mount verification succeeds
- Enforce fsync before atomic renames in ContentCache store operations
- Add directory ancestor loop check in rename (EINVAL)
- Implement punch_hole interval tracking in fallocate
- Purge un-created local node write revisions on unlink
- Fix control socket permission race with octal umask 0o77
- Add unit tests for state, DB, and FUSE POSIX logic (208/208 tests passing)
2026-07-22 00:23:55 +02:00
nvrl 9d55ffb9f6 fixed io lock with alloced files from for example aria2c 2026-07-21 23:23:20 +02:00
nvrl 2275004f79 impplemented fallocate 2026-07-21 22:39:35 +02:00
nvrl 914ff2b5ae added ioctl handler 2026-07-21 16:42:41 +02:00
nvrl 01677f2670 updated documentation on bugs/features 2026-07-21 10:35:53 +02:00
nvrl 3f9d36607a updated readme 2026-07-21 02:12:50 +02:00
nvrl f1399aa661 Merge pull request #13 from narrrl/feature/selective-sync
Feature/selective sync + diagnostics
2026-07-20 23:09:47 +02:00
nvrl a3d9d05777 added settings for this device + quota in settings 2026-07-20 23:08:23 +02:00
nvrl bf625bf8d9 added functionality to open shared files with you + fixed bug where mid transfer the SIGTERM signal can corrupt the mount 2026-07-20 22:30:16 +02:00
nvrl e0f0c7e0b7 added json output 2026-07-20 20:44:37 +02:00
nvrlandClaude Opus 4.8 a6a925667a add --json output to the query commands
Scripts and health checks had to scrape formatted text, which changes whenever
the display does.

--json is global and applies to status, ls, pins, sync list, devices list,
transfers, activity, and cache inspect. Action commands keep their human
output; a script that needs to know whether one worked has the exit code.

Two decisions worth recording:

The daemon's own reply is serialized rather than a hand-built parallel
structure, so new fields reach the JSON without anyone remembering to add them
-- but the variant tag is stripped first. Serializing CtlResponse directly
yields {"SyncFolders": {"items": [...]}}, which would make every caller write
jq '.SyncFolders.items[]' and couple it to the name of an internal enum
variant. Callers get {"items": [...]}.

A daemon-side error prints its payload and then fails the command. The first
cut exited 0 after printing the error body, which was caught by running
`pdfs --json ls NoSuchFolder`: a script would have read a failed lookup as
success. The body is still emitted so `kind` is available, but the exit code
is what `set -e` and `if pdfs ...` actually test.

status composes its object by hand instead, being the one query that merges
the local keyring with the daemon -- including the logged-in-but-no-daemon
case, which has no daemon reply to pass through at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 20:38:48 +02:00
nvrlandClaude Opus 4.8 e259016a00 checkpoint the WAL after the vacuum, not before
Verified against a live install: vacuum reported "34.3 MiB freed (170.3 MiB
-> 136.0 MiB), 0 WAL frame(s) checkpointed" while the WAL grew from 67 MiB to
143 MiB. Net disk use barely moved, and the frame count was zero because the
checkpoint ran before the VACUUM, when there was nothing to fold.

Reordering is strictly better, but the commit message should not overstate it:
the live inflation happens because the daemon's other connections hold read
transactions and a TRUNCATE checkpoint waits on readers, so under a busy
daemon this can still move little. journal_size_limit stays the backstop --
the live WAL did fall back to its 64 MiB cap on its own afterwards.

The accompanying test deliberately does not assert on WAL size or frame count.
Measured both ways, a single-connection test reports frames=0 and a 0-byte WAL
under either ordering, because SQLite folds the log itself without a
concurrent reader to block it; an assertion there would pass on the bug and be
decoration. It asserts what is reliably true instead -- that a file-backed
vacuum reclaims space and leaves the database sound -- and the ordering
rationale plus the residual uncertainty are documented on vacuum() itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 20:32:18 +02:00
nvrlandClaude Opus 4.8 471f57b8e1 add cache inspect/vacuum and a diagnose self-check
A corrupt database or an oversized cache previously left the user with no
answer but "delete the state directory and re-sync", which for a large
account means re-downloading everything to fix a problem that may have been
a few stale rows.

pdfs diagnose reports on paths, keyring, daemon, and mountpoint, and exits
non-zero if any check fails so it works in a health check. Every check is
written to run with no daemon, because that is the situation it gets run in:
a stopped daemon is reported as a finding rather than aborting the report.
Writability is tested by writing, since permission bits say nothing about a
full disk or a read-only remount.

pdfs cache inspect reports database size, reclaimable space, per-table row
counts, and cache usage; --deep adds SQLite's integrity check, opt-in because
it reads every page. The report distinguishes "checked and clean" from "not
checked" rather than letting an empty problem list imply a clean bill of
health it never gave.

pdfs cache vacuum checkpoints the WAL and compacts the database.

Deliberately no orphaned-cache-row pruning: ContentCache::reconcile already
rebuilds the index from what is on disk at every open, so a second pass here
would duplicate it against a worse view of the truth. The WAL is likewise
left to journal_size_limit, which already caps it -- a periodic checkpoint
would paper over a problem that no longer exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 19:54:42 +02:00
nvrlandClaude Opus 4.8 3ade754ea2 add .pdfsignore selective sync for mirror folders
Mirror folders walked and uploaded every path under their root, so syncing a
project directory pushed node_modules/, target/, and .git/ to Drive -- quota
spent on files nobody wants there, and enough API traffic to invite rate
limiting.

Rules come from a .pdfsignore (or .protonignore) at the folder root, unioned
with an ignore_patterns list in config.json that defaults to the usual build,
VCS, and editor-leftover globs. Both are re-read at the start of every pass,
so edits apply without restarting the daemon.

The filter deliberately sits on the classification union in
classification_order(), not on walk_local alone. do_reconcile classifies
local | remote | baseline, so a file synced before a rule started matching it
is absent from local but present in the other two -- the "local deleted,
remote untouched" arm, which calls trash_nodes. Filtering only the walk would
have meant that adding a line to .pdfsignore silently trashed files on Drive
on the next pass. Ignored baseline rows are left in place so un-ignoring
re-adopts the existing remote file instead of re-uploading it.

guard_local_wipe takes a filtered baseline for the same reason: an ignored
path is absent from the walk by rule rather than by loss, and counting those
as missing would trip the guard on every pass once a rule covered the tree,
wedging that folder's sync permanently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFzNQyb4TWrCRWcnegqWxY
2026-07-20 19:40:40 +02:00
nvrl 661ae3cf7a fixed incorrect versioning for rpm build 2026-07-20 17:06:36 +02:00
nvrl f803bf3cd7 fixed github ci 2026-07-20 15:06:17 +02:00
nvrl 084740ad95 version bump to 0.6.2 2026-07-20 14:54:13 +02:00
nvrl 65eed0cc72 Merge pull request #12 from narrrl/release/0.6.2
release/0.6.2
2026-07-20 14:53:06 +02:00
nvrl 1e0e284d5f per-chunk wakeup, B13 + B14 2026-07-20 14:52:31 +02:00
nvrl f0fbd0775c fixed long getattr load times with ls -l 2026-07-20 13:37:59 +02:00
nvrl d25a573e8f fixed mount-kind correlation 2026-07-20 00:09:00 +02:00
nvrl eafe1307b8 Merge pull request #11 from narrrl/fix/return-prompt
Fix/return prompt
2026-07-19 23:53:04 +02:00
nvrl 056ae0463f Merge pull request #10 from narrrl/fix/capture #
added webgtk recaptcha
2026-07-19 23:52:39 +02:00
nvrl b2b0fe7b99 fixed return key not opening file 2026-07-19 23:39:24 +02:00
nvrl d13042b550 Merge branch 'main' into fix/capture 2026-07-19 23:25:10 +02:00
nvrl cb67d97b0f added webgtk recaptcha 2026-07-19 23:24:18 +02:00
nvrl 0323fdc44d Merge pull request #7 from kacperpaczos/fedora-packaging
Add Fedora packaging
2026-07-19 22:26:46 +02:00
nvrl 2b2a49c679 Merge branch 'main' into fedora-packaging 2026-07-19 22:26:28 +02:00
nvrl ed129f7c71 Merge pull request #9 from narrrl/feature/fedora-packeging
Feature/fedora packeging
2026-07-19 20:50:44 +02:00
nvrl b39d09fb9d version bump to 0.6.1 2026-07-19 20:49:50 +02:00
nvrl c8f57ed5c9 added fedora packeging 2026-07-19 20:49:02 +02:00
Kacper PaczosandCursor b90b7e4b9c Add Fedora packaging and fix crates.io SDK build.
Remove the local proton-sdk path patch so the workspace builds from published crates, and document Fedora 44 build/runtime plus a local RPM spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 19:46:56 +02:00
nvrl cd437dff9c removed local sdk ref 2026-07-19 19:42:18 +02:00
nvrl 681db981f5 Merge pull request #6 from narrrl/release/0.6.0
Release/0.6.0
2026-07-19 19:33:45 +02:00
nvrl 45bd48fa5f version bump 2026-07-19 19:32:30 +02:00
nvrl 4a2643163b added recovery instructions 2026-07-19 19:30:40 +02:00
nvrl 829f0ee643 updated architecture 2026-07-19 19:30:25 +02:00
nvrl 532628456e fix(fuse): keep inode stable across rename
relocate() called forget(), dropping the node's by_uid mapping, so
the re-enumeration assigned a fresh inode. The kernel had already
carried the renamed dentry onto the old one and holds it for the
entry TTL, so lookup/getattr/opendir resolved to an inode entries
no longer held and returned ENOENT — while readdir walked children
and reported the new inode. A renamed directory listed in its
parent but could not be entered until the TTL expired.

Rewrite the node in place and invalidate both parents' listings
instead. Also stops the moved node's DB row being deleted and
recreated on every move, which the B1 data-loss fix worked around
2026-07-19 19:30:05 +02:00
nvrl f9250211b8 fixed missing trash logging 2026-07-19 17:45:47 +02:00
nvrl e8f11f7c42 added docs 2026-07-19 17:42:59 +02:00
nvrl 717521195c fixed long file names 2026-07-19 15:57:36 +02:00
nvrl 190ea3e9cb fixed some conflict bugs + ui fix 2026-07-19 15:36:04 +02:00
nvrl 1d5fdfb6b9 some performance optimizations 2026-07-19 12:48:48 +02:00
nvrl a90cf83ff5 fixed some bugs 2026-07-18 19:24:11 +02:00
nvrl 7766628098 refactor, fixed monolithic 2026-07-18 18:33:14 +02:00
nvrl 232b3b5348 db.rs refactor 2026-07-18 14:20:37 +02:00
nvrl d58dc46708 added parking_lot mutex 2026-07-18 12:24:28 +02:00
nvrl 38aca3cd48 video streaming 2026-07-18 12:08:34 +02:00
nvrl c555315998 improved ui + prompt 2026-07-17 23:34:46 +02:00
nvrl 81e7133003 perf improvements 2026-07-17 22:30:08 +02:00
nvrl a293593f4a updated sdk dependencie 2026-07-17 16:00:52 +02:00
nvrl 1f8f1936a7 fixed stale revision bug 2026-07-17 15:31:21 +02:00
nvrl 4720b2a5fb concurrent reads 2026-07-17 13:49:16 +02:00
nvrl 50c96cbbf4 implemented real offline structure, with online drain 2026-07-17 12:39:03 +02:00
nvrl dcc62ba420 file pipeping 2026-07-17 11:13:23 +02:00
nvrl fb39594453 Merge pull request #1 from narrrl/feature/sharing-endpoints
Feature/sharing + devices
2026-07-17 11:12:13 +02:00
nvrl 68e07ed426 implemented offline scaffold 2026-07-17 10:23:24 +02:00
nvrl bb9804498c added new folder icon, fixed db size (max log size), progress bars 2026-07-16 18:13:48 +02:00
nvrl a38d0371ec implemented shared files, folder uploads, caching 2026-07-16 01:18:03 +02:00
nvrl cc744d56c7 added photos thumbnail caching + bigger photos 2026-07-15 01:00:43 +02:00
nvrl ae0d374ec0 updated readme screenshots 2026-07-14 19:36:11 +02:00
95 changed files with 37469 additions and 9509 deletions

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
rust:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev
- name: Install minimum supported Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: '1.96'
components: rustfmt, clippy
- uses: swatinem/rust-cache@v2
- name: Formatting
run: cargo fmt --all -- --check
- name: Lints
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Unit tests
run: cargo test --workspace --locked
- name: Acceptance runner safety guard
run: |
if scripts/fuse-acceptance.sh /tmp >acceptance.out 2>&1; then
echo "runner unexpectedly accepted a non-FUSE path"
exit 1
fi
grep -q "refusing primary non-FUSE path" acceptance.out
# Real Proton credentials and /dev/fuse deliberately do not run on pull
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
+28 -2
View File
@@ -26,9 +26,11 @@ jobs:
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev \
desktop-file-utils
desktop-file-utils \
rpm
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
@@ -97,7 +99,7 @@ jobs:
Priority: optional
Architecture: amd64
Maintainer: Nils Pukropp <contact@narl.io>
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
Installed-Size: ${INSTALLED_SIZE}
Description: Proton Drive client for Linux
Proton Drive client for Linux featuring:
@@ -109,6 +111,28 @@ jobs:
# Build Debian package
dpkg-deb --build deb proton-drive-linux_${{ env.VERSION }}_amd64.deb
- name: Package Fedora package (.rpm)
run: |
# Sanitize version (replace hyphens with dots for RPM compatibility)
RPM_VERSION=$(echo "${{ env.VERSION }}" | tr '-' '.')
# Create RPM build directories
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
# Build RPM package
rpmbuild -bb \
--nodeps \
--define "_topdir $PWD/rpmbuild" \
--define "_sourcedir $PWD" \
--define "git_dir $PWD" \
--define "version ${RPM_VERSION}" \
--define "_dbpath $PWD/rpmbuild/db" \
packaging/proton-drive-linux.spec
# Copy built RPM to root directory
cp rpmbuild/RPMS/x86_64/*.rpm .
- name: Upload Workflow Artifacts
uses: actions/upload-artifact@v4
with:
@@ -116,6 +140,7 @@ jobs:
path: |
*.deb
*.tar.gz
*.rpm
if-no-files-found: error
- name: Create GitHub Release
@@ -125,6 +150,7 @@ jobs:
files: |
*.deb
*.tar.gz
*.rpm
draft: false
prerelease: false
env:
+1
View File
@@ -2,3 +2,4 @@
**/*.rs.bk
*.pdb
.env
/rpmbuild
Generated
+1233 -251
View File
File diff suppressed because it is too large. Load diff
+12 -10
View File
@@ -3,15 +3,15 @@ resolver = "2"
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
[workspace.package]
version = "0.4.0"
version = "0.7.1"
edition = "2024"
license = "MIT"
authors = ["Nils Pukropp <contact@narl.io>"]
rust-version = "1.96"
[workspace.dependencies]
proton-sdk = "0.1.4"
proton-drive-rs = "0.1.4"
proton-sdk = { version = "0.1" }
proton-drive-rs = { version = "0.1" }
# Internal crates.
pdfs-core = { path = "crates/pdfs-core" }
@@ -22,21 +22,23 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
# Non-poisoning mutexes. A handler that panics while holding a lock must not
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
# the recovered worker needs.
parking_lot = "0.12"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
directories = "5"
ignore = "0.4"
sha2 = "0.10"
# Thumbnail generation for photos the server has none for. Decoders only for the
# formats a camera or phone actually produces — the rest of the crate's codecs
# would be dead weight in the daemon.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
rusqlite = { version = "0.32", features = ["bundled"] }
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
clap = { version = "4", features = ["derive"] }
rpassword = "7"
fuser = "0.17"
libc = "0.2"
[patch.crates-io]
# Track the SDK's `main` on GitHub rather than a sibling checkout, so a clean
# clone (and CI, and the PKGBUILD) builds without the working tree next door.
# `cargo update -p proton-sdk -p proton-drive-rs` pulls newer pushes.
proton-sdk = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
proton-drive-rs = { git = "https://github.com/narrrl/proton-sdk-rs", branch = "main" }
+162 -9
View File
@@ -6,11 +6,122 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, and configure options through a modern, native GUI with a fully non-blocking asynchronous main loop.
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
- **Fuzzy Search Launcher Prompt (HUD)**: A Google Drive-style search launcher (`pdfs-prompt`) for finding and opening files or folders instantly, ideal for binding to a system-wide hotkey.
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
## Selective Sync (`.pdfsignore`)
Two-way synced folders skip paths matched by ignore rules, so syncing a project
directory does not upload `node_modules/`, `target/`, or `.git/`.
Rules come from two places, and both apply:
1. **Per folder** — a `.pdfsignore` file at the root of the synced folder
(`.protonignore` also works). Gitignore syntax, including negation:
```gitignore
# everything build-related
build/
*.log
# ...except this one
!important.log
```
2. **Globally** — an `ignore_patterns` list in `config.json`, applied to every
synced folder. When unset, sensible defaults apply: `.git/`, `.hg/`, `.svn/`,
`node_modules/`, `target/`, `.venv/`, `__pycache__/`, `*~`, `*.swp`, `*.tmp`,
`.DS_Store`, and `Thumbs.db`.
```json
{
"ignore_patterns": ["node_modules/", "target/", "*.iso"]
}
```
Set it to `[]` to opt out of the defaults entirely.
Rules are re-read at the start of every sync pass, so edits take effect on the
next pass without restarting the daemon.
**Ignoring is never destructive.** If a rule starts matching a file that was
already synced, its copy on Drive is left untouched — the file simply stops
being tracked. Removing the rule later picks the existing remote file back up
rather than re-uploading it.
## Diagnostics & Maintenance
When something looks wrong, `pdfs diagnose` checks the installation and prints a
report. It runs without a daemon on purpose — the state worth diagnosing is
usually the state where the daemon will not start:
```console
$ pdfs diagnose
Paths
[ok ] state dir: /home/you/.local/state/proton-drive-linux
[ok ] database: /home/you/.local/state/proton-drive-linux/cache.db (170.3 MiB)
Account
[ok ] keyring session: you@proton.me
Daemon
[ok ] daemon responding
[ok ] mounted at: /home/you/ProtonDrive
[ok ] queued writes: none
No problems found.
```
It exits non-zero if any check fails, so it works in a health-check script.
For the local metadata database and content cache:
| Command | What it does |
|---|---|
| `pdfs cache inspect` | Database size, reclaimable space, per-table row counts, cache usage against budget |
| `pdfs cache inspect --deep` | Also runs SQLite's integrity check — reads every page, so it is slow on a large database |
| `pdfs cache vacuum` | Checkpoints the write-ahead log and compacts the database |
| `pdfs cache clear` | Deletes cached file content, keeping pinned files |
`vacuum` takes a write lock for its duration and needs room for a second copy of
the database while it runs, so it is a deliberate operation rather than
something the daemon does on a timer.
Before a release, run the real-kernel acceptance suite against a dedicated test
account and mount. See [`docs/TESTING.md`](docs/TESTING.md); it includes optional
two-mount convergence checks and confines destructive operations to a fresh
test directory.
## Scripting (`--json`)
Query commands accept a global `--json` flag and emit machine-readable output:
```console
$ pdfs --json sync list | jq '.items[] | select(.state != "idle") | .local_path'
$ pdfs --json cache inspect | jq '.db_reclaimable_bytes'
$ pdfs --json status | jq -r '.mount.mountpoint'
```
Supported on `status`, `ls`, `pins`, `sync list`, `devices list`, `transfers`,
`activity`, and `cache inspect`. Commands that perform an action keep their
human output — a script that needs to know whether one worked has the exit code.
Two things worth relying on:
- **The payload is unwrapped.** Output is `{"items": […]}`, not the daemon's
internal `{"SyncFolders": {"items": […]}}`, so scripts never name an internal
variant.
- **Errors still fail.** A daemon-side error prints its JSON body (with a
machine-readable `kind`) on stdout *and* exits non-zero, so `set -e` and
`if pdfs …` behave as expected rather than treating a failed lookup as
success.
## Performance & Caching
@@ -26,13 +137,24 @@ The client includes several optimizations designed for high efficiency, a low me
### GUI Application & Launcher
| Login Screen | Files Browser |
| :---: | :---: |
| ![Login Screen](images/login.png) | ![Files Browser](images/files.png) |
| Photos Timeline | Search Launcher Prompt |
| :---: | :---: |
| ![Photos Timeline](images/photos.png) | ![Search Launcher Prompt](images/prompt.png) |
<table>
<tr>
<td align="center" width="50%"><img src="images/login.png" alt="Login Screen" width="100%"><br><sub><b>Login Screen</b></sub></td>
<td align="center" width="50%"><img src="images/files.png" alt="Files Browser" width="100%"><br><sub><b>Files Browser</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/shared.png" alt="Shared Links" width="100%"><br><sub><b>Shared Links</b></sub></td>
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
</tr>
<tr>
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
</tr>
</table>
---
@@ -48,13 +170,30 @@ sudo apt-get install -y \
libfuse3-dev \
libgtk-4-dev \
libadwaita-1-dev \
libwebkitgtk-6.0-dev \
libsecret-1-dev \
libdbus-1-dev
```
### Arch Linux
```bash
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
```
### Fedora (44+)
```bash
sudo dnf install -y \
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
```
Runtime extras (pick your desktop):
```bash
# GNOME — keyring + tray (AppIndicator)
sudo dnf install -y gnome-keyring gnome-shell-extension-appindicator xdg-utils
# KDE Plasma — KWallet (Secret Service); tray works via built-in SNI
sudo dnf install -y kwallet xdg-utils
```
---
@@ -95,6 +234,20 @@ A local `PKGBUILD` is available under the `packaging/` directory. You can build
cd packaging && makepkg -fi
```
### 3. Fedora (local RPM)
A local `.spec` is available under `packaging/`. From the repository root:
```bash
sudo dnf install -y rpm-build
rpmbuild -bb packaging/proton-drive-linux.spec \
--define "git_dir $PWD" \
--define "_rpmdir $PWD/packaging/out" \
--define "_builddir $PWD/packaging/build" \
--define "_sourcedir $PWD" \
--define "_specdir $PWD/packaging" \
--define "_srcrpmdir $PWD/packaging/out"
sudo dnf install packaging/out/x86_64/proton-drive-linux-*.rpm
```
---
## Automated Releases (CI/CD)
File diff suppressed because it is too large. Load diff
+1
View File
@@ -20,3 +20,4 @@ ignore.workspace = true
sha2.workspace = true
rusqlite.workspace = true
keyring.workspace = true
parking_lot.workspace = true
+76 -4
View File
@@ -8,6 +8,7 @@
use keyring::Entry;
use proton_drive_rs::{KeySalt, ProtonDriveClient};
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
@@ -84,14 +85,68 @@ fn keyring_entry() -> Result<Entry> {
/// Run an interactive SRP + (optional) 2FA login and persist the session.
///
/// `get_totp` is only invoked when the account requires a second factor, so
/// callers can defer prompting until it is actually needed.
/// callers can defer prompting until it is actually needed. It may be called
/// more than once — [`login_interactive`] re-runs the whole login after human
/// verification, and a 2FA account needs a fresh code on that second attempt.
///
/// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end
/// that can present the challenge should use [`login_interactive`] instead.
pub async fn login(
username: &str,
password: &str,
get_totp: impl FnOnce() -> Result<String>,
get_totp: impl Fn() -> Result<String>,
) -> Result<()> {
let mut session =
ProtonApiSession::begin(client_config(), username, password.as_bytes()).await?;
login_verified(username, password, None, get_totp).await
}
/// [`login`], able to answer a human-verification gate rather than fail on it.
///
/// `get_hv` is invoked only when the API actually gates the login, mirroring how
/// `get_totp` is only invoked for accounts with a second factor: neither costs a
/// prompt the user did not need. It receives the challenge and returns the token
/// the user earned by solving it.
///
/// The retry lives here rather than in the front-ends because the recovery is
/// not a UI concern: the gated attempt burned its SRP handshake, so the login
/// has to start over with the credential attached, and every front-end would
/// otherwise have to know that. Gating the *retry* is not handled — a second
/// challenge in a row means the token was rejected, and looping on it would trap
/// the user in a CAPTCHA that never clears.
pub async fn login_interactive(
username: &str,
password: &str,
get_totp: impl Fn() -> Result<String>,
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
) -> Result<()> {
match login_verified(username, password, None, &get_totp).await {
Err(Error::HumanVerificationRequired(hv)) => {
let credential = get_hv(*hv)?;
login_verified(username, password, Some(&credential), &get_totp).await
}
other => other,
}
}
/// [`login`], replaying a human-verification token the user has already earned.
///
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
/// challenge. The front-end presents it, collects the solved token, and calls
/// this. The login starts over from the SRP handshake rather than resuming: the
/// gated attempt never got a session, and its `srp_session` is spent.
pub async fn login_verified(
username: &str,
password: &str,
verification: Option<&HumanVerificationCredential>,
get_totp: impl Fn() -> Result<String>,
) -> Result<()> {
let mut session = ProtonApiSession::begin_verified(
client_config(),
username,
password.as_bytes(),
verification,
)
.await
.map_err(classify_verification_gate)?;
if session.is_waiting_for_second_factor() {
let code = get_totp()?;
@@ -110,6 +165,23 @@ pub async fn login(
Ok(())
}
/// Lift a human-verification gate out of the generic API error into the typed
/// variant a front-end can act on.
///
/// Only a gate that names a solvable method is converted. A `9001` with no
/// `Details`, or one offering only `email`/`sms`, stays a plain API error:
/// promoting it would send the UI to open a verification page it cannot
/// complete, which reads to the user as a hang rather than a refusal.
fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error {
let proton_sdk::error::ProtonError::Api(api) = &e else {
return e.into();
};
match api.human_verification() {
Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)),
_ => e.into(),
}
}
/// Persist the session's current tokens, mailbox password and key salts.
pub async fn save(
session: &ProtonApiSession,
File diff suppressed because it is too large. Load diff
+149 -5
View File
@@ -1,11 +1,12 @@
//! Static client identity and per-user filesystem paths.
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
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.
@@ -34,6 +35,19 @@ pub struct AppConfig {
/// path here. Defaulted for configs predating the field.
#[serde(default)]
pub mountpoint: Option<String>,
/// Ignore patterns (gitignore syntax) applied to every mirror sync folder,
/// on top of each folder's own `.pdfsignore`. `None` means "use
/// [`DEFAULT_IGNORE_PATTERNS`]"; an explicit empty list disables the
/// defaults. Defaulted for configs predating the field.
#[serde(default)]
pub ignore_patterns: Option<Vec<String>>,
/// Device uid this machine has been *adopted* into, overriding the hostname
/// heuristic in `ensure_device`. Lives here rather than in `cache.db` so it
/// survives a fresh install — on a new machine the DB is empty by
/// definition, which is exactly when the pin matters. `None` means "resolve
/// by hostname". Defaulted for configs predating the field.
#[serde(default)]
pub device_uid: Option<String>,
}
impl Default for AppConfig {
@@ -43,6 +57,8 @@ impl Default for AppConfig {
user_agent: USER_AGENT.to_string(),
cache_budget: None,
mountpoint: None,
ignore_patterns: None,
device_uid: None,
}
}
}
@@ -53,6 +69,20 @@ impl AppConfig {
pub fn resolved_cache_budget(&self) -> u64 {
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
}
/// The effective global sync ignore patterns: the user's explicit list, or
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
/// honoured as "no global patterns", which is why this is not a
/// `unwrap_or_default`.
pub fn resolved_ignore_patterns(&self) -> Vec<String> {
match &self.ignore_patterns {
Some(patterns) => patterns.clone(),
None => DEFAULT_IGNORE_PATTERNS
.iter()
.map(|s| (*s).to_string())
.collect(),
}
}
}
/// Resolved XDG locations for state, cache, and the default mountpoint.
@@ -165,18 +195,104 @@ impl AppDirs {
.unwrap_or_else(|| self.default_mountpoint())
}
/// Create state + cache dirs if missing.
/// Create state + cache dirs if missing, owner-only.
///
/// The mode is set explicitly rather than left to the umask. These
/// directories hold decrypted file content, a plaintext index of every node
/// name in the Drive, and the control socket — and `create_dir_all` under a
/// typical umask makes them `0755`. Until this was fixed, the only thing
/// keeping another local user out was `~/.cache` and `~/.local/state`
/// happening to be `0700`, which is a convention of the user's system and
/// not something this client established (bugs.md B6).
///
/// Applied on every start, not just at creation: a directory that already
/// exists with a permissive mode — restored from a backup that flattened
/// modes, or created by an older build — is tightened here.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(self.state_dir())?;
std::fs::create_dir_all(self.cache_dir())?;
let _ = std::fs::create_dir_all(self.dirs.config_dir());
for dir in [self.state_dir(), self.cache_dir()] {
std::fs::create_dir_all(&dir)?;
restrict_dir(&dir);
}
let config_dir = self.dirs.config_dir().to_path_buf();
if std::fs::create_dir_all(&config_dir).is_ok() {
restrict_dir(&config_dir);
}
Ok(())
}
}
/// Make `dir` owner-only (`0700`). Best effort: a mode we cannot set (an
/// exotic filesystem, a directory we do not own) is not worth refusing to
/// start over, and the caller has no better answer than continuing.
fn restrict_dir(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!(path = %dir.display(), error = %e, "could not restrict directory to 0700");
}
}
/// Make a just-bound Unix socket owner-only (`0600`).
///
/// Connecting to the control socket drives the daemon with its authenticated
/// session — enumerate, read, upload, trash, share — without presenting any
/// credential, since the daemon already holds one. It is an authority boundary,
/// not merely private data, and `UnixListener::bind` applies the umask like any
/// other file (bugs.md B6).
///
/// There is a window between `bind` and this call during which the socket
/// carries the umask's mode. It is closed in practice by the containing
/// directory: [`Dirs::ensure`] has already made the state directory `0700`, so
/// nothing else can reach the socket to exploit the window.
pub fn restrict_socket(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn mode_of(p: &Path) -> u32 {
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
}
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
/// directories hold decrypted content, a plaintext index of every node name,
/// and the control socket that commands the daemon's session.
#[test]
fn restrict_dir_makes_a_directory_owner_only() {
let dir = std::env::temp_dir().join(format!("pdfs-cfg-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Start deliberately world-readable, as `create_dir_all` under a typical
// umask would leave it.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
restrict_dir(&dir);
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
let _ = std::fs::remove_dir_all(&dir);
}
/// Connecting to the control socket confers the daemon's authenticated
/// session with no credential, so it is an authority boundary rather than
/// merely private data.
#[test]
fn restrict_socket_makes_a_socket_owner_only() {
let dir = std::env::temp_dir().join(format!("pdfs-sock-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("control.sock");
let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
restrict_socket(&sock).unwrap();
assert_eq!(mode_of(&sock), 0o600, "no other local user may connect");
drop(listener);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_app_config_serialization() {
@@ -185,6 +301,8 @@ mod tests {
user_agent: "test-agent/1.0".to_string(),
cache_budget: Some(1234),
mountpoint: Some("/tmp/x".to_string()),
ignore_patterns: Some(vec!["build/".to_string()]),
device_uid: Some("dev-uid".to_string()),
};
let json = serde_json::to_string(&config).unwrap();
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
@@ -198,4 +316,30 @@ mod tests {
assert_eq!(default_config.app_version, APP_VERSION);
assert_eq!(default_config.user_agent, USER_AGENT);
}
#[test]
fn a_config_predating_ignore_patterns_still_loads() {
// Every installed config on disk looks like this; the field must not be
// required or the daemon would fall back to a default config on upgrade.
let json = r#"{"app_version":"v","user_agent":"u"}"#;
let decoded: AppConfig = serde_json::from_str(json).unwrap();
assert!(decoded.ignore_patterns.is_none());
assert_eq!(
decoded.resolved_ignore_patterns().len(),
DEFAULT_IGNORE_PATTERNS.len(),
"an absent list means the defaults apply"
);
}
#[test]
fn an_explicit_empty_ignore_list_disables_the_defaults() {
let config = AppConfig {
ignore_patterns: Some(Vec::new()),
..AppConfig::default()
};
assert!(
config.resolved_ignore_patterns().is_empty(),
"opting out must be expressible, so this cannot be unwrap_or_default"
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
//! The activity feed: a bounded, newest-first log of what the daemon did.
use rusqlite::params;
use super::Db;
use crate::Result;
use crate::control::{ActivityEntry, ActivityKind};
/// How many activity rows to keep. Older rows are pruned on insert, so the feed
/// stays a bounded "recent history" rather than growing without limit.
pub(super) const ACTIVITY_KEEP: i64 = 2000;
impl Db {
pub fn activity_add(&self, entry: &ActivityEntry) -> Result<()> {
let kind = serde_json::to_string(&entry.kind)?;
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"INSERT INTO activity (time, kind, target, detail, ok) VALUES (?1, ?2, ?3, ?4, ?5)",
params![entry.time, kind, entry.target, entry.detail, entry.ok],
)?;
tx.execute(
"DELETE FROM activity WHERE id <= (
SELECT id FROM activity ORDER BY id DESC LIMIT 1 OFFSET ?1
)",
params![ACTIVITY_KEEP],
)?;
tx.commit()?;
Ok(())
}
/// The most recent activity, newest first, capped at `limit` entries. Rows
/// whose stored `kind` no longer parses (written by an older build) are
/// skipped rather than failing the whole read.
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT time, kind, target, detail, ok FROM activity
ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, bool>(4)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (time, kind, target, detail, ok) = row?;
let Ok(kind) = serde_json::from_str::<ActivityKind>(&kind) else {
continue;
};
out.push(ActivityEntry {
time,
kind,
target,
detail,
ok,
});
}
Ok(out)
}
}
+172
View File
@@ -0,0 +1,172 @@
//! The content-cache index: one row per cached blob, carrying the access stamp
//! the LRU eviction pass sorts on.
use rusqlite::params;
use super::Db;
use crate::Result;
pub struct CacheEntryInput<'a> {
/// On-disk filename of the blob or block.
pub key: &'a str,
/// `'blob'` or `'block'` — the two byte budgets are counted separately.
pub kind: &'a str,
pub size: u64,
/// Initial LRU key, in unix milliseconds.
pub last_accessed: i64,
}
impl Db {
pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(cache_key) DO UPDATE SET
kind = excluded.kind,
size_bytes = excluded.size_bytes,
last_accessed = excluded.last_accessed",
params![key, kind, size as i64, now],
)?;
Ok(())
}
/// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is
/// a no-op — best effort, mirroring the old best-effort mtime touch.
pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
params![key, now],
)?;
Ok(())
}
/// Drop a single cache-entry row (one evicted blob or block).
pub fn cache_remove(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM cache_entries WHERE cache_key = ?1",
params![key],
)?;
Ok(())
}
/// Drop the whole-file blob row for `key` plus every derived row for the
/// same uid — blocks (`<key>.b<idx>`) and thumbnails (`<key>.t<type>`).
/// Called by `ContentCache::evict`, which removes the blob, all of a uid's
/// cached blocks and all of its thumbnails at once; a row left behind here
/// would keep counting bytes that are no longer on disk.
pub fn cache_remove_all(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM cache_entries
WHERE cache_key = ?1 OR cache_key LIKE ?2 OR cache_key LIKE ?3",
params![key, format!("{key}.b%"), format!("{key}.t%")],
)?;
Ok(())
}
/// Total bytes held by entries of `kind`.
///
/// The budget enforcers' first question, and almost always their last: the
/// common case is "under budget, evict nothing". Answering it with a `SUM`
/// costs one index range rather than materializing every row, which matters
/// because `enforce_block_budget` runs on every cached block — once per
/// 4 MiB of every cold read — while holding the shared connection.
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
let conn = self.conn.lock();
let total: i64 = conn.query_row(
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
params![kind],
|r| r.get(0),
)?;
Ok(total.max(0) as u64)
}
/// The `limit` least-recently-accessed entries of `kind`, oldest first.
///
/// Eviction wants a handful of victims, not the whole table. Bounded so a
/// pass over a large cache does not read every row to drop a few, and so the
/// `(kind, last_accessed)` index can answer it as a range scan with an early
/// stop.
pub fn cache_eviction_candidates(
&self,
kind: &str,
limit: usize,
) -> Result<Vec<(String, u64)>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare_cached(
"SELECT cache_key, size_bytes FROM cache_entries
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
)?;
let rows = stmt.query_map(params![kind, limit as i64], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Every cache entry of `kind`, ordered least-recently-accessed first, as
/// `(cache_key, size_bytes)`. Used by the whole-cache passes
/// (`clear_unpinned`); incremental eviction wants
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT cache_key, size_bytes FROM cache_entries
WHERE kind = ?1 ORDER BY last_accessed ASC",
)?;
let rows = stmt.query_map(params![kind], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Replace the whole cache index with `entries`, as one transaction. The
/// daemon calls this on open, so a crash or an external file deletion can
/// never leave a phantom row inflating the budget total.
///
/// The clear and the refill are the same commit deliberately: they are one
/// operation — "the index now describes what is on disk" — and a crash
/// between them would otherwise leave an empty index against a full cache
/// directory, i.e. a budget total of zero and nothing eviction can see.
///
/// One commit also matters for speed. A mount is the only caller and it runs
/// on the open path, so a cache of thousands of blobs used to pay thousands
/// of autocommit fsyncs before the mountpoint appeared.
pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM cache_entries", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(cache_key) DO UPDATE SET
kind = excluded.kind,
size_bytes = excluded.size_bytes,
last_accessed = excluded.last_accessed",
)?;
for e in entries {
stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?;
}
}
tx.commit()?;
Ok(())
}
// --- Pins (P5) --------------------------------------------------------
//
// The pin registry, formerly `pins.json`. One row per directly-pinned node,
// keyed by uid display string. `recursive` marks a folder pin: its whole
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
// descendant is covered the moment it lands in the node cache.
}
+47
View File
@@ -0,0 +1,47 @@
//! This machine as a registered Proton Drive device.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
pub struct StoredDevice {
pub uid: String,
pub share_id: String,
pub root_uid: String,
pub name: String,
pub created: i64,
}
impl Db {
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
let conn = self.conn.lock();
conn.query_row(
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
[],
|r| {
Ok(StoredDevice {
uid: r.get(0)?,
share_id: r.get(1)?,
root_uid: r.get(2)?,
name: r.get(3)?,
created: r.get(4)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Persist (or replace) this machine's device. The table holds a single row.
pub fn device_set(&self, dev: &StoredDevice) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM device", [])?;
conn.execute(
"INSERT INTO device (uid, share_id, root_uid, name, created)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![dev.uid, dev.share_id, dev.root_uid, dev.name, dev.created],
)?;
Ok(())
}
}
+215
View File
@@ -0,0 +1,215 @@
//! The local-file index: a generation-stamped scan of the mount, so search can
//! answer over files the daemon has on disk as well as the remote tree.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
use crate::localindex::LocalEntry;
use super::nodes::candidate_trigrams;
use super::utils::{TRIGRAM_MIN, like_escape};
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
/// in Drive. `path` is absolute.
pub struct LocalFileHit {
pub path: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
}
impl Db {
pub fn local_begin_scan(&self) -> Result<i64> {
let conn = self.conn.lock();
let current: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'local_scan_gen'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let next = current + 1;
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('local_scan_gen', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[next.to_string()],
)?;
Ok(next)
}
/// Write one batch of walked entries under scan generation `generation`. The
/// FTS index is *not* touched here — it is rebuilt once in
/// [`local_finish_scan`](Self::local_finish_scan).
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(path) DO UPDATE SET
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
scan_gen = excluded.scan_gen",
)?;
for e in entries {
stmt.execute(params![
e.path,
e.name,
e.is_dir as i64,
e.size,
e.mtime,
generation
])?;
}
}
tx.commit()?;
Ok(())
}
/// Close scan generation `generation`: drop every row an older scan wrote
/// (those paths are gone from disk), rebuild the FTS index over what remains,
/// and stamp the completion time. Returns the number of indexed entries.
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"DELETE FROM local_files WHERE scan_gen != ?1",
params![generation],
)?;
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[finished_at.to_string()],
)?;
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
tx.commit()?;
Ok(count)
}
/// When the last local scan completed (epoch seconds), or `None` if the index
/// has never been built. The daemon uses this to decide whether a fresh mount
/// needs an immediate rescan or can serve the existing index.
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
let conn = self.conn.lock();
Ok(conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
[],
|row| row.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok()))
}
/// Substring search over indexed local file names, newest-modified first
/// within a relevance tier. Mirrors [`search`](Self::search): the trigram
/// index handles queries of at least [`TRIGRAM_MIN`] chars, shorter ones fall
/// back to a `LIKE` scan.
pub fn search_local(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let mut stmt;
let rows = if query.chars().count() < TRIGRAM_MIN {
let pat = format!("%{}%", like_escape(query));
stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name LIKE ?1 ESCAPE '\\'
ORDER BY mtime DESC LIMIT ?2",
)?;
stmt.query_map(params![pat, limit as i64], local_hit)?
} else {
let phrase = query
.split_whitespace()
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
stmt = conn.prepare(
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
FROM local_fts x JOIN local_files f ON f.id = x.rowid
WHERE x.name MATCH ?1
ORDER BY x.rank LIMIT ?2",
)?;
stmt.query_map(params![phrase, limit as i64], local_hit)?
};
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Bounded candidates for the fuzzy scorer, including matches in the full
/// local path. ORed trigrams admit misspellings while the FTS index prevents
/// this from becoming an unbounded filesystem-table scan.
pub fn search_local_candidates(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\'
ORDER BY mtime DESC LIMIT ?2",
)?;
stmt.query_map(params![pat, limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
} else {
let mut stmt = conn.prepare(
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
FROM local_fts x JOIN local_files f ON f.id = x.rowid
WHERE local_fts MATCH ?1
ORDER BY x.rank LIMIT ?2",
)?;
stmt.query_map(params![terms.join(" OR "), lane_limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
};
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
let prefix = format!("{}%", like_escape(&first.to_string()));
let mut stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
ORDER BY name COLLATE NOCASE LIMIT ?2",
)?;
let fallback = stmt
.query_map(params![prefix, lane_limit as i64], local_hit)?
.collect::<std::result::Result<Vec<_>, _>>()?;
for hit in fallback {
if !rows.iter().any(|existing| existing.path == hit.path) {
rows.push(hit);
}
}
rows.truncate(limit);
}
Ok(rows)
}
}
/// Row → [`LocalFileHit`]. Every local-search query selects the same
/// columns in the same order.
pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
Ok(LocalFileHit {
path: row.get(0)?,
name: row.get(1)?,
is_dir: row.get::<_, i64>(2)? != 0,
size: row.get(3)?,
mtime: row.get(4)?,
})
}
+335
View File
@@ -0,0 +1,335 @@
//! Database health reporting and repair (features.md 4.3).
//!
//! Everything here exists for the case where something has already gone wrong:
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
//! what it holds. The daemon never calls these on its own — they are what
//! `pdfs cache inspect`, `pdfs cache vacuum`, and `pdfs diagnose` are built on,
//! so a user with a suspect install can get an answer without being told to
//! delete their state directory and re-sync from scratch.
use rusqlite::OptionalExtension;
use super::Db;
use crate::Result;
/// A point-in-time picture of the database's size and contents.
///
/// Sizes are computed from SQLite's own page accounting rather than by stat'ing
/// the file, so they stay right when the file is sparse or a checkpoint is
/// pending.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DbStats {
/// Schema version the file is currently at.
pub schema_version: i64,
/// Total pages allocated to the database.
pub page_count: i64,
/// Pages allocated but unused — what a `VACUUM` would reclaim.
pub free_pages: i64,
/// Bytes per page.
pub page_size: i64,
/// Row counts for the tables worth reporting, in display order.
pub tables: Vec<(String, i64)>,
}
impl DbStats {
/// Bytes the database file accounts for.
pub fn total_bytes(&self) -> u64 {
(self.page_count * self.page_size).max(0) as u64
}
/// Bytes a `VACUUM` could hand back to the filesystem.
pub fn reclaimable_bytes(&self) -> u64 {
(self.free_pages * self.page_size).max(0) as u64
}
}
/// What a maintenance run reclaimed.
///
/// Deliberately does not carry an orphaned-row count: the content cache already
/// rebuilds its index from what is actually on disk every time it opens (see
/// [`crate::cache::ContentCache`]'s `reconcile`), so a second pruning pass here
/// would be a duplicate of that logic with a worse view of the truth.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct VacuumOutcome {
/// Database bytes before the run.
pub before_bytes: u64,
/// Database bytes after it.
pub after_bytes: u64,
/// WAL frames written back into the database by the checkpoint.
pub wal_frames_checkpointed: i64,
}
impl VacuumOutcome {
/// Bytes handed back to the filesystem. Saturating: a database can legitimately
/// grow across a vacuum (a checkpoint folds WAL frames in), and reporting that
/// as a huge positive number via wraparound would be worse than reporting zero.
pub fn freed_bytes(&self) -> u64 {
self.before_bytes.saturating_sub(self.after_bytes)
}
}
/// The tables `inspect` reports on, in the order a reader wants them.
const REPORTED_TABLES: &[&str] = &[
"nodes",
"cache_entries",
"pins",
"photos",
"trash",
"device",
"sync_folder",
"sync_entry",
"pending_op",
"activity",
"local_files",
];
impl Db {
/// SQLite's own consistency check.
///
/// Returns an empty vector when the database is sound; otherwise each entry
/// is one problem SQLite found. Read-only and safe against a live daemon,
/// though it reads every page, so it is not free on a large file.
pub fn integrity_check(&self) -> Result<Vec<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut problems = Vec::new();
for row in rows {
let row = row?;
// A sound database reports the single line "ok"; anything else is a
// finding.
if row != "ok" {
problems.push(row);
}
}
Ok(problems)
}
/// Size and row-count snapshot for `pdfs cache inspect`.
pub fn stats(&self) -> Result<DbStats> {
let conn = self.conn.lock();
let page_count: i64 = conn.query_row("PRAGMA page_count", [], |r| r.get(0))?;
let free_pages: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0))?;
let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0))?;
// The schema version lives in the `sync_state` key/value table, not in
// `PRAGMA user_version` — see `migrations`.
let schema_version: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'schema_version'",
[],
|r| r.get::<_, String>(0),
)
.optional()?
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let mut tables = Vec::new();
for name in REPORTED_TABLES {
// A table listed here may not exist yet on an older schema, and a
// report is not worth failing over a missing one.
let exists: Option<String> = conn
.query_row(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?1",
[name],
|r| r.get(0),
)
.optional()?;
if exists.is_none() {
continue;
}
let count: i64 =
conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| {
r.get(0)
})?;
tables.push(((*name).to_string(), count));
}
Ok(DbStats {
schema_version,
page_count,
free_pages,
page_size,
tables,
})
}
/// Fold the WAL back into the database and compact the file.
///
/// `VACUUM` rewrites the whole database into a fresh file, so it needs room
/// for a second copy and takes a write lock for the duration. That makes it
/// a deliberate, user-invoked operation rather than something the daemon
/// does on a timer.
///
/// The checkpoint runs *after* the vacuum. Checkpointing only beforehand
/// folds a log that the vacuum is about to rewrite anyway, and leaves the
/// rewrite itself sitting in the WAL: measured on a live install, a 170 MiB
/// database vacuumed to 136 MiB while its WAL grew 67 MiB → 143 MiB,
/// erasing the reclaim in net disk terms and reporting `0 frames`
/// checkpointed because it ran when there was nothing to fold.
///
/// Ordering it afterwards is strictly better but is *not* a proven fix for
/// that inflation. The live case inflates because the daemon's other
/// connections hold read transactions, and a `TRUNCATE` checkpoint waits on
/// readers — so under a busy daemon this can still return having moved
/// little. `journal_size_limit` remains the backstop that caps the file.
/// Confirming the fix needs a measurement against a live daemon, not a
/// single-connection test, which folds the WAL on its own either way.
pub fn vacuum(&self) -> Result<VacuumOutcome> {
let before = self.stats()?.total_bytes();
{
let conn = self.conn.lock();
conn.execute_batch("VACUUM")?;
}
let wal_frames_checkpointed = self.checkpoint_wal()?;
let after = self.stats()?.total_bytes();
Ok(VacuumOutcome {
before_bytes: before,
after_bytes: after,
wal_frames_checkpointed,
})
}
/// Checkpoint the WAL and truncate it, returning the number of frames
/// written back into the database.
///
/// `TRUNCATE` blocks on readers rather than giving up part-way, which is
/// what makes the result meaningful: a `PASSIVE` checkpoint against a busy
/// daemon can legitimately move nothing and report success.
pub fn checkpoint_wal(&self) -> Result<i64> {
let conn = self.conn.lock();
// Columns are (busy, log frames, frames checkpointed).
let frames: i64 = conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(2))?;
Ok(frames.max(0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_database_passes_its_integrity_check() {
let db = Db::open_in_memory().unwrap();
assert!(db.integrity_check().unwrap().is_empty());
}
#[test]
fn stats_report_the_schema_and_the_known_tables() {
let db = Db::open_in_memory().unwrap();
let stats = db.stats().unwrap();
assert!(stats.schema_version > 0, "migrations set a user_version");
assert!(stats.page_size > 0);
assert!(
stats.tables.iter().any(|(name, _)| name == "nodes"),
"the nodes table should be reported; got {:?}",
stats.tables
);
// A fresh database has no rows in any of them.
assert!(stats.tables.iter().all(|(_, count)| *count == 0));
}
#[test]
fn total_and_reclaimable_bytes_derive_from_the_page_counts() {
let stats = DbStats {
schema_version: 1,
page_count: 10,
free_pages: 4,
page_size: 4096,
tables: Vec::new(),
};
assert_eq!(stats.total_bytes(), 40960);
assert_eq!(stats.reclaimable_bytes(), 16384);
}
/// A vacuum that leaves the file larger (a checkpoint folded WAL frames in)
/// must report nothing freed, not an underflowed enormous number.
#[test]
fn freed_bytes_does_not_underflow_when_the_file_grew() {
let outcome = VacuumOutcome {
before_bytes: 100,
after_bytes: 400,
..VacuumOutcome::default()
};
assert_eq!(outcome.freed_bytes(), 0);
}
#[test]
fn freed_bytes_reports_the_difference_when_the_file_shrank() {
let outcome = VacuumOutcome {
before_bytes: 4096,
after_bytes: 1024,
..VacuumOutcome::default()
};
assert_eq!(outcome.freed_bytes(), 3072);
}
#[test]
fn vacuuming_a_fresh_database_leaves_it_sound() {
let db = Db::open_in_memory().unwrap();
db.vacuum().unwrap();
assert!(db.integrity_check().unwrap().is_empty());
}
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::cache`]'s tests. Needed here because an in-memory database has
/// no WAL file to measure.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-maint-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A file-backed vacuum reclaims space and leaves the file sound.
///
/// Note what this does *not* pin: the WAL-inflation behaviour that motivated
/// the checkpoint ordering in [`Db::vacuum`]. Measured here, both orderings
/// report `frames=0` and leave a 0-byte WAL, because a single-connection
/// test lets SQLite fold the log itself. The live case inflated only because
/// the daemon holds concurrent readers that block a checkpoint, and
/// reproducing that needs a second connection parked in a read transaction.
/// An assertion on frame count or WAL size here would pass under both
/// orderings and so would be decoration — see the note in `vacuum`.
#[test]
fn a_file_backed_vacuum_reclaims_space_and_stays_sound() {
let dir = TempDir::new();
let path = dir.0.join("cache.db");
let db = Db::open(&path).unwrap();
for i in 0..2000 {
db.set_state_str(&format!("k{i}"), &"v".repeat(256))
.unwrap();
}
let full = db.stats().unwrap().total_bytes();
for i in 0..2000 {
db.set_state_str(&format!("k{i}"), "").unwrap();
}
let outcome = db.vacuum().unwrap();
assert!(
outcome.after_bytes < full,
"vacuum did not reclaim: {} → {} (peak {full})",
outcome.before_bytes,
outcome.after_bytes
);
assert!(db.integrity_check().unwrap().is_empty());
}
}
+408
View File
@@ -0,0 +1,408 @@
//! Forward-only schema migrations.
//!
//! Each `MIGRATION_V*` is the SQL that moves the schema from version N-1 to N.
//! They are applied in order and never edited once shipped: a released version
//! is already on disk somewhere, so a change here would silently diverge from
//! what an existing install actually has. Add a new one instead.
use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 16;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
let mut conn = self.conn.lock();
// `sync_state` is the key/value table holding `schema_version` and the
// event cursor (later). Create it first so we can read the version.
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT);",
)?;
let current: i64 = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'schema_version'",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if current >= SCHEMA_VERSION {
return Ok(());
}
let tx = conn.transaction()?;
if current < 1 {
tx.execute_batch(MIGRATION_V1)?;
}
if current < 2 {
tx.execute_batch(MIGRATION_V2)?;
}
if current < 3 {
tx.execute_batch(MIGRATION_V3)?;
}
if current < 4 {
tx.execute_batch(MIGRATION_V4)?;
}
if current < 5 {
tx.execute_batch(MIGRATION_V5)?;
}
if current < 6 {
tx.execute_batch(MIGRATION_V6)?;
}
if current < 7 {
tx.execute_batch(MIGRATION_V7)?;
}
if current < 8 {
tx.execute_batch(MIGRATION_V8)?;
}
if current < 9 {
tx.execute_batch(MIGRATION_V9)?;
}
if current < 10 {
tx.execute_batch(MIGRATION_V10)?;
}
if current < 11 {
tx.execute_batch(MIGRATION_V11)?;
}
if current < 12 {
tx.execute_batch(MIGRATION_V12)?;
}
if current < 13 {
tx.execute_batch(MIGRATION_V13)?;
}
if current < 14 {
tx.execute_batch(MIGRATION_V14)?;
}
if current < 15 {
tx.execute_batch(MIGRATION_V15)?;
}
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[SCHEMA_VERSION.to_string()],
)?;
tx.commit()?;
Ok(())
}
}
/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created
/// in [`Db::migrate`] before this runs.
const MIGRATION_V1: &str = "
CREATE TABLE nodes (
uid TEXT PRIMARY KEY,
parent_uid TEXT,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER,
mtime INTEGER NOT NULL,
revision_hash TEXT,
trashed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_nodes_parent ON nodes(parent_uid);
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, content='', tokenize='trigram'
);
CREATE TABLE cache_entries (
cache_key TEXT PRIMARY KEY,
size_bytes INTEGER,
last_accessed INTEGER,
is_pinned INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v2: node write-through (P1). `node_json` stores the full [`Node`] so
/// the hot-cache maps rehydrate losslessly; `listed` records whether a folder's
/// child listing was complete when persisted.
const MIGRATION_V2: &str = "
ALTER TABLE nodes ADD COLUMN node_json TEXT;
ALTER TABLE nodes ADD COLUMN listed INTEGER NOT NULL DEFAULT 0;
";
/// Schema v3: FTS search (P3). The v1 `nodes_fts` was contentless (`content=''`)
/// and never populated — drop it and recreate as a self-contained trigram index
/// carrying the `uid` (UNINDEXED, so retrievable but not tokenized) alongside the
/// indexed `name`. Backfill from the nodes already persisted by P1.
const MIGRATION_V3: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
uid UNINDEXED, name, tokenize='trigram'
);
INSERT INTO nodes_fts (uid, name)
SELECT uid, name FROM nodes WHERE trashed = 0;
";
/// Schema v4: content-cache LRU index (P4). The v1 `cache_entries` keyed only by
/// `cache_key`; add a `kind` discriminator so the whole-file blob pool and the
/// block pool — which carry separate byte budgets — can be summed and evicted
/// independently. Existing rows (none in practice, the table was never written)
/// default to `'blob'`. The daemon rebuilds the index from disk on open, so no
/// backfill is needed here.
const MIGRATION_V4: &str = "
ALTER TABLE cache_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'blob';
";
/// Schema v5: pins move out of `pins.json` into the DB (P5). One row per
/// directly-pinned node, keyed by uid display string so a pin survives
/// renames/moves. `recursive` marks a folder pin whose whole subtree is kept;
/// descendants are resolved on demand against `nodes` via a CTE rather than
/// being expanded into rows here. `ContentCache::open` imports any legacy
/// `pins.json` into this table once, then deletes the file.
const MIGRATION_V5: &str = "
CREATE TABLE pins (
uid TEXT PRIMARY KEY,
path TEXT NOT NULL,
recursive INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v6: the index of *local* (non-Drive) files, so the launcher prompt can
/// search the machine alongside Drive. `local_files` is keyed by absolute path;
/// `scan_gen` stamps the scan that last saw a row, so a rescan prunes vanished
/// files with one `DELETE` instead of diffing.
///
/// `local_fts` is an *external-content* FTS5 index over `local_files.name`: the
/// text lives once in the base table and the index is rebuilt in bulk at the end
/// of a scan (`INSERT INTO local_fts(local_fts) VALUES('rebuild')`). That is far
/// cheaper than the delete-then-insert-per-row dance `nodes_fts` needs, because
/// a scan rewrites most rows at once rather than trickling single updates.
const MIGRATION_V6: &str = "
CREATE TABLE local_files (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0,
scan_gen INTEGER NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE local_fts USING fts5(
name, content='local_files', content_rowid='id', tokenize='trigram'
);
";
/// Schema v7: the photos timeline and the trash listing become persistent, so
/// opening the app paints them from disk instead of re-fetching the world (both
/// were memory-only: the timeline behind a 60 s TTL, the trash not cached at all).
///
/// `photos.seq` preserves the server's newest-first order, which is the only order
/// the timeline has — `capture_time` ties are common (a burst of shots) and would
/// otherwise shuffle between refreshes. `ratio` and `thumb_state` are *locally
/// learned*: they cost a download to rediscover, so `photos_replace` carries them
/// across a refresh while capture times and order come from the server.
const MIGRATION_V7: &str = "
CREATE TABLE photos (
uid TEXT PRIMARY KEY,
capture_time INTEGER NOT NULL,
name TEXT,
ratio REAL,
thumb_state INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL
);
CREATE INDEX idx_photos_seq ON photos(seq);
CREATE TABLE trash (
uid TEXT PRIMARY KEY,
name TEXT NOT NULL,
is_dir INTEGER NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
mtime INTEGER NOT NULL DEFAULT 0
);
";
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
/// **Device** (a share + root folder on the main volume); `device` is a singleton
/// row cached so we reuse the same device across restarts instead of creating a
/// new one each run. `sync_folder` is one row per local folder the user added,
/// each mapped to a folder under the device root; `mode` is `mirror` (full local
/// copy, two-way synced) or `ondemand` (FUSE mount at `local_path`, no local
/// storage). `sync_entry` is the per-file baseline for three-way merge — added in
/// this migration so Phase 2 has the table, though Phase 1 leaves it empty.
const MIGRATION_V8: &str = "
CREATE TABLE device (
uid TEXT PRIMARY KEY,
share_id TEXT NOT NULL,
root_uid TEXT NOT NULL,
name TEXT NOT NULL,
created INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_folder (
id INTEGER PRIMARY KEY,
local_path TEXT NOT NULL UNIQUE,
remote_uid TEXT NOT NULL,
remote_share_id TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'mirror',
state TEXT NOT NULL DEFAULT 'idle',
last_sync INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE sync_entry (
folder_id INTEGER NOT NULL,
rel_path TEXT NOT NULL,
remote_uid TEXT,
local_mtime INTEGER NOT NULL DEFAULT 0,
local_size INTEGER NOT NULL DEFAULT 0,
remote_hash TEXT,
remote_rev TEXT,
PRIMARY KEY (folder_id, rel_path)
);
";
const MIGRATION_V9: &str = "
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
time INTEGER NOT NULL,
kind TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX activity_time ON activity(time DESC);
";
/// Schema v10: a mode switch the user asked for is queued rather than rejected
/// when the folder is mid-pass or has un-uploaded changes, so `pending_mode`
/// records the intent until the engine can act on it. NULL is the resting state:
/// the folder is already where the user wants it.
const MIGRATION_V10: &str = "
ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
";
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
/// the bytes and records the intended upload here, and a drain worker performs it
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
/// dead network and a restart.
///
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
/// the [`StagedWrite`] sidecar describing which of its bytes are real — an
/// incomplete blob must be gap-filled from the remote base before it can be
/// uploaded. `next_attempt_at` is a ms deadline implementing retry backoff.
///
/// [`StagedWrite`]: crate::cache::StagedWrite
const MIGRATION_V11: &str = "
CREATE TABLE pending_op (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
uid TEXT NOT NULL,
blob_path TEXT,
meta_json TEXT,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_attempt_at INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX pending_op_uid ON pending_op(uid);
";
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
/// revision op never had to describe — it always addressed a node the server had
/// already minted a uid for (offline.md Phase 3b).
///
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
/// locally under a `local~<uuid>` placeholder and the op records where it goes
/// (`parent_uid`) and what it is called (`name`). Both are nullable because a
/// revision op sets neither.
///
/// `parent_uid` is indexed: draining a folder rewrites every queued child that
/// points at its placeholder.
const MIGRATION_V12: &str = "
ALTER TABLE pending_op ADD COLUMN parent_uid TEXT;
ALTER TABLE pending_op ADD COLUMN name TEXT;
CREATE INDEX pending_op_parent ON pending_op(parent_uid);
";
/// Schema v13: photos record their media type and a derived `kind` so the
/// timeline can be split into Photos / Videos / Raw tabs. `media_type` is
/// nullable — it is filled once the daemon resolves a photo's node — while
/// `kind` (see [`crate::control::PhotoKind`]) defaults to `0` (still photo), the
/// classification anything unresolved falls back to. Existing rows are
/// reclassified for free on the next timeline refresh, so no backfill is needed.
const MIGRATION_V13: &str = "
ALTER TABLE photos ADD COLUMN media_type TEXT;
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
";
/// Schema v14: index the content-cache LRU. `cache_entries` carried only its
/// `cache_key` primary key, so every budget check and every eviction pass was a
/// full scan plus a sort — on a path that runs once per cached 4 MiB block,
/// under the connection lock every FUSE metadata call also needs. `(kind,
/// last_accessed)` turns both into a range scan: the `SUM` reads one kind's
/// slice, and eviction reads its front and stops.
const MIGRATION_V14: &str = "
CREATE INDEX cache_entries_lru ON cache_entries(kind, last_accessed);
";
/// Schema v15: key the search index by rowid so a row can be replaced without
/// scanning the whole index (B12).
///
/// v3 built `nodes_fts` with the node's `uid` as an `UNINDEXED` column and
/// `upsert_nodes` refreshed a row with `DELETE FROM nodes_fts WHERE uid = ?`.
/// `UNINDEXED` means exactly what it says — the column is retrievable but not
/// searchable — so that predicate could only ever be a full scan of the index
/// (`EXPLAIN QUERY PLAN`: `SCAN nodes_fts VIRTUAL TABLE`). Every node written
/// scanned every node indexed: measured at 6.5 ms per node against a 17k-node
/// index, about half the cost of a cold listing, and *growing with the size of
/// the account* rather than the size of the listing.
///
/// FTS5 deletes by `rowid` efficiently, and `nodes` is an ordinary rowid table
/// (`uid` is its `TEXT PRIMARY KEY`, not `WITHOUT ROWID`), so its rowid is a
/// stable per-node key that `ON CONFLICT DO UPDATE` preserves. Rebuild the index
/// keyed by it and drop the `uid` column: searches now join on `rowid`, which is
/// also a cheaper join than the old one on a TEXT uid.
const MIGRATION_V15: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, tokenize='trigram'
);
INSERT INTO nodes_fts (rowid, name)
SELECT rowid, name FROM nodes WHERE trashed = 0;
";
/// Schema v16: fuzzy search needs a bounded candidate set that includes parent
/// paths. Keeping the path in the trigram indexes lets SQLite narrow both name
/// and path candidates without walking either base table for every keystroke.
/// Node paths are initially backfilled below and subsequently maintained by the
/// node write path (including descendants after a folder move/rename).
const MIGRATION_V16: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
INSERT INTO nodes_fts (rowid, name, path)
WITH RECURSIVE paths(rowid, uid, path) AS (
SELECT n.rowid, n.uid, '' FROM nodes n
WHERE n.parent_uid IS NULL
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
UNION ALL
SELECT n.rowid, n.uid,
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
)
SELECT n.rowid, n.name, paths.path
FROM nodes n JOIN paths ON paths.rowid = n.rowid
WHERE n.trashed = 0;
DROP TABLE IF EXISTS local_fts;
CREATE VIRTUAL TABLE local_fts USING fts5(
name, path, content='local_files', content_rowid='id', tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
INSERT INTO local_fts(local_fts) VALUES('rebuild');
";
+128
View File
@@ -0,0 +1,128 @@
//! Unified SQLite metadata cache — the single persistence layer behind FUSE
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
//!
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
//! the same data through the control socket. The connection is wrapped in a
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
//! behind the `State` lock, so a connection pool would be overkill.
//!
//! This module is the P0 foundation: it opens the database, enables WAL, and
//! applies the forward-only schema migrations. Write-through of nodes, the
//! event cursor, FTS, and the cache index land in later phases on this schema.
//!
//! The surface is one `Db` type; its methods live in a submodule per table group
//! (`nodes`, `photos`, `pins`, …) as separate `impl Db` blocks. Everything those
//! modules define is re-exported here, so callers keep saying `db::StoredPhoto`
//! and never name a submodule.
use parking_lot::Mutex;
use rusqlite::Connection;
use crate::Result;
use std::path::Path;
mod activity;
mod cache;
mod devices;
mod local;
mod maintenance;
mod migrations;
mod nodes;
mod ops;
mod photos;
mod pins;
mod state;
mod sync;
mod trash;
mod utils;
pub use cache::CacheEntryInput;
pub use devices::StoredDevice;
pub use local::LocalFileHit;
pub use maintenance::{DbStats, VacuumOutcome};
pub use nodes::{SearchHit, StoredNode};
pub use ops::{
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PendingCounts, PendingOp, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use sync::{StoredSyncEntry, StoredSyncFolder};
pub use trash::StoredTrash;
/// Size the WAL is truncated back to after a checkpoint. Comfortably above the
/// steady-state working set (a few MB), so the truncation only claws back the
/// outliers rather than fighting the normal write path for disk.
const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
/// How long a statement waits for a database lock before giving up.
///
/// Long enough to outlast any transaction this daemon writes (the largest, a
/// full-index FTS rebuild, is well under a second), short enough that a
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Handle to the unified metadata database.
///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
/// the inner `Mutex<Connection>`.
pub struct Db {
conn: Mutex<Connection>,
}
impl Db {
/// Open (creating if absent) the database at `path`, enable WAL, and bring
/// the schema up to [`SCHEMA_VERSION`].
pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?;
// WAL: readers never block the single writer. NORMAL sync is the
// standard durability/throughput tradeoff for WAL.
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
// Hand the WAL's disk back after a checkpoint. Without a limit SQLite
// reuses the file in place but never shrinks it, so a single large
// transaction (a full-index FTS rebuild in `local_finish_scan` is the
// one that reaches this size) leaves the WAL at its high-water mark
// forever — a multi-GB file next to a database two orders of magnitude
// smaller. Checkpointing is unaffected; only the file is truncated back.
conn.pragma_update(None, "journal_size_limit", WAL_SIZE_LIMIT)?;
// Wait for a lock rather than failing instantly on SQLITE_BUSY.
//
// This is deliberately explicit and deliberately redundant: rusqlite
// already applies a five-second busy timeout of its own, so this changes
// nothing today. It is here so the value is *ours* — a behaviour the
// daemon depends on should not be an inherited default that a dependency
// bump can silently change. `open_bounds_the_wal_size` pins it.
//
// (Bare SQLite does default to 0, which is where the belief that this
// was unset came from. That default is not what we get.)
conn.busy_timeout(BUSY_TIMEOUT)?;
let db = Self {
conn: Mutex::new(conn),
};
db.migrate()?;
Ok(db)
}
/// Open an in-memory database. For tests.
#[cfg(test)]
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
let db = Self {
conn: Mutex::new(conn),
};
db.migrate()?;
Ok(db)
}
/// Run a closure with the locked connection. Escape hatch for callers that
/// need a query no typed method covers yet.
pub fn with_conn<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
let conn = self.conn.lock();
f(&conn)
}
}
#[cfg(test)]
mod tests;
+418
View File
@@ -0,0 +1,418 @@
//! Node rows: the persisted mirror of the remote tree, plus the trigram
//! full-text index over names and paths that backs `Request::Search`.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeKind};
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
pub struct StoredNode {
pub node: Node,
pub listed: bool,
}
/// One full-text search match: the stored [`Node`] plus its mountpoint-relative
/// path (`/`-joined, root excluded) so the front-end can navigate to or open it.
pub struct SearchHit {
pub node: Node,
pub path: String,
}
impl Db {
pub fn upsert_node(&self, node: &Node) -> Result<()> {
self.upsert_nodes(std::slice::from_ref(node))
}
/// Write-through a batch of nodes as one transaction — a whole directory
/// listing, typically. Otherwise identical to [`upsert_node`](Self::upsert_node),
/// which is the single-node case of it.
///
/// The commit count is the point: SQLite autocommits every statement that is
/// not in an explicit transaction, so interning a folder of a thousand
/// children row-by-row cost a thousand fsyncs, and `ls` waited for all of
/// them.
pub fn upsert_nodes(&self, nodes: &[Node]) -> Result<()> {
if nodes.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
for node in nodes {
let json = serde_json::to_string(node)?;
let uid = node.uid.to_string();
tx.execute(
"INSERT INTO nodes
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(uid) DO UPDATE SET
parent_uid = excluded.parent_uid,
name = excluded.name,
is_dir = excluded.is_dir,
size = excluded.size,
mtime = excluded.mtime,
trashed = excluded.trashed,
node_json = excluded.node_json",
params![
uid,
node.parent_uid.as_ref().map(|u| u.to_string()),
node.name,
node.is_folder() as i64,
node_size(node),
node.modification_time,
node.trashed as i64,
json,
],
)?;
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
// Trashed nodes are kept out of the index entirely so they never
// surface in search results.
//
// Keyed by the node's rowid, never by its uid: the uid is not an
// indexed FTS column, so deleting by it scans the entire index once
// per node written (B12). The rowid is stable across the upsert
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
// the same key before and after.
let rowid: i64 = tx.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)?;
// A folder rename/move changes every descendant's searchable path.
// Refresh the affected subtree, while retaining efficient rowid
// deletes for the overwhelmingly common one-file case.
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed {
let path = path_of(&tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
}
tx.commit()?;
Ok(())
}
/// Drop a node row (delete or trash from the hot cache). Children rows are
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
let uid = uid.to_string();
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// Read the rowid before dropping the node: it is the search index's key
// (B12), and it is gone once the row is. A node with no row leaves
// nothing to unindex — the index is keyed off `nodes`, so it cannot
// hold an entry the table never had.
let rowid: Option<i64> = tx
.query_row(
"SELECT rowid FROM nodes WHERE uid = ?1",
params![uid],
|row| row.get(0),
)
.optional()?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
if let Some(rowid) = rowid {
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
}
tx.commit()?;
Ok(())
}
/// Check if a folder node has any non-trashed children in the database.
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
let uid_str = parent_uid.to_string();
let conn = self.conn.lock();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
params![uid_str],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Resolve `uid` relative to an ancestor node.
///
/// This is used when a remote subtree has its own local sync mount: the
/// sync folder stores the ancestor UID, while search results identify the
/// selected descendant. Returning `None` for an unrelated or incomplete
/// chain keeps callers from accidentally joining a Drive-wide path onto the
/// wrong mountpoint. The ancestor itself resolves to the empty path.
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
WHERE anc.depth < 1024
)
SELECT uid, name FROM anc ORDER BY depth",
)?;
let rows = stmt.query_map(params![uid], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut parts = Vec::new();
for row in rows {
let (current_uid, name) = row?;
if current_uid == ancestor_uid {
parts.reverse();
return Ok(Some(parts.join("/")));
}
parts.push(name);
}
Ok(None)
}
/// Full-text search over node names, newest schema's trigram index giving
/// substring (not just prefix) matches. Returns up to `limit` non-trashed
/// hits, each with its mountpoint-relative path resolved. Queries shorter
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
/// nothing below 3 chars.
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT node_json, uid FROM nodes
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
} else {
// Escape double quotes and quote each term, then combine with AND so
// all terms must match but can appear in any order or position.
let phrase = query
.split_whitespace()
.map(|word| format!("\"{}\"", word.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
};
let mut hits = Vec::with_capacity(rows.len());
for (json, uid) in rows {
let node: Node = serde_json::from_str(&json)?;
let path = path_of(&conn, &uid)?;
hits.push(SearchHit { node, path });
}
Ok(hits)
}
/// Return a bounded, deliberately broad candidate pool for fuzzy ranking.
/// Unlike [`search`](Self::search), trigram terms are ORed across both the
/// basename and parent path, so a typo can still share enough trigrams to
/// enter the pool. Final relevance ordering belongs to the caller.
pub fn search_candidates(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<(String, String)> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT node_json, uid FROM nodes
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
} else {
let expression = terms.join(" OR ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
};
// A short substitution can destroy every trigram (`vedio` vs `video`).
// Feed the scorer an additional indexed same-initial lane so it can
// recover those candidates without scanning the full node table.
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
let prefix = format!("{}%", like_escape(&first.to_string()));
let mut stmt = conn.prepare(
"SELECT node_json, uid FROM nodes
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name COLLATE NOCASE LIMIT ?2",
)?;
let fallback =
collect_pairs(stmt.query_map(params![prefix, lane_limit as i64], pair)?)?;
for row in fallback {
if !rows.iter().any(|(_, uid)| uid == &row.1) {
rows.push(row);
}
}
rows.truncate(limit);
}
rows.into_iter()
.map(|(json, uid)| {
Ok(SearchHit {
node: serde_json::from_str(&json)?,
path: path_of(&conn, &uid)?,
})
})
.collect()
}
/// Mark (or unmark) a folder's child listing as complete. A listed folder
/// rehydrates its `children` map on mount even when empty; an unlisted one
/// re-enumerates from the remote on next access.
pub fn set_listed(&self, uid: &NodeUid, listed: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE nodes SET listed = ?2 WHERE uid = ?1",
params![uid.to_string(), listed as i64],
)?;
Ok(())
}
/// Load every persisted node for cold-start hydration of the `State` maps.
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
let conn = self.conn.lock();
let mut stmt =
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
let rows = stmt.query_map([], |row| {
let json: String = row.get(0)?;
let listed: i64 = row.get(1)?;
Ok((json, listed != 0))
})?;
let mut out = Vec::new();
for row in rows {
let (json, listed) = row?;
let node: Node = serde_json::from_str(&json)?;
out.push(StoredNode { node, listed });
}
Ok(out)
}
/// Load one persisted node back by uid. Used to recover the My Files root
/// when the API is unreachable, so the mount can still serve the cached tree
/// (offline.md Phase 1).
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
let conn = self.conn.lock();
let json: Option<String> = conn
.query_row(
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
params![uid],
|r| r.get(0),
)
.optional()?;
match json {
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
None => Ok(None),
}
}
/// Read the persisted incremental-sync cursor (a `DriveEventId`), if any.
/// The daemon resumes from this on restart instead of reseeding to the
/// server head, so changes made while unmounted are still applied (P2).
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
let conn = self.conn.lock();
let listed: Option<i64> = conn
.query_row(
"SELECT listed FROM nodes WHERE uid = ?1",
params![parent.to_string()],
|r| r.get(0),
)
.optional()?;
if listed != Some(1) {
return Ok(None);
}
let mut stmt = conn.prepare(
"SELECT node_json FROM nodes
WHERE parent_uid = ?1 AND node_json IS NOT NULL AND trashed = 0",
)?;
let rows = stmt.query_map(params![parent.to_string()], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for json in rows {
out.push(serde_json::from_str(&json?)?);
}
Ok(Some(out))
}
// --- Content-cache LRU index (P4) -------------------------------------
//
// Replaces the per-eviction `read_dir` scans in `ContentCache`. Each cached
// blob/block carries one row keyed by its on-disk filename (`cache_key`),
// tagged with `kind` ('blob' | 'block') so the two byte budgets stay
// separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns
// the on-disk cache and rebuilds this index from disk on open, then keeps it
// in sync on every store/read/evict, so it is authoritative for eviction.
}
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
let chars: Vec<char> = query.to_lowercase().chars().collect();
let mut terms = Vec::new();
for window in chars.windows(TRIGRAM_MIN) {
if window.iter().all(|c| c.is_whitespace()) {
continue;
}
let raw: String = window.iter().collect();
let quoted = format!("\"{}\"", raw.replace('"', "\"\""));
if !terms.contains(&quoted) {
terms.push(quoted);
}
}
terms
}
/// Effective plaintext size of a node for the indexed `size` column: the
/// claimed size when known, else the on-storage size; folders are 0.
pub(super) fn node_size(node: &Node) -> i64 {
match &node.kind {
NodeKind::Folder => 0,
NodeKind::File {
total_size_on_storage,
claimed_size,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0),
}
}
+457
View File
@@ -0,0 +1,457 @@
//! The offline mutation queue (`pending_op`): writes the kernel has accepted but
//! the remote has not yet seen. Rebuilt into memory on mount and drained in row
//! order, so a child never drains before the parent that gives it a real uid.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
/// The `kind` of a [`PendingOp`] that uploads a staged file as a new revision.
pub const OP_REVISION: &str = "revision";
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
///
/// The written bytes ride along on the same row rather than as a follow-on
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
/// leave a separate revision op addressed to a uid that no longer exists.
pub const OP_CREATE: &str = "create";
/// The `kind` of a [`PendingOp`] that creates a folder that so far exists only
/// locally. Ordering matters: a child's op is queued after its parent's and
/// [`Db::pending_ops`] replays by row id, so the parent has a real uid by the
/// time the child drains.
pub const OP_MKDIR: &str = "mkdir";
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
/// both — the queued form of `mv` (offline.md Phase 3b).
///
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
/// drain can compare them against the remote and skip whichever half already
/// matches. The parent may be a `local~` placeholder — moving a file into a
/// folder that was itself created offline — in which case it is rewritten by
/// that folder's own drain, exactly as for [`OP_CREATE`].
pub const OP_RENAME: &str = "rename";
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
/// (offline.md Phase 3b).
///
/// A node that only ever existed locally never gets one of these: there is
/// nothing to trash remotely, so deleting it just drops its queued ops
/// ([`Db::delete_ops_for_uid`]).
pub const OP_TRASH: &str = "trash";
/// Whether at most one op of this `kind` may be queued per node, so that a newer
/// one replaces the older rather than queueing behind it.
///
/// True exactly of the kinds that describe a node's desired *end state* rather
/// than a step towards it: the newest revision already contains every earlier
/// one's bytes, the newest name is the only name wanted, and a trash subsumes
/// anything queued before it. A `create`/`mkdir` is the opposite — it is the one
/// thing that will ever make the node exist, so it must never be replaced.
pub fn op_supersedes(kind: &str) -> bool {
matches!(kind, OP_REVISION | OP_RENAME | OP_TRASH)
}
/// 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~<uuid>` and
/// round-trips through the same `Display`/parse path as any other uid.
///
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// A mutation that has been accepted locally but not yet performed against the
/// API — the durable half of the write-back queue (offline.md Phase 3).
///
/// The daemon answers the FUSE call the moment this row and its staged blob are
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
/// behind it. That also makes an offline write succeed rather than EIO: the row
/// simply waits for the network.
#[derive(Debug, Clone)]
pub struct PendingOp {
/// Row id, `0` on a value being inserted.
pub id: i64,
/// See [`OP_REVISION`], [`OP_CREATE`], [`OP_MKDIR`].
pub kind: String,
/// Node this op targets. For [`OP_CREATE`]/[`OP_MKDIR`] this is the
/// `local~<uuid>` placeholder the node is known by until it drains.
pub uid: String,
/// Where the new node goes; only set for [`OP_CREATE`]/[`OP_MKDIR`]. May
/// itself be a placeholder when the parent folder is also still queued, in
/// which case it is rewritten when the parent drains.
pub parent_uid: Option<String>,
/// The new node's name; only set for [`OP_CREATE`]/[`OP_MKDIR`].
pub name: Option<String>,
/// Staged blob holding the bytes to upload.
pub blob_path: Option<String>,
/// Serialized [`StagedWrite`](crate::cache::StagedWrite).
pub meta_json: Option<String>,
/// When the op was queued (ms since epoch).
pub created_at: i64,
pub attempts: i64,
pub last_error: Option<String>,
/// Earliest ms at which to retry, for backoff.
pub next_attempt_at: i64,
}
/// How much work the queue owes the server, by whether it carries bytes.
#[derive(Debug, Clone, Copy, Default)]
pub struct PendingCounts {
/// Queued `create`/`revision` ops: files whose content is not on the remote.
pub uploads: i64,
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
/// told about.
pub changes: i64,
}
/// The outcome of folding freshly written bytes into a queued create.
#[derive(Debug, Clone)]
pub struct AttachedBlob {
/// Row id of the create the bytes were attached to.
pub id: i64,
/// Blob the create held before, now orphaned.
pub superseded: Option<String>,
}
impl Db {
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
let conn = self.conn.lock();
let superseded: Option<String> = if op_supersedes(&op.kind) {
let blob: Option<String> = conn
.query_row(
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
|r| r.get(0),
)
.optional()?
.flatten();
conn.execute(
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![op.uid, op.kind],
)?;
blob
} else {
None
};
conn.execute(
"INSERT INTO pending_op
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
op.kind,
op.uid,
op.parent_uid,
op.name,
op.blob_path,
op.meta_json,
op.created_at,
op.next_attempt_at
],
)?;
Ok((conn.last_insert_rowid(), superseded))
}
/// Point a queued create at the bytes that were just written to it, returning
/// any blob it previously held so the caller can discard it.
///
/// This is what `release` does for a file that only exists locally: the node
/// has no uid to hang a revision op on yet, so the bytes ride on the create.
/// Repeated writes before the drain simply replace the blob.
///
/// Returns `Ok(None)` and touches nothing if the create has already drained —
/// the caller must then queue an ordinary revision against the real uid.
pub fn attach_blob_to_create(
&self,
uid: &str,
blob_path: &str,
meta_json: &str,
) -> Result<Option<AttachedBlob>> {
let conn = self.conn.lock();
let existing: Option<(i64, Option<String>)> = conn
.query_row(
"SELECT id, blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
params![uid, OP_CREATE],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let Some((id, superseded)) = existing else {
return Ok(None);
};
conn.execute(
"UPDATE pending_op
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
WHERE id = ?1",
params![id, blob_path, meta_json],
)?;
Ok(Some(AttachedBlob { id, superseded }))
}
/// Replace the sidecar of a queued op, for a baseline that has moved under
/// it.
///
/// Exists for one caller: when *our own* upload seals a new revision while a
/// further write to the same node is already queued, that queued write's
/// [`StagedWrite::based_on`](crate::cache::StagedWrite::based_on) still
/// names the revision we just replaced. Left alone it would read as "another
/// device changed this file" and divert the write into a conflict copy over
/// nothing — a self-conflict. Restamping it is what keeps a chain of writes
/// to one file a chain rather than a pile of copies.
///
/// Deliberately narrow: only the sidecar changes, so the blob, the attempt
/// count and the backoff all survive. Returns false when no such op is
/// queued, which is the ordinary case.
pub fn update_op_meta(&self, uid: &str, kind: &str, meta_json: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET meta_json = ?3 WHERE uid = ?1 AND kind = ?2",
params![uid, kind, meta_json],
)?;
Ok(n > 0)
}
/// Point a queued create at a new parent and name, for a node renamed or
/// moved before it ever reached the server.
///
/// A `local~` uid means nothing to the API, so there is no rename call to
/// make — the node is still only a queued intent, and rewriting that intent
/// *is* the rename. Returns false when the create has already drained, in
/// which case the node has a real uid and the caller must rename it there
/// instead (offline.md Phase 3b).
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute(
"UPDATE pending_op SET parent_uid = ?2, name = ?3
WHERE uid = ?1 AND kind IN (?4, ?5)",
params![uid, parent_uid, name, OP_CREATE, OP_MKDIR],
)?;
Ok(n > 0)
}
/// Check if a queued create or mkdir op exists for `uid`.
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
params![uid, OP_CREATE, OP_MKDIR],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Drop every op targeting a node **or anything queued beneath it**,
/// returning the staged blobs they held so the caller can delete them.
///
/// Used when a node that only ever existed locally is deleted: there is
/// nothing on the server to trash, so the queued work simply stops being
/// wanted.
///
/// The descent is what keeps the queue alive. Deleting a folder created
/// offline drops the `mkdir` that would have given it a real uid, and any
/// op still queued under that placeholder is then unreachable forever: it
/// can never be attempted (its parent is a `local~` uid, so `op_is_ready`
/// refuses it) and nothing is left to remap it. It would sit in the queue,
/// and in the user's pending count, for the life of the database.
///
/// Only `create`/`mkdir` ops carry a `parent_uid`, so for a file this
/// recursion finds nothing and costs one query.
pub fn delete_ops_for_uid(&self, uid: &str) -> Result<Vec<String>> {
const SUBTREE: &str = "
WITH RECURSIVE doomed(uid) AS (
SELECT ?1
UNION
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
)";
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
let blobs: Vec<String> = {
let mut stmt = tx.prepare(&format!(
"{SUBTREE}
SELECT blob_path FROM pending_op
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
))?;
let rows = stmt.query_map(params![uid], |r| r.get::<_, String>(0))?;
rows.collect::<std::result::Result<Vec<_>, _>>()?
};
tx.execute(
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
params![uid],
)?;
tx.commit()?;
Ok(blobs)
}
/// Rewrite every queued op that points at a placeholder parent, once that
/// parent has drained and has a real uid. Also moves the node rows whose
/// parent column still names the placeholder, so listings keep resolving.
pub fn remap_local_uid(&self, local: &str, real: &str) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute(
"UPDATE pending_op SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute(
"UPDATE nodes SET parent_uid = ?2 WHERE parent_uid = ?1",
params![local, real],
)?;
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![local])?;
tx.commit()?;
Ok(())
}
/// Every queued op, oldest first. The drain worker replays them in this order
/// so a file's writes land in the order they were made.
/// The oldest queued op that is due and not blocked, or `None`.
///
/// This is the drain loop's per-iteration query, and it is deliberately not
/// `pending_ops().find(...)`. That variant read every row — including each
/// op's full `meta_json` — sorted them, and threw all but one away, once per
/// drained op *and* once per retry, while holding the single connection
/// mutex that every FUSE `lookup` also needs. A queue of a few thousand ops
/// (an ordinary offline `cp -r`) made draining quadratic in the queue length.
///
/// Both filters are in SQL so the row is decided by the database rather than
/// materialized and rejected in Rust:
///
/// * `next_attempt_at <= now` — the backoff.
/// * a `local~` parent is skipped. A node created inside a folder that was
/// itself created offline cannot be sent anywhere until that folder is
/// real. Ops replay in queue order so the parent normally drains first;
/// this matters when the parent is backing off, where the child must wait
/// rather than burn its own retries.
///
/// `id` is `INTEGER PRIMARY KEY`, i.e. the rowid, so `ORDER BY id LIMIT 1`
/// walks the table in insertion order and stops at the first match — no
/// sort, and no separate index to maintain. The common case (something is
/// due) exits on the first row.
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare_cached(&format!(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op
WHERE next_attempt_at <= ?1
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
ORDER BY id LIMIT 1",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
))?;
let op = stmt
.query_row(params![now], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})
.optional()?;
Ok(op)
}
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
/// queue is empty. Used by the drain loop to sleep exactly until the next
/// debounced or backed-off op becomes eligible rather than waiting the full
/// idle-poll interval.
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
let conn = self.conn.lock();
let ts: Option<i64> = conn
.query_row(
&format!(
"SELECT MIN(next_attempt_at) FROM pending_op \
WHERE parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~'",
v = LOCAL_VOLUME,
n = LOCAL_VOLUME.len() + 1,
),
[],
|r| r.get(0),
)
.optional()?
.flatten();
Ok(ts)
}
/// Every queued op, oldest first. For status read-outs and the CLI — the
/// drain wants [`next_due_op`](Self::next_due_op) instead.
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
attempts, last_error, next_attempt_at
FROM pending_op ORDER BY id",
)?;
let rows = stmt
.query_map([], |r| {
Ok(PendingOp {
id: r.get(0)?,
kind: r.get(1)?,
uid: r.get(2)?,
parent_uid: r.get(3)?,
name: r.get(4)?,
blob_path: r.get(5)?,
meta_json: r.get(6)?,
created_at: r.get(7)?,
attempts: r.get(8)?,
last_error: r.get(9)?,
next_attempt_at: r.get(10)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// How many queued ops there are of each kind, for `Response::Status`.
///
/// Split because "3 uploads queued" has to mean three files whose bytes are
/// not on the remote yet. A queued `mkdir`/`rename`/`trash` is also work the
/// mount owes the server, but it carries no bytes and reporting it as an
/// upload is simply untrue.
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
let conn = self.conn.lock();
let uploads = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
let changes = conn.query_row(
"SELECT COUNT(*) FROM pending_op WHERE kind NOT IN (?1, ?2)",
params![OP_REVISION, OP_CREATE],
|r| r.get(0),
)?;
Ok(PendingCounts { uploads, changes })
}
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Drop a queued op, once its upload has actually landed.
pub fn delete_op(&self, id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM pending_op WHERE id = ?1", params![id])?;
Ok(())
}
/// Record a failed attempt and when to next try. Leaves the row in place —
/// the staged bytes are still the only copy of the user's write.
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE pending_op
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
WHERE id = ?1",
params![id, error, next_attempt_at],
)?;
Ok(())
}
}
+247
View File
@@ -0,0 +1,247 @@
//! The photos timeline: a flat, date-ordered projection of the photo share,
//! persisted so the gallery opens instantly instead of re-enumerating on launch.
use rusqlite::params;
use std::collections::HashMap;
use super::Db;
use crate::Result;
/// A photo whose thumbnail state is not known yet: it has never been asked for.
pub const THUMB_UNKNOWN: i64 = 0;
/// A thumbnail exists for this photo — served by the server, or generated locally
/// from the full file when the server had none.
pub const THUMB_HAVE: i64 = 1;
/// This photo can never be given a thumbnail: the server has none and the bytes
/// could not be decoded locally either. Never retried.
pub const THUMB_NONE: i64 = 2;
/// One photo of the persisted timeline. The timeline itself is server-ordered
/// (newest first) and stored with that order in `seq`; `ratio` and `thumb_state`
/// are locally learned and survive a refresh of the timeline.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredPhoto {
pub uid: String,
pub capture_time: i64,
pub name: Option<String>,
/// Aspect ratio (w/h), known once a thumbnail has been decoded.
pub ratio: Option<f64>,
/// One of [`THUMB_UNKNOWN`] / [`THUMB_HAVE`] / [`THUMB_NONE`].
pub thumb_state: i64,
/// Which Photos-page tab this entry belongs to, derived from its name and
/// media type when the timeline was last replaced.
pub kind: crate::control::PhotoKind,
}
impl Db {
pub fn photos_replace(
&self,
items: &[(String, i64, Option<String>, Option<String>)],
) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
// timeline DTO carries only the uid and capture time, so the daemon may
// not know a photo's media type yet when it replaces the timeline. Keep
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
rows.collect::<rusqlite::Result<_>>()?
};
tx.execute("DELETE FROM photos", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO photos
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![
uid,
capture_time,
name,
ratio,
thumb_state,
seq as i64,
media_type,
kind.as_i64(),
])?;
}
}
tx.commit()?;
Ok(())
}
/// One page of the persisted timeline, newest first. `kind`, when set,
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
pub fn photos_page(
&self,
offset: usize,
limit: usize,
kind: Option<crate::control::PhotoKind>,
range: Option<(i64, i64)>,
) -> Result<Vec<StoredPhoto>> {
let conn = self.conn.lock();
// Built up so any combination of the optional filters is one indexed
// query rather than a statement per case.
let mut sql =
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
let mut binds: Vec<i64> = Vec::new();
let mut conds: Vec<String> = Vec::new();
if let Some(k) = kind {
binds.push(k.as_i64());
conds.push(format!("kind = ?{}", binds.len()));
}
if let Some((from, to)) = range {
binds.push(from);
conds.push(format!("capture_time >= ?{}", binds.len()));
binds.push(to);
conds.push(format!("capture_time < ?{}", binds.len()));
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
}
binds.push(limit as i64);
let limit_pos = binds.len();
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// The months the timeline spans, newest first, each with how many photos it
/// holds — the data behind the date scrubber. Buckets are local-time
/// `(year, month)` so they line up with the day headings the gallery draws
/// and with the boundaries a front-end computes when it jumps to one. `kind`
/// scopes the counts to one tab when set.
pub fn photos_months(
&self,
kind: Option<crate::control::PhotoKind>,
) -> Result<Vec<(i32, i32, usize)>> {
let conn = self.conn.lock();
let (filter, binds): (&str, Vec<i64>) = match kind {
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
None => ("", Vec::new()),
};
let sql = format!(
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
CAST(strftime('%m', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS m, \
COUNT(*) \
FROM photos{filter} GROUP BY y, m ORDER BY y DESC, m DESC"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok((
r.get::<_, i32>(0)?,
r.get::<_, i32>(1)?,
r.get::<_, i64>(2)? as usize,
))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
use crate::control::PhotoKind;
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
for row in rows {
let (kind, n) = row?;
match PhotoKind::from_i64(kind) {
PhotoKind::Photo => photos = n as usize,
PhotoKind::Video => videos = n as usize,
PhotoKind::Raw => raw = n as usize,
}
}
Ok((photos, videos, raw))
}
/// The stored photos for `uids`, in no particular order. Used by the thumbnail
/// path, which needs each photo's capture time (the cache validity tag) and
/// its thumbnail verdict.
pub fn photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
if uids.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let placeholders = vec!["?"; uids.len()].join(",");
let mut stmt = conn.prepare(&format!(
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
WHERE uid IN ({placeholders})"
))?;
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
Ok(StoredPhoto {
uid: r.get(0)?,
capture_time: r.get(1)?,
name: r.get(2)?,
ratio: r.get(3)?,
thumb_state: r.get(4)?,
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
})
})?;
let mut photos = Vec::new();
for row in rows {
photos.push(row?);
}
Ok(photos)
}
/// Record what a thumbnail attempt learned: whether the photo now has one
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
/// seen. A `None` ratio leaves any previously learned one alone.
pub fn photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
params![uid, state, ratio],
)?;
Ok(())
}
/// Number of photos in the persisted timeline.
pub fn photos_count(&self) -> Result<usize> {
let conn = self.conn.lock();
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
}
+121
View File
@@ -0,0 +1,121 @@
//! Pinned nodes: files and folders the user has asked to keep on disk, exempt
//! from LRU eviction. A recursive pin covers a whole subtree.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
impl Db {
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO pins (uid, path, recursive) VALUES (?1, ?2, ?3)
ON CONFLICT(uid) DO UPDATE SET
path = excluded.path, recursive = excluded.recursive",
params![uid, path, recursive as i64],
)?;
Ok(())
}
/// Drop `uid`'s pin row. Returns whether a row existed.
pub fn pin_remove(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let n = conn.execute("DELETE FROM pins WHERE uid = ?1", params![uid])?;
Ok(n > 0)
}
/// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid.
pub fn pin_list(&self) -> Result<Vec<(String, String, bool)>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)? != 0,
))
})?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Whether `uid` is pinned — directly, or because a strict ancestor folder
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
/// yet (e.g. a CLI that never hydrates the node cache).
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let direct: Option<i64> = conn
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
r.get(0)
})
.optional()?;
if direct.is_some() {
return Ok(true);
}
let anc: Option<i64> = conn
.query_row(
"WITH RECURSIVE anc(uid, parent_uid) AS (
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
)
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
WHERE a.uid != ?1 AND p.recursive = 1
LIMIT 1",
params![uid],
|r| r.get(0),
)
.optional()?;
Ok(anc.is_some())
}
/// Every uid that is pinned, directly or transitively: all direct pins plus
/// every descendant of a recursively-pinned folder. Used to build the
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
pub fn pinned_uids(&self) -> Result<Vec<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS (
SELECT uid FROM pins WHERE recursive = 1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
)
SELECT uid FROM sub
UNION
SELECT uid FROM pins",
)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"WITH RECURSIVE sub(uid) AS (
SELECT uid FROM nodes WHERE parent_uid = ?1
UNION
SELECT n.uid FROM nodes n JOIN sub ON n.parent_uid = sub.uid
)
SELECT uid FROM sub",
)?;
let rows = stmt.query_map(params![folder], |r| r.get::<_, String>(0))?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
// ---- local (non-Drive) file index (schema v6) ----
}
+97
View File
@@ -0,0 +1,97 @@
//! The `state` key/value table: small scalars that outlive a mount — the volume
//! event cursor, listing freshness stamps, the local-index generation.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
impl Db {
pub fn get_event_cursor(&self) -> Result<Option<String>> {
let conn = self.conn.lock();
let v = conn
.query_row(
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
[],
|r| r.get::<_, String>(0),
)
.optional()?;
Ok(v)
}
/// Persist the incremental-sync cursor after a batch of events is applied.
pub fn set_event_cursor(&self, cursor: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES ('event_cursor', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![cursor],
)?;
Ok(())
}
/// Read a `sync_state` value as a string.
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.lock();
let v = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get::<_, String>(0),
)
.optional()?;
Ok(v)
}
/// Write a `sync_state` string value.
pub fn set_state_str(&self, key: &str, value: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
/// Queue an op to be performed by the drain worker, returning its row id.
///
/// One *revision* op per node: a second write to the same file before the
/// first has drained replaces it, since the newer blob already contains
/// everything the older one did. The superseded blob's path is returned so
/// the caller can delete it.
///
/// Rows supersede only their own kind, and only the kinds
/// [`op_supersedes`] names. A create op for the same uid must survive —
/// dropping it would leave a file that exists nowhere but this machine with
/// nothing left to create it. (Writes to a node that is itself still queued
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
let conn = self.conn.lock();
let v: Option<String> = conn
.query_row(
"SELECT value FROM sync_state WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()?;
Ok(v.and_then(|v| v.parse().ok()))
}
/// Write a `sync_state` integer value.
pub fn set_state_i64(&self, key: &str, value: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value.to_string()],
)?;
Ok(())
}
/// Drop a `sync_state` key, so whatever it stamped counts as never fetched.
pub fn clear_state(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Folder-sync bookkeeping: the folders this device mirrors, and the per-file
//! state each pass reconciles against.
use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
use std::collections::HashMap;
pub struct StoredSyncFolder {
pub id: i64,
pub local_path: String,
pub remote_uid: String,
pub remote_share_id: String,
/// `mirror` (full local copy, two-way synced) or `ondemand` (FUSE mount).
pub mode: String,
/// A mode the user asked for that could not be applied on the spot — the
/// folder was mid-pass, or had un-uploaded changes. The engine applies it
/// once the folder is safe to switch, so the request is queued rather than
/// rejected. `None` when the folder is where the user wants it.
pub pending_mode: Option<String>,
/// `idle` | `syncing` | `error` | `conflict`.
pub state: String,
pub last_sync: i64,
}
/// One per-file sync baseline row: what a path looked like on both sides at the
/// last successful sync, so the next reconcile can tell which side changed
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
/// its modification time and size as strings — since no cheap content hash is
/// exposed; change detection is `(mtime, size)` on each side.
#[derive(Clone, Debug, PartialEq)]
pub struct StoredSyncEntry {
pub rel_path: String,
pub remote_uid: Option<String>,
pub local_mtime: i64,
pub local_size: i64,
pub remote_rev: Option<String>,
pub remote_hash: Option<String>,
}
impl Db {
pub fn sync_folder_add(
&self,
local_path: &str,
remote_uid: &str,
remote_share_id: &str,
) -> Result<i64> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_folder (local_path, remote_uid, remote_share_id)
VALUES (?1, ?2, ?3)",
params![local_path, remote_uid, remote_share_id],
)?;
Ok(conn.last_insert_rowid())
}
/// Every synced folder, oldest first.
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
FROM sync_folder ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
/// Look up one synced folder by id.
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
let conn = self.conn.lock();
conn.query_row(
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
FROM sync_folder WHERE id = ?1",
params![id],
|r| {
Ok(StoredSyncFolder {
id: r.get(0)?,
local_path: r.get(1)?,
remote_uid: r.get(2)?,
remote_share_id: r.get(3)?,
mode: r.get(4)?,
pending_mode: r.get(5)?,
state: r.get(6)?,
last_sync: r.get(7)?,
})
},
)
.optional()
.map_err(Into::into)
}
/// Remove a synced folder and its per-file baseline.
pub fn sync_folder_remove(&self, id: i64) -> Result<bool> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM sync_entry WHERE folder_id = ?1", params![id])?;
let n = tx.execute("DELETE FROM sync_folder WHERE id = ?1", params![id])?;
tx.commit()?;
Ok(n > 0)
}
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
/// reached the mode it was asked for, so any queued request is satisfied and
/// cleared in the same write — a `pending_mode` outliving the switch it asked
/// for would have the engine try to apply it again on the next pass.
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
params![id, mode],
)?;
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<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET pending_mode = ?2 WHERE id = ?1",
params![id, mode],
)?;
Ok(())
}
/// Update a synced folder's state and stamp `last_sync` to now.
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"UPDATE sync_folder SET state = ?2, last_sync = ?3 WHERE id = ?1",
params![id, state, last_sync],
)?;
Ok(())
}
/// The whole per-file sync baseline for a folder, keyed by relative path.
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
FROM sync_entry WHERE folder_id = ?1",
)?;
let rows = stmt.query_map(params![folder_id], |r| {
Ok(StoredSyncEntry {
rel_path: r.get(0)?,
remote_uid: r.get(1)?,
local_mtime: r.get(2)?,
local_size: r.get(3)?,
remote_rev: r.get(4)?,
remote_hash: r.get(5)?,
})
})?;
let mut map = HashMap::new();
for row in rows {
let e = row?;
map.insert(e.rel_path.clone(), e);
}
Ok(map)
}
/// Insert or replace one baseline row.
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"INSERT INTO sync_entry
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
remote_uid = excluded.remote_uid,
local_mtime = excluded.local_mtime,
local_size = excluded.local_size,
remote_rev = excluded.remote_rev,
remote_hash = excluded.remote_hash",
params![
folder_id,
e.rel_path,
e.remote_uid,
e.local_mtime,
e.local_size,
e.remote_rev,
e.remote_hash,
],
)?;
Ok(())
}
/// Drop one baseline row (a path that left the sync set on both sides).
pub fn sync_entry_remove(&self, folder_id: i64, rel_path: &str) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1 AND rel_path = ?2",
params![folder_id, rel_path],
)?;
Ok(())
}
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
/// the local tree was evicted, so the old baseline is stale and would make the
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
/// leaves an empty baseline + full remote = pure download (devices.md P3).
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
let conn = self.conn.lock();
conn.execute(
"DELETE FROM sync_entry WHERE folder_id = ?1",
params![folder_id],
)?;
Ok(())
}
}
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
//! Trashed nodes, persisted so the trash view opens without a round-trip.
use rusqlite::params;
use super::Db;
use crate::Result;
pub struct StoredTrash {
pub uid: String,
pub name: String,
pub is_dir: bool,
pub size: i64,
pub mtime: i64,
}
impl Db {
pub fn trash_replace(&self, items: &[StoredTrash]) -> Result<()> {
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
tx.execute("DELETE FROM trash", [])?;
{
let mut stmt = tx.prepare(
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
for item in items {
stmt.execute(params![
item.uid,
item.name,
item.is_dir as i64,
item.size,
item.mtime
])?;
}
}
tx.commit()?;
Ok(())
}
/// The persisted trash listing, folders first then by name — the order the
/// Trash page shows it in.
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
let conn = self.conn.lock();
let mut stmt = conn.prepare(
"SELECT uid, name, is_dir, size, mtime FROM trash
ORDER BY is_dir DESC, name COLLATE NOCASE",
)?;
let rows = stmt.query_map([], |r| {
Ok(StoredTrash {
uid: r.get(0)?,
name: r.get(1)?,
is_dir: r.get::<_, i64>(2)? != 0,
size: r.get(3)?,
mtime: r.get(4)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
// ---- device sync (devices.md) -----------------------------------------
}
+59
View File
@@ -0,0 +1,59 @@
//! Small query helpers shared across the table modules.
use rusqlite::{Connection, params};
use crate::Result;
/// Below this length the trigram tokenizer indexes nothing (it needs 3-char
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
pub(super) const TRIGRAM_MIN: usize = 3;
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
Ok((row.get(0)?, row.get(1)?))
}
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
pub(super) fn collect_pairs(
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
/// Escape `LIKE` wildcards in a user query so `%` and `_` match literally
/// (paired with `ESCAPE '\'` in the statement).
pub(super) fn like_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
let mut stmt = conn.prepare(
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
)
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
)?;
let rows = stmt.query_map(params![uid], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
})?;
let mut parts = Vec::new();
for r in rows {
let (name, parent_uid) = r?;
// Skip the root node (no parent); its name is the mount itself.
if parent_uid.is_some() {
parts.push(name);
}
}
Ok(parts.join("/"))
}
+266
View File
@@ -1,7 +1,10 @@
//! Crate-wide error type.
use proton_sdk::api::HumanVerification;
use thiserror::Error;
use crate::control::ErrorKind;
#[derive(Debug, Error)]
pub enum Error {
#[error("proton api: {0}")]
@@ -29,8 +32,271 @@ pub enum Error {
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
ReloginRequired,
/// 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)).
///
/// Carries the challenge rather than just a message so a front-end can
/// present it; a front-end that cannot (no webview) still has prose to show.
#[error("sign-in needs human verification — complete the CAPTCHA to continue")]
HumanVerificationRequired(Box<HumanVerification>),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// A failure on its way to a front-end: prose for the user, plus the
/// [`ErrorKind`] the front-end actually branches on.
///
/// This is what the daemon's request-serving methods return, as opposed to
/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The
/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no
/// information a user can act on, so it arrives here as
/// [`ErrorKind::Internal`] and keeps its text only for the log.
///
/// Classification happens where the failure is *understood* — at the API
/// boundary that knows a 404 from a 403, at the path resolver that knows an
/// `ENOENT` — never at the call site, which by then has only a string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreError {
pub kind: ErrorKind,
pub message: String,
}
impl CoreError {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
/// The API is unreachable. Carries fixed wording because there is only ever
/// one thing to say about it.
pub fn offline() -> Self {
Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable")
}
pub fn not_found(what: impl Into<String>) -> Self {
Self::new(ErrorKind::NotFound, what)
}
pub fn denied(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Denied, what)
}
pub fn conflict(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Conflict, what)
}
pub fn invalid(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Invalid, what)
}
pub fn remote(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Remote, what)
}
pub fn internal(what: impl Into<String>) -> Self {
Self::new(ErrorKind::Internal, what)
}
/// Classify a failure from an SDK call, prefixing `what` for the log.
///
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
/// through the boxes the drain deals in, where the concrete type survives
/// but the static one does not.
///
/// A failure that is not an API response at all — a transport error, a
/// timeout, a TLS failure — is what being offline looks like from here, so
/// it classifies as [`ErrorKind::Offline`] rather than
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
/// user their connection is down beats telling them the server refused.
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that is not an `Api` variant never reached the
// API; so does anything that is not a `ProtonError` at all.
_ => ErrorKind::Offline,
};
Self::new(kind, format!("{what}: {e}"))
}
/// Prefix the message while keeping the classification.
///
/// For adding the caller's context to an already-classified failure — the
/// kind was decided by whoever understood the error and must survive being
/// passed up.
pub fn context(mut self, what: &str) -> Self {
self.message = format!("{what}: {}", self.message);
self
}
}
impl std::fmt::Display for CoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CoreError {}
impl From<rusqlite::Error> for CoreError {
fn from(e: rusqlite::Error) -> Self {
Self::internal(format!("database: {e}"))
}
}
impl From<std::io::Error> for CoreError {
fn from(e: std::io::Error) -> Self {
Self::internal(format!("io: {e}"))
}
}
impl From<serde_json::Error> for CoreError {
fn from(e: serde_json::Error) -> Self {
Self::internal(format!("serialization: {e}"))
}
}
impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired | 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,
_ => ErrorKind::Internal,
};
Self::new(kind, e.to_string())
}
}
/// What an API response code means to a front-end.
///
/// The grouping is by *what the user can do about it*, which is what
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
/// `NotEnoughPermissions` land together despite being different numbers, while
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
use proton_sdk::api::ResponseCode as C;
match code {
// The account may not do this, and repeating it will not help.
C::Unauthorized
| C::Forbidden
| C::NotEnoughPermissions
| C::NotEnoughPermissionsToGrantPermissions
| C::InsufficientScope
| C::IncorrectLoginCredentials
| C::InvalidRefreshToken
| C::AccountDeleted
| C::AccountDisabled
| C::NoActiveSubscription => ErrorKind::Denied,
C::DoesNotExist => ErrorKind::NotFound,
// The remote moved underneath the request; the caller has to decide.
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
// The request was malformed or asks for something structurally
// impossible. A bug in the caller, not a condition to retry.
C::InvalidRequirements
| C::InvalidValue
| C::InvalidEncryptedIdFormat
| C::InvalidApp
| C::TooManyChildren
| C::NestingTooDeep => ErrorKind::Invalid,
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
ErrorKind::Quota
}
// Reached Proton, but it could not answer *now*. Retrying is exactly
// the right response, so these are Offline rather than Remote — the
// front-end already words that as "not right now".
C::RequestTimeout
| C::Timeout
| C::TooManyRequests
| C::ServiceUnavailable
| C::Offline => ErrorKind::Offline,
// Reached Proton and it refused for a reason we have no specific
// handling for. Retryable, because an unchanged retry can legitimately
// succeed against a transient server-side condition.
_ => ErrorKind::Remote,
}
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::api::ResponseCode as C;
use proton_sdk::{ProtonApiError, ProtonError};
fn api(code: C) -> ProtonError {
ProtonError::Api(ProtonApiError {
code,
http_status: 422,
message: "nope".into(),
details: None,
})
}
#[test]
fn api_codes_classify_by_what_the_user_can_do() {
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
// Nothing specific known about it, but Proton did answer.
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
}
/// 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.
#[test]
fn a_forbidden_response_is_not_offered_as_retryable() {
let e = CoreError::from_api(&api(C::Forbidden), "rename");
assert_eq!(e.kind, ErrorKind::Denied);
assert!(!e.kind.retryable());
assert!(e.message.starts_with("rename: "));
}
/// Being full is not a transient condition, so retrying an upload that did
/// not fit must not be offered as a fix.
#[test]
fn running_out_of_space_is_not_retryable() {
assert!(
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
.kind
.retryable()
);
}
/// A failure that never reached the API is the user's connection, not
/// Proton refusing — the front-end words those differently.
#[test]
fn a_transport_failure_reads_as_offline() {
let io = std::io::Error::other("connection reset");
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
}
/// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure.
#[test]
fn converting_from_the_plumbing_error_classifies_the_api_code() {
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
assert_eq!(e.kind, ErrorKind::NotFound);
}
}
+4 -1
View File
@@ -9,10 +9,13 @@ pub mod control;
pub mod db;
pub mod error;
pub mod localindex;
pub mod profile;
pub mod search;
pub mod service;
pub mod shell;
pub mod syncignore;
pub use error::{Error, Result};
pub use error::{CoreError, CoreResult, Error, Result};
// Re-export the SDK surface downstream crates need, so they depend on
// `pdfs-core` rather than pinning the SDK directly.
+3 -3
View File
@@ -93,7 +93,7 @@ pub fn scan(
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
});
let state = std::sync::Mutex::new(SinkState {
let state = parking_lot::Mutex::new(SinkState {
buf: Vec::with_capacity(BATCH),
total: 0,
sink,
@@ -112,7 +112,7 @@ pub fn scan(
return WalkState::Continue;
};
let mut state = state.lock().unwrap();
let mut state = state.lock();
state.push(local);
if state.total >= MAX_ENTRIES {
return WalkState::Quit;
@@ -121,7 +121,7 @@ pub fn scan(
})
});
let mut state = state.into_inner().unwrap();
let mut state = state.into_inner();
state.flush();
state.total
}
+171
View File
@@ -0,0 +1,171 @@
//! The machine profile that travels with a device (features.md 5.3).
//!
//! Sync folder mappings, pins, and the settings that shape them live in
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
//! the remote data survives but the *arrangement* of it does not: which folders
//! were synced, where they lived locally, which were on-demand, what was pinned.
//!
//! So the daemon mirrors that arrangement into a `profile.json` in its device's
//! root folder on Drive. It goes up the normal upload path, which means it is
//! end-to-end encrypted with the account keys like any other file — 5.3's
//! "encrypt with the user's Proton keys" is satisfied by *not* special-casing
//! it. It lives under the device rather than the Drive root because it describes
//! that device; adopting the device is what makes it relevant.
//!
//! This module is pure data. The upload/download and the restore flow live in
//! `pdfs-fuse`, next to the client that performs them.
use serde::{Deserialize, Serialize};
/// File name of the profile inside the device root folder.
pub const PROFILE_FILE_NAME: &str = "profile.json";
/// Format version of the profile document.
///
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
/// qualify — unknown fields are ignored on the way in, so an older client reads
/// a newer profile's common subset. A newer `version` than this is refused
/// outright rather than half-applied: a restore that silently drops folders it
/// did not understand is worse than one that tells you to upgrade.
pub const PROFILE_VERSION: u32 = 1;
/// One synced folder, as it was arranged on the machine that wrote the profile.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProfileFolder {
/// Uid of the folder's remote root, under the device root. The stable
/// identity — the local path may not exist on the restoring machine, but
/// this does.
pub remote_uid: String,
/// Absolute local path it was synced to. A *suggestion* on restore: another
/// machine may have a different username, home, or mount layout.
pub local_path: String,
/// `mirror` or `ondemand`.
pub mode: String,
}
/// One pinned node (P5 pins), as `pins` stores it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProfilePin {
pub uid: String,
/// Mountpoint-relative path, kept for display: a pin whose node has been
/// deleted should still be nameable in a restore summary.
pub path: String,
pub recursive: bool,
}
/// The full arrangement of one machine.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Profile {
pub version: u32,
/// The device this profile describes.
pub device_uid: String,
/// Hostname of the machine that wrote it, so a restore prompt can say
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
pub hostname: String,
/// When it was written, epoch seconds.
pub saved_at: i64,
#[serde(default)]
pub folders: Vec<ProfileFolder>,
#[serde(default)]
pub pins: Vec<ProfilePin>,
/// Global sync ignore patterns. Safe to restore as-is.
#[serde(default)]
pub ignore_patterns: Option<Vec<String>>,
/// Machine-shaped settings. Carried, but never applied without asking: a
/// laptop's 5 GB cache budget and `~/ProtonDrive` mountpoint are not
/// obviously right on the next machine.
#[serde(default)]
pub cache_budget: Option<u64>,
#[serde(default)]
pub mountpoint: Option<String>,
}
impl Profile {
/// Parse a profile document, refusing one written by a newer client.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
let profile: Profile =
serde_json::from_slice(bytes).map_err(|e| format!("malformed profile.json: {e}"))?;
if profile.version > PROFILE_VERSION {
return Err(format!(
"profile.json was written by a newer client (version {}, this client understands {PROFILE_VERSION}); upgrade to restore it",
profile.version
));
}
Ok(profile)
}
/// Serialize for upload. Pretty-printed: it is a small file a user may well
/// open in the web UI to see what their machine had.
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(self).map_err(|e| format!("serialize profile: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Profile {
Profile {
version: PROFILE_VERSION,
device_uid: "dev-1".to_string(),
hostname: "laptop-xyz".to_string(),
saved_at: 1_700_000_000,
folders: vec![ProfileFolder {
remote_uid: "vol~link".to_string(),
local_path: "/home/nils/Documents".to_string(),
mode: "mirror".to_string(),
}],
pins: vec![ProfilePin {
uid: "vol~pin".to_string(),
path: "Photos/2024".to_string(),
recursive: true,
}],
ignore_patterns: Some(vec!["target/".to_string()]),
cache_budget: Some(5_000_000_000),
mountpoint: Some("/home/nils/ProtonDrive".to_string()),
}
}
#[test]
fn round_trips() {
let p = sample();
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
}
/// A field added by a later client must not break this one's restore: the
/// common subset is still usable, which is the whole reason `version` is
/// reserved for breaking changes only.
#[test]
fn unknown_fields_are_ignored() {
let json = br#"{
"version": 1,
"device_uid": "dev-1",
"hostname": "laptop-xyz",
"saved_at": 1,
"folders": [],
"something_from_the_future": {"a": 1}
}"#;
let p = Profile::parse(json).unwrap();
assert_eq!(p.device_uid, "dev-1");
assert!(p.folders.is_empty());
}
/// Missing optional sections read as empty rather than failing: a profile
/// written before pins existed is still a valid folder mapping.
#[test]
fn absent_sections_default() {
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
let p = Profile::parse(json).unwrap();
assert!(p.pins.is_empty());
assert!(p.ignore_patterns.is_none());
}
#[test]
fn future_version_is_refused() {
let mut p = sample();
p.version = PROFILE_VERSION + 1;
let err = Profile::parse(&p.to_bytes().unwrap()).unwrap_err();
assert!(err.contains("newer client"), "{err}");
}
}
+214
View File
@@ -0,0 +1,214 @@
//! Shared, deterministic search-result relevance scoring.
//!
//! The scorer deliberately operates on a small candidate set rather than
//! replacing an index. Callers should use SQLite (or another index) to find
//! candidates, then use this module to put local and remote results on the
//! same relevance scale.
/// Scores a candidate against a user query.
///
/// Every query term must match either `basename` or `parent_path`. Basename
/// matches are weighted twice as strongly as path matches. The returned score
/// is stable and only meaningful when compared with scores produced by this
/// function.
#[must_use]
pub fn relevance_score(query: &str, basename: &str, parent_path: &str) -> Option<i64> {
let query = normalize(query);
if query.is_empty() {
return Some(0);
}
let name = normalize(basename);
let path = normalize(parent_path);
let query_terms: Vec<_> = query.split_whitespace().collect();
let name_words: Vec<_> = name.split_whitespace().collect();
let path_words: Vec<_> = path.split_whitespace().collect();
let mut total = 0_i64;
for term in query_terms {
let name_score = field_term_score(term, &name, &name_words).map(|score| score * 2);
let path_score = field_term_score(term, &path, &path_words);
total += name_score.into_iter().chain(path_score).max()?;
}
// Whole-field bonuses make ordering intuitive without changing whether a
// candidate matches. They dominate small differences between term scores.
if name == query {
total += 20_000;
} else if name.starts_with(&query) {
total += 10_000;
} else if name.contains(&query) {
total += 4_000;
}
Some(total)
}
fn normalize(value: &str) -> String {
let mut normalized = String::with_capacity(value.len());
let mut last_was_space = true;
for character in value.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
normalized.push(character);
last_was_space = false;
} else if !last_was_space {
normalized.push(' ');
last_was_space = true;
}
}
if last_was_space {
normalized.pop();
}
normalized
}
fn field_term_score(term: &str, field: &str, words: &[&str]) -> Option<i64> {
if field == term {
return Some(10_000);
}
if field.starts_with(term) {
return Some(8_500);
}
if words.contains(&term) {
return Some(8_000);
}
if words.iter().any(|word| word.starts_with(term)) {
return Some(7_000);
}
if field.contains(term) {
return Some(6_000);
}
// Subsequence matching supports useful abbreviations such as "qtrrpt".
let subsequence = std::iter::once(field)
.chain(words.iter().copied())
.filter_map(|word| subsequence_gap(term, word))
.min();
if let Some(gap) = subsequence {
return Some(4_500 - i64::try_from(gap.min(500)).unwrap_or(500));
}
// Typo matching is deliberately restricted to substantial terms to avoid
// noisy matches for one- and two-letter searches.
if term.chars().count() >= 4
&& words
.iter()
.any(|word| bounded_damerau_levenshtein(term, word, typo_limit(term)))
{
return Some(3_500);
}
None
}
fn subsequence_gap(needle: &str, haystack: &str) -> Option<usize> {
let needle_length = needle.chars().count();
let mut needle = needle.chars();
let mut wanted = needle.next()?;
let mut first = None;
for (index, character) in haystack.chars().enumerate() {
if character == wanted {
first.get_or_insert(index);
match needle.next() {
Some(next) => wanted = next,
None => return Some(index + 1 - first.unwrap_or(index) - needle_length),
}
}
}
None
}
fn typo_limit(term: &str) -> usize {
usize::from(term.chars().count() >= 5) + 1
}
/// A bounded optimal-string-alignment distance check. This treats a single
/// adjacent transposition (for example, `vidoe`) as one edit.
fn bounded_damerau_levenshtein(left: &str, right: &str, limit: usize) -> bool {
let left: Vec<_> = left.chars().collect();
let right: Vec<_> = right.chars().collect();
if left.len().abs_diff(right.len()) > limit || left.len().max(right.len()) > 64 {
return false;
}
let mut distances = vec![vec![0_usize; right.len() + 1]; left.len() + 1];
for (index, row) in distances.iter_mut().enumerate() {
row[0] = index;
}
for (index, distance) in distances[0].iter_mut().enumerate() {
*distance = index;
}
for i in 1..=left.len() {
for j in 1..=right.len() {
let cost = usize::from(left[i - 1] != right[j - 1]);
distances[i][j] = (distances[i - 1][j] + 1)
.min(distances[i][j - 1] + 1)
.min(distances[i - 1][j - 1] + cost);
if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] {
distances[i][j] = distances[i][j].min(distances[i - 2][j - 2] + 1);
}
}
}
distances[left.len()][right.len()] <= limit
}
#[cfg(test)]
mod tests {
use super::relevance_score;
#[test]
fn exact_name_beats_prefix_and_substring() {
let exact = relevance_score("report", "report", "work").unwrap();
let prefix = relevance_score("report", "report final", "work").unwrap();
let substring = relevance_score("report", "annual report", "work").unwrap();
assert!(exact > prefix);
assert!(prefix > substring);
}
#[test]
fn basename_is_weighted_above_parent_path() {
let name = relevance_score("budget", "budget notes", "archive").unwrap();
let path = relevance_score("budget", "notes", "work/budget").unwrap();
assert!(name > path);
}
#[test]
fn matches_typos_and_adjacent_transpositions() {
assert!(relevance_score("vedio", "video.mp4", "media").is_some());
assert!(relevance_score("vidoe", "video.mp4", "media").is_some());
assert!(relevance_score("presentaton", "presentation.pdf", "work").is_some());
}
#[test]
fn separators_form_searchable_word_boundaries() {
assert!(relevance_score("final draft", "Final_Draft-v2.pdf", "work").is_some());
assert!(relevance_score("quarter report", "quarterly-report.pdf", "work").is_some());
}
#[test]
fn unicode_case_folding_preserves_accented_matches() {
assert!(relevance_score("CAFÉ", "Café Photos", "Trips").is_some());
assert!(relevance_score("ångström", "ÅNGSTRÖM notes", "Research").is_some());
}
#[test]
fn matches_terms_across_name_and_path() {
assert!(relevance_score("tax invoice", "invoice-1042.pdf", "archive/tax/2025").is_some());
}
#[test]
fn ordered_subsequence_supports_abbreviations() {
assert!(relevance_score("qtrrpt", "quarter-report.pdf", "finance").is_some());
}
#[test]
fn rejects_unrelated_candidates_and_short_typos() {
assert_eq!(relevance_score("video", "holiday.jpg", "photos"), None);
assert_eq!(relevance_score("xy", "xz.txt", "misc"), None);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
//!
//! A mirror folder otherwise uploads every path beneath its root, which for a
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
//! spent on files nobody wants on Drive, and enough API traffic to invite rate
//! limiting.
//!
//! Rules come from two places, unioned:
//!
//! * `.pdfsignore` (or `.protonignore`) at the root of the synced folder,
//! gitignore syntax.
//! * A global pattern list from the config, applied to every synced folder.
//!
//! Both are read fresh at the start of each reconcile pass, so editing the file
//! takes effect on the next pass without restarting the daemon.
//!
//! # Ignoring is not deleting
//!
//! A path that becomes ignored after it was already synced keeps its remote
//! copy and its baseline row. The sync engine drops ignored paths from
//! classification entirely rather than treating them as locally deleted —
//! see the union filter in `pdfs-fuse`'s `do_reconcile`. Editing a text file
//! must never trash data on Drive.
use std::path::Path;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
/// Patterns applied to every synced folder when the config does not override
/// them. Version-control metadata, dependency and build trees, editor
/// leftovers, and OS turds — none of which belong on Drive, all of which are
/// large or rewritten constantly.
pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
".git/",
".hg/",
".svn/",
"node_modules/",
"target/",
".venv/",
"__pycache__/",
"*~",
"*.swp",
"*.tmp",
".DS_Store",
"Thumbs.db",
];
/// Filenames accepted as a folder's ignore file, in precedence order.
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
/// Compiled ignore rules for one synced folder.
pub struct IgnoreRules {
matcher: Option<Gitignore>,
}
impl IgnoreRules {
/// Build the rules for a folder rooted at `root`: `globals` first, then any
/// ignore file found at the root, so a folder-local rule can negate a
/// global one with `!pattern`.
///
/// Malformed patterns are skipped individually rather than failing the
/// load — a typo in `.pdfsignore` must not take the folder's sync down.
pub fn load(root: &Path, globals: &[String]) -> Self {
let mut builder = GitignoreBuilder::new(root);
let mut any = false;
for pattern in globals {
if builder.add_line(None, pattern).is_ok() {
any = true;
}
}
for name in IGNORE_FILE_NAMES {
let path = root.join(name);
if !path.is_file() {
continue;
}
// `add` returns the first parse error but still takes the lines it
// could read, so a bad line costs that line and nothing more.
let _ = builder.add(&path);
any = true;
break;
}
if !any {
return Self { matcher: None };
}
Self {
matcher: builder.build().ok(),
}
}
/// Rules matching nothing, for callers with no folder context.
pub fn empty() -> Self {
Self { matcher: None }
}
/// Whether any rule is loaded. When false, [`is_ignored`](Self::is_ignored)
/// is always false and callers can skip filtering entirely.
pub fn is_empty(&self) -> bool {
self.matcher.is_none()
}
/// Whether `rel` — a `/`-joined path relative to the folder root — is
/// ignored.
///
/// A path under an ignored directory is ignored too. `Gitignore` matches
/// one path against one rule set and does not walk parents itself, so each
/// ancestor is tested as a directory. This is what makes filtering the
/// classification union sound: the engine can ask about any path in
/// isolation, in any order, without having walked its parents first.
pub fn is_ignored(&self, rel: &str, is_dir: bool) -> bool {
let Some(matcher) = &self.matcher else {
return false;
};
if rel.is_empty() {
return false;
}
let mut prefix = String::new();
let mut parts = rel.split('/').peekable();
while let Some(part) = parts.next() {
if !prefix.is_empty() {
prefix.push('/');
}
prefix.push_str(part);
let last = parts.peek().is_none();
// Every ancestor is a directory; only the final component's kind
// is the caller's to say.
let as_dir = if last { is_dir } else { true };
if matcher.matched(&prefix, as_dir).is_ignore() {
return true;
}
}
false
}
}
impl std::fmt::Debug for IgnoreRules {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IgnoreRules")
.field("active", &!self.is_empty())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
/// [`crate::cache`]'s tests.
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-ignore-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn globals() -> Vec<String> {
DEFAULT_IGNORE_PATTERNS
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn write(root: &Path, name: &str, body: &str) {
std::fs::write(root.join(name), body).unwrap();
}
#[test]
fn no_rules_at_all_ignores_nothing() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_empty());
assert!(!rules.is_ignored("anything", false));
assert!(!rules.is_ignored("deep/nested/path.rs", false));
}
#[test]
fn global_defaults_match_without_an_ignore_file() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("node_modules", true));
assert!(rules.is_ignored("target", true));
assert!(rules.is_ignored("notes.txt~", false));
assert!(!rules.is_ignored("src/main.rs", false));
}
#[test]
fn a_path_under_an_ignored_directory_is_ignored() {
let dir = TempDir::new();
let rules = IgnoreRules::load(dir.path(), &globals());
// The engine asks about deep paths in isolation, so each must answer
// correctly without its parents having been tested first.
assert!(rules.is_ignored("node_modules/left-pad/index.js", false));
assert!(rules.is_ignored("node_modules/left-pad", true));
assert!(rules.is_ignored("target/debug/build/x/out", false));
}
#[test]
fn a_folder_ignore_file_adds_to_the_globals() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "secrets/\n*.log\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("secrets/key.pem", false));
assert!(rules.is_ignored("run.log", false));
// Globals still apply alongside the file.
assert!(rules.is_ignored("node_modules", true));
assert!(!rules.is_ignored("run.txt", false));
}
#[test]
fn a_folder_rule_can_negate_a_global_one() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "!keep.tmp\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("scratch.tmp", false));
assert!(!rules.is_ignored("keep.tmp", false));
}
#[test]
fn protonignore_is_accepted_as_an_alias() {
let dir = TempDir::new();
write(dir.path(), ".protonignore", "build/\n");
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_ignored("build/out.o", false));
}
#[test]
fn pdfsignore_wins_when_both_files_exist() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "from-pdfs/\n");
write(dir.path(), ".protonignore", "from-proton/\n");
let rules = IgnoreRules::load(dir.path(), &[]);
assert!(rules.is_ignored("from-pdfs/x", false));
assert!(!rules.is_ignored("from-proton/x", false));
}
#[test]
fn a_malformed_pattern_does_not_take_the_rest_down() {
let dir = TempDir::new();
// An unclosed character class is not a valid glob.
write(dir.path(), ".pdfsignore", "[unclosed\nvalid/\n");
let rules = IgnoreRules::load(dir.path(), &globals());
assert!(rules.is_ignored("valid/x", false));
assert!(rules.is_ignored("node_modules", true));
}
#[test]
fn the_root_itself_is_never_ignored() {
let dir = TempDir::new();
write(dir.path(), ".pdfsignore", "*\n");
let rules = IgnoreRules::load(dir.path(), &[]);
// A blanket rule must not classify the folder root, or the pass would
// have nothing to reconcile against.
assert!(!rules.is_ignored("", true));
}
}
+3
View File
@@ -15,3 +15,6 @@ tracing.workspace = true
fuser.workspace = true
libc.workspace = true
serde_json.workspace = true
image.workspace = true
notify = "8"
parking_lot.workspace = true
+268
View File
@@ -0,0 +1,268 @@
//! Remote-event invalidation and local-index background workers.
use super::*;
/// Apply one remote event to the local cache and notify the kernel so it drops
/// any stale cached metadata/data for the affected inodes.
///
/// The cache is authoritative-by-absence: dropping a directory's `children`
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
/// most events only need to invalidate listings rather than re-fetch eagerly.
fn apply_event(
state: &Mutex<State>,
content: &ContentCache,
pending: &Mutex<HashMap<NodeUid, PendingRevision>>,
notifier: &Notifier,
event: &DriveEvent,
) {
match event {
DriveEvent::NodeUpdated {
node_uid,
parent_node_uid,
is_trashed,
..
} => {
let mut st = state.lock();
if *is_trashed {
// Trashing makes a node vanish from its parent listing.
let child = st.by_uid.get(node_uid).copied();
if let Some((parent, name)) = st.forget(node_uid) {
content.evict(node_uid);
match child {
Some(child) => {
let _ =
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
}
} else if pending.lock().contains_key(node_uid) {
// A node we owe an upload for is *ahead* of the remote, not
// behind it: this event is almost always the echo of our own
// empty-file create, and re-fetching would replace the size and
// mtime of the write we just accepted with the stale revision's
// — making a file that was copied in seconds ago read as empty
// until its upload lands (offline.md Phase 3).
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
} else if let Some(&ino) = st.by_uid.get(node_uid) {
// Known node changed: drop its cached attrs/data (and listing if
// it is a directory) so the next access re-fetches. Its content
// blob may now be stale, so evict it too.
st.invalidate_listing(ino);
content.evict(node_uid);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
// A create (or move-in) shows up as a change to the parent listing;
// drop it so the new child is picked up on the next readdir.
if let Some(parent_uid) = parent_node_uid
&& let Some(&parent) = st.by_uid.get(parent_uid)
{
st.invalidate_listing(parent);
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
}
}
DriveEvent::NodeDeleted { node_uid, .. } => {
let mut st = state.lock();
// Capture the inode before `forget` clears the uid mapping.
let child = st.by_uid.get(node_uid).copied();
content.evict(node_uid);
if let Some((parent, name)) = st.forget(node_uid) {
match child {
Some(child) => {
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
}
None => {
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
}
}
}
}
// Continuity or scope was lost: our cached listings may be arbitrarily
// stale, so drop every listing and tell the kernel to forget all
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
let mut st = state.lock();
let dirs: Vec<u64> = st.children.keys().copied().collect();
for &ino in &dirs {
st.invalidate_listing(ino);
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
}
}
// No substantive local change; the cursor advance is handled by the
// caller persisting the event id.
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
}
}
/// Poll the remote event cursor forever, applying each batch to the shared
/// state. Resumes from the cursor persisted in the DB so changes made while
/// unmounted are applied; only a first-ever mount seeds from the server head.
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
/// only on fatal error.
pub(super) async fn run_event_sync(
client: ProtonDriveClient,
scope: DriveEventScopeId,
state: Arc<Mutex<State>>,
content: Arc<ContentCache>,
db: Arc<Db>,
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
notifier: Notifier,
) {
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
// Resume: pick up exactly where the last run left off.
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
// First mount: a `None` cursor yields a single `CursorAdvanced` at the
// server head; persist it so the next restart resumes instead of
// reseeding (which would skip everything that changed offline).
// Seeding needs the network, and this task also runs on mounts that
// started offline (offline.md Phase 1) — so retry rather than giving up,
// which used to disable live sync for the life of the daemon.
Ok(None) => {
let mut delay = ONLINE_PROBE_MIN;
loop {
match client.enumerate_events(&scope, None).await {
Ok(events) => {
let head = events.last().map(|e| e.id().clone());
if let Some(c) = &head
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist seed cursor failed");
}
break head;
}
Err(e) => {
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
tokio::time::sleep(delay).await;
delay = (delay * 2).min(ONLINE_PROBE_MAX);
}
}
}
}
Err(e) => {
error!(error = %e, "read persisted cursor failed; live sync disabled");
return;
}
};
info!(?cursor, "event sync started");
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let events = match client.enumerate_events(&scope, cursor.as_ref()).await {
Ok(events) => events,
Err(e) => {
warn!(error = %e, "event poll failed; retrying after interval");
continue;
}
};
if events.is_empty() {
continue;
}
debug!(count = events.len(), "applying remote events");
for event in &events {
// Converge the SDK's own caches (folder keys, entity cache) on the
// server before applying the event to our tree. Without this, a node
// re-keyed/moved by another client keeps a stale key in the SDK for
// the life of the daemon (SDK plan #9). `apply_event` only touches
// our FUSE state, so nothing else does this.
if let Err(e) = client.invalidate_caches_for_event(event).await {
warn!(error = %e, "sdk cache invalidation for event failed");
}
apply_event(&state, &content, &pending, &notifier, event);
}
cursor = events.last().map(|e| e.id().clone());
if let Some(c) = &cursor
&& let Err(e) = db.set_event_cursor(c.as_str())
{
warn!(error = %e, "persist event cursor failed");
}
}
}
/// Keep the local-file index fresh for the launcher prompt's "This computer"
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
/// then sleeps; runs on its own thread for the life of the daemon.
///
/// The walk is the one part of the daemon that touches the wider filesystem, so
/// it is deliberately kept off every hot path: it never runs on a FUSE or
/// control-socket thread, and it excludes the mountpoint (walking it would fault
/// every remote node in through FUSE, defeating on-demand hydration).
pub(super) fn run_local_index(
db: Arc<Db>,
indexing: Arc<AtomicBool>,
transfers: Arc<TransferRegistry>,
mountpoint: PathBuf,
) {
loop {
let age = db.local_indexed_at().ok().flatten();
let stale =
age.is_none_or(|at| now_secs().saturating_sub(at) >= LOCAL_INDEX_TTL.as_secs() as i64);
if stale {
scan_local_once(&db, &indexing, &transfers, &mountpoint);
}
std::thread::sleep(LOCAL_INDEX_CHECK);
}
}
/// Walk `$HOME` once and replace the local-file index with what it finds.
/// Batches stream straight into SQLite, so peak memory is one batch — not the
/// whole home directory.
///
/// Reports itself as a job: the first scan after a fresh install walks the whole
/// home directory, and `indexing` alone only tells the launcher prompt to say
/// "still indexing" — nothing else showed that the daemon was busy.
fn scan_local_once(
db: &Db,
indexing: &AtomicBool,
transfers: &Arc<TransferRegistry>,
mountpoint: &Path,
) {
let dirs = match AppDirs::new() {
Ok(d) => d,
Err(e) => {
warn!(error = %e, "local index: cannot resolve app dirs");
return;
}
};
let Some(home) = dirs.home_dir() else {
warn!("local index: cannot resolve home directory");
return;
};
let generation = match db.local_begin_scan() {
Ok(g) => g,
Err(e) => {
warn!(error = %e, "local index: cannot open scan generation");
return;
}
};
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
indexing.store(true, Ordering::Relaxed);
let started = Instant::now();
// 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");
job.detail("Scanning your files");
let walked = localindex::scan(&[home], &excludes, |batch| {
if let Err(e) = db.local_upsert_batch(generation, &batch) {
warn!(error = %e, "local index: batch write failed");
}
});
// Prune what this scan did not see and rebuild the FTS index over the rest,
// even if some batches failed — a partial index still beats none.
job.detail("Building the search index");
match db.local_finish_scan(generation, now_secs()) {
Ok(indexed) => info!(
walked,
indexed,
elapsed_ms = started.elapsed().as_millis() as u64,
"local index rebuilt"
),
Err(e) => warn!(error = %e, "local index: finish failed"),
}
indexing.store(false, Ordering::Relaxed);
}
+810
View File
@@ -0,0 +1,810 @@
//! The control socket: the daemon`s IPC surface.
//!
//! The CLI and the GUI never touch the mount or the database directly — they
//! open this Unix socket, write one JSON [`CtlRequest`] line, and read one JSON
//! [`CtlResponse`] line back. That keeps the daemon the single writer of both
//! the FUSE state and `cache.db`.
//!
//! Connections are served concurrently, one thread each, because requests differ
//! wildly in cost: an `OpenPhoto` downloads a whole photo, and a serial loop
//! would stall the GUI`s 2s status poll behind it.
use std::io::{BufRead, BufReader, Write as _};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Instant;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
TransferDirection,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
use super::transfers::CountingReader;
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
/// must live under `mountpoint`; a relative path is taken as already relative to
/// the mount root.
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
let p = Path::new(path);
if p.is_absolute() {
p.strip_prefix(mountpoint)
.map(Path::to_path_buf)
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
} else {
Ok(p.to_path_buf())
}
}
/// Handle one control-socket connection: read a single JSON request line,
/// dispatch it against `core`, and write back a JSON response line.
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "control: clone stream failed");
return;
}
});
let mut line = String::new();
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
return;
}
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => {
let pins = core.cache.list_pins();
let queued = core.db.pending_op_counts().unwrap_or_default();
CtlResponse::Status {
username: username.to_string(),
mountpoint: mountpoint.display().to_string(),
pinned: pins.len(),
used: core.cache.usage(),
budget: core.cache.budget(),
pins,
online: core.online.load(Ordering::Relaxed),
pending_uploads: queued.uploads.max(0) as u64,
pending_changes: queued.changes.max(0) as u64,
}
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
message: format!("pinned {name}"),
}
}
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.unpin(&rel) {
Ok(name) => {
core.touch_profile();
CtlResponse::Ok {
message: format!("unpinned {name}"),
}
}
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
pins: core.cache.list_pins(),
},
Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_dir(&rel) {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Refresh { scope }) => {
let result = match &scope {
RefreshScope::Dir { path } => {
rel_to_mount(mountpoint, path).and_then(|rel| core.refresh_dir(&rel))
}
RefreshScope::Trash => {
core.invalidate_trash();
Ok(())
}
RefreshScope::Photos => {
core.invalidate_photos();
Ok(())
}
};
match result {
Ok(()) => CtlResponse::Ok {
message: "refreshed".to_string(),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::PhotosTimeline {
offset,
limit,
kind,
range,
}) => match core.photos_timeline(offset, limit, kind, range) {
Ok(Some(items)) => CtlResponse::Photos {
available: true,
items,
counts: core.db.photos_counts().ok(),
},
Ok(None) => CtlResponse::Photos {
available: false,
items: Vec::new(),
counts: None,
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
Ok(months) => CtlResponse::PhotoMonths {
months: months
.into_iter()
.map(|(year, month, count)| PhotoMonth { year, month, count })
.collect(),
},
Err(e) => CtlResponse::error(e.into()),
},
Ok(CtlRequest::PhotoThumbs { uids }) => {
let parsed: Vec<NodeUid> = uids.iter().filter_map(|u| parse_uid(u)).collect();
CtlResponse::Thumbs {
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
Some(u) => match core.open_photo(&u) {
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::error(e),
},
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
},
Ok(CtlRequest::UploadPhoto {
name,
media_type,
source_path,
capture_time,
}) => {
// Streamed off disk rather than carried over the socket: see the note
// on `Request::UploadPhoto`. The length comes from the same handle the
// bytes do, so a file changing size under us cannot make the two
// disagree.
match std::fs::File::open(&source_path).and_then(|f| f.metadata().map(|m| (f, m.len())))
{
Err(e) => CtlResponse::error(CoreError::invalid(format!(
"cannot read {source_path}: {e}"
))),
Ok((file, len)) => {
let photos = core.photos();
let metadata = proton_drive_rs::PhotoUploadMetadata {
capture_time,
..Default::default()
};
let guard =
core.transfers
.begin(name.clone(), "", TransferDirection::Upload, len);
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
match core.rt.block_on(photos.upload_photo_from(
&name,
&media_type,
reader,
len as i64,
Vec::new(),
metadata,
false,
)) {
Ok(uid) => {
// The photo we just uploaded belongs at the head of the
// timeline, and the GUI reloads the gallery the moment this
// reply lands — so refresh now rather than leaving it to a
// background pass that would land just after that reload.
if let Err(e) = core.rt.block_on(core.refresh_timeline()) {
warn!(error = %e, "timeline refresh after upload failed");
}
CtlResponse::Ok {
message: format!("uploaded photo with uid {uid}"),
}
}
Err(e) => {
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
}
}
}
}
}
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.open_file(&rel) {
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
Ok(hits) => CtlResponse::SearchResults { hits },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
Ok(hits) => CtlResponse::LocalResults {
hits,
indexing: core.indexing.load(Ordering::Relaxed),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SearchV2 {
query,
limit,
filters,
}) => match core.search_v2(&query, limit, &filters) {
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
drive_hits,
local_hits,
local_indexing: core.indexing.load(Ordering::Relaxed),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.rename(&rel, &new_name) {
Ok(name) => {
core.log_activity(ActivityKind::Rename, &name, format!("was {path}"), true);
CtlResponse::Ok {
message: format!("renamed to {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Rename, &path, &e, false);
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Move { path, new_parent }) => {
match (
rel_to_mount(mountpoint, &path),
rel_to_mount(mountpoint, &new_parent),
) {
(Ok(rel), Ok(parent_rel)) => match core.move_to(&rel, &parent_rel) {
Ok(name) => {
let dest = if new_parent.is_empty() {
"My files".to_string()
} else {
new_parent.clone()
};
core.log_activity(ActivityKind::Move, &name, format!("to {dest}"), true);
CtlResponse::Ok {
message: format!("moved {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Move, &path, &e, false);
CtlResponse::error(e)
}
},
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
}
}
Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.delete(&rel) {
Ok(name) => {
core.log_activity(ActivityKind::Trash, &name, "", true);
CtlResponse::Ok {
message: format!("trashed {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Trash, &path, &e, false);
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) {
Ok(parent_rel) => match core.create_folder(&parent_rel, &name) {
Ok(name) => {
core.log_activity(ActivityKind::CreateFolder, &name, "", true);
CtlResponse::Ok {
message: format!("created folder {name}"),
}
}
Err(e) => {
core.log_activity(ActivityKind::CreateFolder, &name, &e, false);
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UploadPaths { parent, sources }) => {
match rel_to_mount(mountpoint, &parent) {
// Ack immediately and upload on a background thread: a directory tree
// far outlasts the socket read timeout. Progress and completion are
// observed through GetQueueStatus; the activity log gets the summary
// when the whole batch finishes.
Ok(parent_rel) => {
let core = core.clone();
let paths: Vec<PathBuf> = sources.into_iter().map(PathBuf::from).collect();
let n = paths.len();
std::thread::spawn(move || {
let started = Instant::now();
match core.upload_paths(&parent_rel, &paths) {
Ok(stats) => {
// e.g. "Uploaded 700 files to Photos" — name the destination
// so the log reads like a sentence rather than a bare count.
let dest = parent_rel
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("your Drive");
let target = format!(
"{} to {dest}",
count_noun(stats.uploaded, "file", "files")
);
// Size · folders · elapsed, with a trailing failure count
// when some files didn't make it.
let mut parts = vec![human_bytes(stats.bytes)];
if stats.folders > 0 {
parts.push(count_noun(stats.folders, "folder", "folders"));
}
parts.push(human_duration(started.elapsed()));
if stats.failed > 0 {
parts.push(format!("{} failed", stats.failed));
}
core.log_activity(
ActivityKind::Upload,
target,
parts.join(" · "),
stats.failed == 0,
);
}
Err(e) => {
warn!(error = %e, "bulk upload failed");
core.log_activity(ActivityKind::Upload, "bulk upload", &e, false);
}
}
});
CtlResponse::Ok {
message: format!("uploading {n} item(s)"),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::ListTrash) => match core.list_trash() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) {
Ok(n) => {
core.log_activity(ActivityKind::Restore, format!("{n} item(s)"), "", true);
CtlResponse::Ok {
message: format!("restored {n} item(s)"),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) {
Ok(n) => {
core.log_activity(
ActivityKind::DeleteForever,
format!("{n} item(s)"),
"",
true,
);
CtlResponse::Ok {
message: format!("permanently deleted {n} item(s)"),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
Ok(n) => {
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
CtlResponse::Ok {
message: format!("emptied trash ({n} item(s))"),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PurgeCache) => {
let freed = core.cache.clear_unpinned();
CtlResponse::Ok {
message: format!(
"purged {:.1} MiB of unpinned cache",
freed as f64 / 1_048_576.0
),
}
}
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
Ok(stats) => {
// The deep check reads every page, so it runs only on request;
// `integrity_checked` tells the caller which answer it is
// holding, since "no problems found" and "never looked" are
// both an empty list.
let (integrity_problems, integrity_checked) = if deep {
match core.db.integrity_check() {
Ok(problems) => (problems, true),
Err(e) => (vec![format!("integrity check failed: {e:?}")], true),
}
} else {
(Vec::new(), false)
};
CtlResponse::CacheReport {
schema_version: stats.schema_version,
db_bytes: stats.total_bytes(),
db_reclaimable_bytes: stats.reclaimable_bytes(),
tables: stats.tables,
cache_used: core.cache.usage(),
cache_budget: core.cache.budget(),
integrity_problems,
integrity_checked,
}
}
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
},
Ok(CtlRequest::CacheVacuum) => match core.db.vacuum() {
Ok(outcome) => CtlResponse::Ok {
message: format!(
"vacuumed: {:.1} MiB freed ({:.1} MiB → {:.1} MiB), {} WAL frame(s) checkpointed",
outcome.freed_bytes() as f64 / 1_048_576.0,
outcome.before_bytes as f64 / 1_048_576.0,
outcome.after_bytes as f64 / 1_048_576.0,
outcome.wal_frames_checkpointed,
),
},
Err(e) => CtlResponse::error(CoreError::internal(format!("vacuum: {e:?}"))),
},
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
items: core.transfers.snapshot(),
jobs: core.jobs_snapshot(),
},
Ok(CtlRequest::SetCacheBudget { bytes }) => {
core.cache.set_budget(bytes);
// Persist so the next mount keeps the new cap. Best-effort: a config
// write failure is reported but the live cap is already applied.
match AppDirs::new().map(|dirs| {
let mut cfg = dirs.load_config();
cfg.cache_budget = Some(bytes);
dirs.save_config(&cfg)
}) {
Ok(Ok(())) => {
core.touch_profile();
CtlResponse::Ok {
message: format!("cache budget set to {bytes} bytes"),
}
}
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
"budget applied but config write failed: {e}"
))),
Err(e) => CtlResponse::error(CoreError::internal(format!(
"budget applied but config unavailable: {e}"
))),
}
}
Ok(CtlRequest::ListDevices) => match core.list_devices() {
Ok(items) => CtlResponse::Devices { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
Ok(()) => CtlResponse::Ok {
message: format!("renamed device to {name}"),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) {
Ok(()) => CtlResponse::Ok {
message: "device deleted".to_string(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AdoptDevice { uid }) => match core.adopt_device(uid.as_deref()) {
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
// thread. The folder appears in ListSyncFolders once the row lands;
// completion (and any failures) go to the activity log.
let core = core.clone();
let path = PathBuf::from(&local_path);
std::thread::spawn(move || {
let started = Instant::now();
match core.add_sync_folder(&path) {
Ok(folder) => {
core.touch_profile();
let name = Path::new(&folder.local_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&folder.local_path)
.to_string();
core.log_activity(
ActivityKind::Upload,
format!("synced {name}"),
human_duration(started.elapsed()),
folder.state != "error",
);
}
Err(e) => {
warn!(error = %e, "add sync folder failed");
core.log_activity(ActivityKind::Upload, "add sync folder", &e, false);
}
}
});
CtlResponse::Ok {
message: format!("adding {local_path}"),
}
}
Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() {
Ok(items) => CtlResponse::SyncFolders { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => {
match core.remove_sync_folder(id, delete_remote) {
Ok(()) => {
core.touch_profile();
CtlResponse::Ok {
message: "removed synced folder".to_string(),
}
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SetSyncFolderMode { id, mode }) => {
match core.request_sync_folder_mode(id, &mode) {
Ok(message) => {
core.touch_profile();
core.log_activity(ActivityKind::Upload, &message, "", true);
CtlResponse::Ok { message }
}
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::SyncNow { id }) => {
core.sync_now(id);
CtlResponse::Ok {
message: match id {
Some(id) => format!("reconciling folder {id}"),
None => "reconciling all folders".to_string(),
},
}
}
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
Ok(items) => CtlResponse::RestorableFolders { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RestoreSyncFolders { items }) => {
// Like AddSyncFolder: each restored folder downloads a whole tree,
// which outlasts the socket timeout. Ack, then work.
let core = core.clone();
std::thread::spawn(move || match core.restore_sync_folders(&items) {
Ok(message) => {
core.touch_profile();
core.log_activity(ActivityKind::Download, &message, "", true);
}
Err(e) => {
warn!(error = %e, "restore failed");
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
}
});
CtlResponse::Ok {
message: "restoring folders".to_string(),
}
}
Ok(CtlRequest::ShareNode {
path,
emails,
role,
message,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.share_node(&rel, &emails, &role, message.as_deref()) {
Ok((proton, external)) => {
core.log_activity(
ActivityKind::Share,
&path,
format!("{} recipient(s) as {role}", proton + external),
true,
);
CtlResponse::Ok {
message: format!("invited {proton} Proton and {external} external user(s)"),
}
}
Err(e) => {
core.log_activity(ActivityKind::Share, &path, &e, false);
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.list_share(&rel) {
Ok((entries, link)) => CtlResponse::Share { entries, link },
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::UpdateShareRole {
path,
id,
kind,
role,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.update_share_role(&rel, &id, kind, &role) {
Ok(()) => CtlResponse::Ok {
message: format!("role updated to {role}"),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &path, "access removed", true);
CtlResponse::Ok {
message: "removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::CreatePublicLink {
path,
role,
password,
expires,
}) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.create_public_link(&rel, &role, password.as_deref(), expires) {
Ok(link) => {
core.log_activity(ActivityKind::PublicLink, &path, "link created", true);
CtlResponse::PublicLink { link }
}
Err(e) => {
core.log_activity(ActivityKind::PublicLink, &path, &e, false);
CtlResponse::error(e)
}
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.remove_public_link(&rel, &id) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, &path, "link removed", true);
CtlResponse::Ok {
message: "public link removed".to_string(),
}
}
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AccountQuota) => match core.account_quota() {
Ok((max_space, used_space)) => CtlResponse::AccountQuota {
max_space,
used_space,
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedFolder { uid }) => match core.list_shared_folder(&uid) {
Ok(entries) => CtlResponse::Entries { entries },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::OpenSharedFile { uid }) => match core.open_shared_file(&uid) {
Ok(p) => CtlResponse::FilePath {
path: p.display().to_string(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) {
Ok(()) => {
core.log_activity(ActivityKind::Unshare, "shared item", "left", true);
CtlResponse::Ok {
message: "left shared item".to_string(),
}
}
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListInvitations) => match core.list_invitations() {
Ok(items) => CtlResponse::Invitations { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation accepted".to_string(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) {
Ok(()) => CtlResponse::Ok {
message: "invitation rejected".to_string(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() {
Ok(items) => CtlResponse::Bookmarks { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::CreateBookmark { url, password }) => {
match core.create_bookmark(&url, password.as_deref()) {
Ok(()) => CtlResponse::Ok {
message: "bookmark saved".to_string(),
},
Err(e) => CtlResponse::error(e),
}
}
Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) {
Ok(()) => CtlResponse::Ok {
message: "bookmark removed".to_string(),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() {
Ok(items) => CtlResponse::SharedByMe { items },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
items: core.list_activity(limit),
},
// The request did not parse: the caller sent something malformed.
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
};
let mut out = match serde_json::to_vec(&response) {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "control: serialize response failed");
return;
}
};
out.push(b'\n');
let mut stream = stream;
let _ = stream.write_all(&out);
}
/// Listen on the control socket, serving one command per connection, each on its
/// own thread. Runs on its own thread; returns only if the listener itself fails.
///
/// Concurrent rather than serial because requests differ wildly in cost: an
/// `OpenPhoto` downloads a whole photo, and while it ran the accept loop used to
/// stall every other caller behind it — the GUI's 2s status poll, and the
/// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles
/// (`Arc`/`Clone`), so each connection gets its own copy of it.
pub(crate) fn run_control_socket(
core: Core,
username: String,
mountpoint: PathBuf,
listener: UnixListener,
) {
info!("control socket listening");
for conn in listener.incoming() {
match conn {
Ok(stream) => {
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.clone();
if let Err(e) = std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || handle_control_conn(&core, &username, &mountpoint, stream))
{
warn!(error = %e, "control: spawn handler failed");
}
}
Err(e) => {
warn!(error = %e, "control: accept failed");
}
}
}
}
+868
View File
@@ -0,0 +1,868 @@
//! This machine as a Proton Drive Device, and the local folders it keeps in
//! step with the remote (devices.md).
//!
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
//! tree and mounts a second FUSE session over the same path, rooted at the
//! folder`s remote node. Flipping between them is the delicate part — see
//! [`Core::apply_sync_folder_mode`].
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fuser::{Config, MountOption, Session};
use parking_lot::Mutex;
use pdfs_core::control::{
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
};
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
use proton_drive_rs::{DeviceType, Node};
use std::sync::OnceLock;
use tracing::{info, warn};
use super::sync::{self, base_name};
use super::{
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid,
sync_folder_info, this_hostname, umount_session_unblocked,
};
/// The device uid this machine has been adopted into, from `config.json`.
///
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
/// config from the control socket, and the next `ensure_device` must see it
/// without a daemon restart.
pub(crate) fn adopted_device_uid() -> Option<String> {
let uid = pdfs_core::config::AppDirs::new()
.ok()?
.load_config()
.device_uid?;
let uid = uid.trim().to_string();
(!uid.is_empty()).then_some(uid)
}
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
let dirs = pdfs_core::config::AppDirs::new()
.map_err(|e| CoreError::internal(format!("config dirs: {e}")))?;
let mut cfg = dirs.load_config();
cfg.device_uid = uid.map(|u| u.to_string());
dirs.save_config(&cfg)
.map_err(|e| CoreError::internal(format!("write config: {e}")))
}
impl Core {
// ---- devices ----------------------------------------------------------
/// List the account's registered devices, flagging the one *this* machine
/// syncs to so a front-end can treat it as more than another computer in the
/// list — deleting it takes this machine's synced folders down with it.
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
let devices = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
// No cached device row yet means this machine syncs nothing, so none of
// the listed devices is ours.
let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid);
let pinned = adopted_device_uid();
Ok(devices
.into_iter()
.map(|d| {
let uid = d.uid.to_string();
DeviceInfo {
this_device: this_uid.as_deref() == Some(uid.as_str()),
adopted: pinned.as_deref() == Some(uid.as_str()),
uid,
name: d.name.unwrap_or_else(|_| "(unnamed device)".to_string()),
device_type: device_type_str(d.device_type).to_string(),
last_sync: d.last_sync_time,
}
})
.collect())
}
/// Rename a device by its uid.
pub(crate) fn rename_device(&self, uid: &str, name: &str) -> CoreResult<()> {
if name.is_empty() {
return Err(CoreError::invalid("device name must not be empty"));
}
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.rename_device(&device_uid, name))
.map_err(|e| CoreError::from_api(&e, "rename device"))?;
Ok(())
}
/// Delete (deregister) a device by its uid.
pub(crate) fn delete_device(&self, uid: &str) -> CoreResult<()> {
let device_uid = DeviceUid::from(uid);
self.rt
.block_on(self.client.delete_device(&device_uid))
.map_err(|e| CoreError::from_api(&e, "delete device"))?;
Ok(())
}
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
/// so hostname changes and reinstalls stop registering duplicates
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
///
/// Refuses a uid that is not in the account: the whole point of the pin is
/// that it is authoritative, so accepting a bad one would defer the failure
/// to the next `ensure_device` with no obvious cause.
pub(crate) fn adopt_device(&self, uid: Option<&str>) -> CoreResult<String> {
let Some(uid) = uid else {
set_adopted_device_uid(None)?;
return Ok("adoption cleared; device now resolved by hostname".to_string());
};
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
let dev = remote
.into_iter()
.find(|d| d.uid.to_string() == uid)
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
let name = dev
.name
.as_deref()
.ok()
.map(|n| n.to_string())
.unwrap_or_else(this_hostname);
set_adopted_device_uid(Some(uid))?;
// Write the row through too, so callers that read the cached device
// (and `ListSyncFolders`' "this device" flag) agree immediately rather
// than after the next `ensure_device`.
self.db
.device_set(&StoredDevice {
uid: uid.to_string(),
share_id: dev.share_id.to_string(),
root_uid: dev.root_folder_uid.to_string(),
name: name.clone(),
created: dev.creation_time,
})
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
info!(uid, name, "adopted device");
Ok(format!("adopted device {name}"))
}
// ---- device folder sync (devices.md, Phase 1) -------------------------
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
/// it so restarts reuse the same device. Recovery matches an existing remote
/// Linux device by name before creating a new one, so a lost local record
/// doesn't orphan the device's root folder.
///
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
/// cached DB row (validated remotely), then a hostname match, then create.
/// The pin exists because the hostname heuristic silently registers a second
/// device after a rename or reinstall, orphaning the first one's folders
/// (features.md 5.1).
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
let name = this_hostname();
// Enumerate the remote devices once: used to validate the pin, validate
// any cached record, and recover an existing device by name.
let remote = self
.rt
.block_on(self.client.enumerate_devices())
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
// An adopted uid is an explicit instruction, so it outranks everything
// and it fails loudly: falling back to the heuristic here would create
// the duplicate device the user adopted specifically to avoid.
if let Some(pin) = adopted_device_uid() {
let d = remote
.iter()
.find(|d| d.uid.to_string() == pin)
.ok_or_else(|| {
CoreError::not_found(format!(
"adopted device {pin} not found in this account; \
run `pdfs devices adopt --clear` to return to hostname matching"
))
})?;
let dev = StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name: d.name.as_deref().ok().unwrap_or(&name).to_string(),
created: d.creation_time,
};
self.db
.device_set(&dev)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
return Ok(dev);
}
// A cached device is only trustworthy if it still exists remotely. A
// device deleted from another client (or the web UI) leaves a stale row
// whose root folder is gone, so creating folders under it fails with
// "parent node is not a folder". Re-register in that case.
if let Some(dev) = self
.db
.device_get()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
{
if remote.iter().any(|d| d.uid.to_string() == dev.uid) {
return Ok(dev);
}
warn!(uid = %dev.uid, "cached device is gone remotely; re-registering");
}
// Recover: an existing remote Linux device with the same name is ours.
let existing = remote.into_iter().find(|d| {
d.device_type == DeviceType::Linux && d.name.as_deref().ok() == Some(name.as_str())
});
let dev = match existing {
Some(d) => StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name,
created: d.creation_time,
},
None => {
let d = self
.rt
.block_on(self.client.create_device(&name, DeviceType::Linux))
.map_err(|e| CoreError::from_api(&e, "create device"))?;
StoredDevice {
uid: d.uid.to_string(),
share_id: d.share_id.to_string(),
root_uid: d.root_folder_uid.to_string(),
name,
created: d.creation_time,
}
}
};
self.db
.device_set(&dev)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
Ok(dev)
}
/// An untrashed folder named `name` directly under the device root, if one
/// already exists.
pub(crate) fn find_device_child_folder(
&self,
root_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(None);
}
let nodes = self
.rt
.block_on(self.client.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))
}
/// Add a local folder to this device's sync set: register the device if
/// needed, create a matching folder under the device root, upload the local
/// tree into it once, and record the mapping. Phase 1 is a one-shot upload —
/// the two-way engine (Phase 2) reconciles later changes.
pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult<StoredSyncFolder> {
let meta = std::fs::metadata(local)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?;
if !meta.is_dir() {
return Err(CoreError::invalid(format!(
"{} is not a directory",
local.display()
)));
}
let local = local
.canonicalize()
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
let local_str = local.to_string_lossy().to_string();
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
if self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.iter()
.any(|f| f.local_path == local_str)
{
return Err(CoreError::invalid(format!(
"{} is already synced",
local.display()
)));
}
let name = local
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
CoreError::invalid(format!("unusable folder name: {}", local.display()))
})?
.to_string();
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
// The synced folder's remote root: the folder under the device root named
// after the local basename. Reuse an existing one rather than creating a
// second folder with the same name — re-adding a folder (after a removal, or
// after a failed add that had already created it) must land back on the
// original, not leave the user with two "Downloads" in their Drive. The
// reconcile treats an existing remote tree correctly: unmatched paths read as
// a conflict, not as data loss.
let remote_root = match self.find_device_child_folder(&root_uid, &name)? {
Some(uid) => {
info!(name, "reusing existing device folder");
uid
}
None => self
.rt
.block_on(
self.client
.create_folder(&root_uid, &name, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?,
};
let id = self
.db
.sync_folder_add(&local_str, &remote_root.to_string(), &device.share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// Hand the initial upload to the sync engine: an empty baseline against a
// full local tree reconciles as "upload everything", and the folder is
// added to the filesystem watch set in the same pass.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(local = %local.display(), id, "added sync folder");
self.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found("sync folder vanished after insert"))
}
/// List this device's synced folders for the front-ends, each carrying the
/// live progress of its pass when one is running.
pub(crate) fn list_sync_folders(&self) -> CoreResult<Vec<SyncFolderInfo>> {
let progress = self.sync_progress.lock();
Ok(self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| {
let live = progress.get(&f.id).cloned();
sync_folder_info(f, live)
})
.collect())
}
/// Everything the daemon is chewing on that isn't moving bytes, for
/// `GetQueueStatus`: the registered jobs (bulk-upload scans, the local index)
/// plus a synthesized job per running sync pass, so one Activity view answers
/// "is anything still happening?" without also polling `ListSyncFolders`.
///
/// The sync passes are folded in here rather than tracked as registry jobs
/// because the Devices page needs them per folder anyway
/// ([`SyncFolderInfo::progress`]) — this keeps one source of truth and hits
/// the db only while a pass is actually running.
pub(crate) fn jobs_snapshot(&self) -> Vec<JobItem> {
let mut jobs = self.transfers.jobs_snapshot();
let mut passes: Vec<(i64, SyncProgress)> = self
.sync_progress
.lock()
.iter()
.map(|(id, p)| (*id, p.clone()))
.collect();
if passes.is_empty() {
return jobs;
}
passes.sort_by_key(|(id, _)| *id);
let names: HashMap<i64, String> = self
.db
.sync_folder_list()
.unwrap_or_default()
.into_iter()
.map(|f| (f.id, f.local_path))
.collect();
for (id, p) in passes {
// The row is titled with the folder's own name; the full local path
// is what the Devices page shows, and is far too long for this line.
let folder = names
.get(&id)
.and_then(|path| Path::new(path).file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "folder".to_string());
jobs.push(match p.phase {
// The scan's total is the last pass's baseline, so a folder that
// has never synced still reports indeterminate (`total: 0`) — but
// every later pass has a real bar. A grown folder can push `done`
// past the estimate; clamp so the row never reads "600 of 500".
SyncPhase::Scanning => JobItem {
title: format!("Checking {folder}"),
detail: "Looking for changes".to_string(),
done: p.done as u64,
total: if p.total == 0 {
0
} else {
p.total.max(p.done) as u64
},
},
SyncPhase::Applying => JobItem {
title: format!("Syncing {folder}"),
detail: p.current.clone(),
done: p.done as u64,
total: p.total.max(p.done) as u64,
},
});
}
jobs
}
/// The lock guarding sync-folder `id` against concurrent reconcile/mode-switch.
pub(crate) fn sync_lock(&self, id: i64) -> Arc<Mutex<()>> {
self.sync_locks.lock().entry(id).or_default().clone()
}
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
/// folder under the device root; otherwise the cloud copy is left in place.
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
// An `ondemand` folder *is* a FUSE mount over its local path, so dropping
// only the row would strand the mount: the path would keep serving a
// folder the daemon no longer tracks, and nothing would ever unmount it.
// Tear it down first — including before trashing the remote tree it
// serves, which would otherwise leave it answering for deleted nodes.
if let Some((session, conn)) = self.mounts.lock().remove(&id) {
if let Err(e) = umount_session_unblocked(session, conn) {
warn!(id, error = %e, "unmount on-demand folder failed");
} else {
info!(id, path = %folder.local_path, "unmounted on-demand folder");
}
}
if delete_remote
&& let Some(uid) = parse_uid(&folder.remote_uid)
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
{
warn!(id, error = %e, "delete remote device folder failed");
}
if !self
.db
.sync_folder_remove(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
{
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
}
// Stop watching the folder we just dropped.
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
Ok(())
}
/// Trigger a reconcile: one folder by id, or every folder when `id` is `None`.
pub(crate) fn sync_now(&self, id: Option<i64>) {
let _ = match id {
Some(id) => self.sync_tx.send(sync::SyncMsg::Reconcile(id)),
None => self.sync_tx.send(sync::SyncMsg::ReconcileAll),
};
}
/// A sibling Core that shares this one's client/rt/cache/db (and transfer,
/// activity, mount registries) but gets a **fresh, empty `State`** — its own
/// inode space starting at [`ROOT_INO`]. Used to root a secondary FUSE session
/// at an `ondemand` sync folder without colliding with the main mount's inodes
/// (devices.md Phase 3).
pub(crate) fn fork_state(&self) -> Core {
let mut fork = self.clone();
fork.state = Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino: 2,
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
db: self.db.clone(),
}));
// A fresh inode space needs a fresh notification channel: this fork's
// session is the only one that knows these inodes, so it must be the one
// notified about them. Filled in by `spawn_ondemand_mount`.
fork.notifier = Arc::new(OnceLock::new());
// Size upgrades are keyed by inode, which is per-fork too.
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
fork
}
/// Ask for a synced folder to move to `mode`, applying it now if the folder is
/// free and safe to switch, and **queueing** it otherwise. Returns the human
/// message for the reply.
///
/// Queueing rather than rejecting is what makes the toggle usable: a folder
/// that syncs continuously (a busy Downloads folder) is almost never caught in
/// the narrow window where it is both unlocked and `idle`, so a `try_lock`
/// rejection asks the user to keep retrying until they get lucky. Instead the
/// intent is recorded and the engine applies it at the end of the pass already
/// running — which, seeing a queued `ondemand`, also stops pulling down files
/// it is about to evict ([`Core::push_pass`]).
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
if mode != "mirror" && mode != "ondemand" {
return Err(CoreError::invalid(format!(
"unknown mode {mode:?} (want mirror|ondemand)"
)));
}
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
if folder.mode == mode {
// Already there. A queued switch the other way is the user changing
// their mind back before it landed, so withdraw it.
if folder.pending_mode.is_some() {
self.db
.sync_folder_set_pending_mode(id, None)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
return Ok(format!("staying {mode}"));
}
return Ok(format!("already {mode}"));
}
match self.apply_sync_folder_mode(id, mode) {
Ok(message) => Ok(message),
// Not switchable this instant — remember the intent instead of making
// the user retry, and kick a pass to clear whatever is in the way.
Err(SwitchBlocked::NotNow) => {
self.db
.sync_folder_set_pending_mode(id, Some(mode))
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
Ok(match mode {
"ondemand" => format!(
"{} will go on-demand once its local changes are uploaded",
base_name(&folder.local_path)
),
_ => format!(
"{} will start mirroring once the current sync finishes",
base_name(&folder.local_path)
),
})
}
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
}
}
/// Apply a queued mode switch if the folder has one and is now able to take it.
/// Called by the sync engine after every pass, so a switch the user asked for
/// mid-sync lands as soon as the pass that blocked it is done. A folder that is
/// still not ready (its push failed, so the local copy is not safe to evict)
/// keeps its `pending_mode` and is retried after the next pass.
pub(crate) fn settle_pending_mode(&self, id: i64) {
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
return;
};
let Some(mode) = folder.pending_mode.as_deref() else {
return;
};
if folder.mode == mode {
let _ = self.db.sync_folder_set_pending_mode(id, None);
return;
}
match self.apply_sync_folder_mode(id, mode) {
Ok(message) => {
info!(id, mode, "applied queued mode switch");
self.log_activity(ActivityKind::Sync, &message, "", true);
}
// Still blocked: the pass could not get everything up, so the local copy
// is not safe to evict yet. Leave the request standing — the next pass
// (poll, or the retry the engine schedules) tries again.
Err(SwitchBlocked::NotNow) => {
info!(id, mode, "queued mode switch still waiting");
}
Err(SwitchBlocked::Failed(e)) => {
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
let _ = self.db.sync_folder_set_pending_mode(id, None);
self.log_activity(
ActivityKind::Sync,
format!("couldn't switch {}", base_name(&folder.local_path)),
e,
false,
);
}
}
}
/// Flip a synced folder between `mirror` (full local copy + two-way sync) and
/// `ondemand` (a FUSE mount over the local path; no local storage). Returns a
/// human message on success.
///
/// - **mirror→ondemand**: require the folder in-sync (`idle`), stop watching it,
/// evict the local files to reclaim disk, then mount a secondary `ProtonFs`
/// rooted at the folder's remote node over its local path.
/// - **ondemand→mirror**: unmount, clear the stale baseline (the local tree was
/// evicted), then hand the folder back to the engine, which re-downloads it.
///
/// [`SwitchBlocked::NotNow`] means "not yet, try after a pass" and is never an
/// error the user needs to see — callers queue on it.
pub(crate) fn apply_sync_folder_mode(
&self,
id: i64,
mode: &str,
) -> Result<String, SwitchBlocked> {
// Hold the folder's lock for the whole switch so no reconcile pass can be
// running over the tree we are about to evict (or start while we mount over
// it). A pass in flight holds the lock for its full duration, so `try_lock`
// failing is exactly "still syncing" — and it is the only reliable signal:
// the `state` column is still `idle` in the window between `add_sync_folder`
// inserting the row and the engine picking it up.
let lock = self.sync_lock(id);
let Some(_guard) = lock.try_lock() else {
return Err(SwitchBlocked::NotNow);
};
// Re-read under the lock: a pass that finished while we waited may have
// changed `state`.
let folder = self
.db
.sync_folder_get(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
if folder.mode == mode {
return Ok(format!("already {mode}"));
}
let local = PathBuf::from(&folder.local_path);
match mode {
"ondemand" => {
// Only flip a folder that is fully in sync — a failed reconcile means
// local edits could still be un-uploaded, and we are about to delete
// the local copy. Not an error: a pass makes this true, and the queued
// request is applied once one does.
if folder.state != "idle" {
return Err(SwitchBlocked::NotNow);
}
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
})?;
let root = self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
.map_err(|e| SwitchBlocked::Failed(format!("fetch remote root: {e}")))?
.into_iter()
.next()
.ok_or_else(|| SwitchBlocked::Failed("remote folder not found".to_string()))?;
// Persist the mode *before* touching the local tree, and never roll it
// back on failure.
//
// The eviction below deletes every local file in the folder. While the
// row still says `mirror`, that empty directory is a valid input to the
// sync engine, which reads it as "the user deleted all of this" and
// trashes the whole folder on Drive. So the window between the evict and
// the mode write is a window in which any failure — a busy mountpoint, a
// missing fuse module, a dead DB — destroys the folder. Writing the mode
// first closes it: the engine skips non-mirror folders, so the worst a
// failure can now leave behind is an `ondemand` row with no mount, which
// is inert. Inert and marked `error` is recoverable; trashed is not.
//
// The same reasoning forbids reverting to `mirror` when a step below
// fails, since eviction may already have deleted part of the tree.
// Recovery is the user's explicit switch back to `mirror`, whose arm
// clears the baseline and re-downloads.
if let Err(e) = self.db.sync_folder_set_mode(id, "ondemand") {
return Err(SwitchBlocked::Failed(format!("db: {e:?}")));
}
// Reclaim the disk before mounting. Once FUSE covers `local`, a
// directory walk addresses the remote namespace, so evicting at
// that point sends unlink/rmdir requests to Drive and leaves the
// underlying mirror hidden and untouched.
if let Err(e) = evict_dir_contents(&local) {
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
}
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| {
SwitchBlocked::Failed(format!(
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
))
})?;
self.mounts
.lock()
.insert(id, (session, fuse_connection_id(&local)));
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
info!(id, path = %local.display(), "mounted sync folder on-demand");
Ok(format!("{} is now on-demand", local.display()))
}
_ => {
// ondemand→mirror: tear down the secondary mount first.
if let Some((session, conn)) = self.mounts.lock().remove(&id)
&& let Err(e) = umount_session_unblocked(session, conn)
{
warn!(id, error = %e, "unmount on-demand folder failed");
}
// The evicted local tree makes the old baseline lie ("everything
// deleted locally"); clear it so the reconcile is a pure download.
self.db
.sync_entries_clear(id)
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
self.db
.sync_folder_set_mode(id, "mirror")
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
info!(id, path = %local.display(), "restored sync folder to mirror");
Ok(format!(
"{} is mirroring again; downloading",
local.display()
))
}
}
}
/// Spawn a secondary FUSE session rooted at `root` over `local` on a forked
/// inode space. Clears any stale kernel mount first (a crashed run can leave
/// one, which would fail the fresh mount with EBUSY).
pub(crate) fn spawn_ondemand_mount(
&self,
local: &Path,
root: Node,
) -> CoreResult<BackgroundSession> {
clear_stale_mount(local);
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
let core = self.fork_state();
let fs = ProtonFs::new(core.clone(), root);
let session = Session::new(fs, local, &config)
.and_then(|s| s.spawn())
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
// Hand this fork its own session's notification channel, so a background
// size upgrade can drop the kernel's cached attrs for these inodes.
let _ = core.notifier.set(session.notifier());
Ok(session)
}
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
/// restart (their local dirs are empty on disk — the files live in the cloud).
/// Best-effort per folder: a missing local path or a failed remote fetch marks
/// the folder `error` and moves on rather than aborting the rest. Runs on its
/// own thread from `mount` so the network fetches never block startup
/// (devices.md Phase 4).
pub(crate) fn restore_ondemand_mounts(&self) {
let folders = match self.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {
warn!(error = ?e, "restore on-demand: cannot list folders");
return;
}
};
for folder in folders {
if folder.mode != "ondemand" {
continue;
}
let local = PathBuf::from(&folder.local_path);
// A daemon killed before it could unmount (systemd's stop timeout,
// say) leaves this path attached to a dead FUSE connection: it still
// exists, but every stat on it fails with ENOTCONN — so `is_dir()`
// reads it as *missing* and the folder gets parked in `error` on
// every subsequent start, with no way back but a manual
// `fusermount -u`. Detach the corpse first; the directory
// underneath then restores normally.
if is_stale_mount(&local) {
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
clear_stale_mount(&local);
}
if !local.is_dir() {
warn!(id = folder.id, path = %local.display(), "restore on-demand: local path missing");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
// An `ondemand` folder's local dir is empty by construction — the switch
// evicts it. Finding files there means the row is lying (a switch that
// died between persisting the mode and evicting), and mounting over them
// would hide real local data behind the remote tree. Leave the files
// alone and let the user resolve it.
match dir_is_empty(&local) {
Ok(true) => {}
Ok(false) => {
warn!(
id = folder.id,
path = %local.display(),
"restore on-demand: local dir is not empty; refusing to mount over it"
);
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
Err(e) => {
warn!(id = folder.id, path = %local.display(), error = %e, "restore on-demand: cannot read local dir");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
}
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
warn!(id = folder.id, "restore on-demand: bad remote uid");
continue;
};
let root = match self
.rt
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
{
Ok(v) => match v.into_iter().next() {
Some(n) => n,
None => {
warn!(id = folder.id, "restore on-demand: remote folder gone");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
},
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
continue;
}
};
match self.spawn_ondemand_mount(&local, root) {
Ok(session) => {
self.mounts
.lock()
.insert(folder.id, (session, fuse_connection_id(&local)));
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
}
Err(e) => {
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
let _ = self
.db
.sync_folder_set_state(folder.id, "error", now_secs());
}
}
}
}
}
+898
View File
@@ -0,0 +1,898 @@
//! The write-back drain: turning queued mutations into remote calls.
//!
//! Every write the kernel hands us is answered the moment its bytes and its
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp`
//! into the mount run at disk speed and lets an offline write succeed at all.
//! This module is the other half: the worker that walks that queue and performs
//! the uploads, creates, renames and trashes it recorded.
//!
//! Two invariants matter more than anything else here, because between them
//! they are the only thing standing between a queued write and lost data:
//!
//! 1. A staged blob is the *only* copy of the user`s bytes. It is dropped only
//! after the op it belongs to has provably landed — never before, and never
//! on a failure path that might be retried.
//! 2. A failure never pauses the queue. Recording it pushes that op`s
//! `next_attempt_at` past now, so one file wedged against a vanished parent
//! cannot hold up an unrelated upload behind it.
//!
//! When the remote has moved on underneath a queued revision, the losing bytes
//! are kept as a conflict copy rather than discarded — see
//! [`Core::revision_conflict`].
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::Duration;
use pdfs_core::cache::{Baseline, StagedWrite};
use pdfs_core::control::{ActivityKind, TransferDirection};
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
use proton_drive_rs::Node;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{debug, error, info, warn};
use super::state::Intervals;
use super::transfers::CountingReader;
use super::{
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_size, now_millis, now_secs,
parse_node_uid,
};
impl Core {
/// Drain the pending-op queue: the background half of every write
/// (offline.md Phase 3).
///
/// Runs for the life of the mount. Ops are replayed oldest-first, each
/// retried with doubling backoff and *never* dropped on failure — the staged
/// blob is the only copy of the user's bytes, so a failed op stays queued
/// until it lands or the user deletes the file.
///
/// A failure does not pause the queue. Recording it pushes that op's
/// `next_attempt_at` past `now`, so the next pass simply picks the next op
/// that is due — one file wedged against a folder that no longer exists must
/// not hold up an unrelated upload behind it. The worker only blocks once
/// nothing is due at all.
pub(crate) fn run_pending_drain(&self) {
loop {
let now = now_millis();
// One row, chosen by the database. Reading the whole queue to pick
// one op made a long queue quadratic to drain, and held the shared
// connection — and so every FUSE metadata call — for the duration.
let due = self.db.next_due_op(now).unwrap_or_default();
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
self.recover_fsynced_writes();
// 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();
continue;
};
if let Err(e) = self.drain_op(&op) {
let attempts = op.attempts + 1;
let err_str = e.to_string();
// Never infer that user data is disposable from an error string
// or an arbitrary retry count. The staged blob and this row are
// the only durable description of an accepted write. Even a
// permission/quota error can become recoverable after the user
// changes account state, and deleting the row here made the blob
// unreachable after five ordinary network failures.
let backoff = DRAIN_BACKOFF_MIN
.saturating_mul(1u32 << attempts.min(6))
.min(DRAIN_BACKOFF_MAX);
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
if let Err(e) = self.db.record_op_failure(
op.id,
&err_str,
now_millis() + backoff.as_millis() as i64,
) {
error!(uid = %op.uid, error = %e, "recording a drain failure failed");
self.wait_for_drain_work();
}
}
}
}
/// Block until there is plausibly something to do: a new op, a reconnect, or
/// the shortest outstanding backoff elapsing.
pub(crate) fn wait_for_drain_work(&self) {
let (lock, cv) = &*self.drain_wake;
let mut woken = lock.lock();
if !*woken {
cv.wait_for(&mut woken, DRAIN_IDLE_POLL);
}
*woken = false;
}
/// 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,
};
let (lock, cv) = &*self.drain_wake;
let mut woken = lock.lock();
if !*woken {
cv.wait_for(&mut woken, timeout);
}
*woken = false;
}
/// Perform one queued op and retire it.
pub(crate) fn drain_op(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
match op.kind.as_str() {
OP_REVISION => self.drain_revision(op),
OP_CREATE | OP_MKDIR => self.drain_local_node(op),
OP_RENAME => self.drain_rename(op),
OP_TRASH => self.drain_trash(op),
other => Err(format!("unknown pending op kind {other:?}").into()),
}
}
/// Apply a queued rename/move to the remote.
///
/// The op is the desired end state, so the remote's current state decides
/// what actually has to be called: either half may already match (the event
/// sync saw someone else do it, or an earlier attempt got half way through
/// before failing). That also makes the whole thing idempotent, which a
/// retrying queue needs.
pub(crate) fn drain_rename(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let uid = parse_node_uid(&op.uid).ok_or("rename op has an unparseable uid")?;
let parent_str = op.parent_uid.as_deref().ok_or("rename op has no parent")?;
if is_local_uid_str(parent_str) {
return Err(format!("destination {parent_str} has not been created yet").into());
}
let parent = parse_node_uid(parent_str).ok_or("rename op has an unparseable parent")?;
let name = op.name.clone().ok_or("rename op has no name")?;
// The node we were asked to rename may be gone or trashed by now. Either
// way there is nothing to rename and nothing to lose — a rename holds no
// bytes — so the op is satisfied rather than retried forever.
let node = match self.fetch_node_remote(&uid)? {
Some(n) if !n.trashed => n,
_ => {
warn!(%uid, name, "renamed node is gone or trashed remotely; dropping the rename");
self.db.delete_op(op.id)?;
return Ok(());
}
};
// The name half goes first, so that a collision on the move half below is
// about the name the node will actually land under rather than the one it
// is about to lose.
let mut landed = node.name.clone();
if landed != name {
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
Ok(()) => landed = name.clone(),
// Someone took the name while we were offline. Renaming to a
// *different* name is the non-destructive resolution: it neither
// clobbers their file nor drops ours, and it is visible.
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&name, now_secs());
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
self.rt
.block_on(self.client.rename_node(&uid, &alt, None))?;
landed = alt.clone();
self.adopt_drained_name(&uid, &alt);
self.log_activity(
ActivityKind::Rename,
&name,
format!("name was taken remotely; renamed to {alt}"),
false,
);
}
Err(e) => return Err(e.into()),
}
}
if node.parent_uid.as_ref() != Some(&parent) {
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
Ok(()) => {}
// The destination holds a `landed` of its own. Same resolution as
// a name collision, and it has to happen before the move: the API
// has no move-and-rename, so the node is renamed out of the way
// here and moved second.
Err(e) if is_already_exists(&e) => {
let alt = conflict_name(&landed, now_secs());
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
self.rt
.block_on(self.client.rename_node(&uid, &alt, None))?;
self.rt.block_on(self.client.move_node(&uid, &parent))?;
self.adopt_drained_name(&uid, &alt);
self.log_activity(
ActivityKind::Rename,
&landed,
format!("destination already had that name; moved as {alt}"),
false,
);
}
// The destination folder is gone. Leaving the node in its current
// parent is the honest outcome: it is not where the user asked for
// it, but it exists, it is where it has always been, and the
// rename half above still applied. Retrying could only fail again
// — the folder is not coming back — and would wedge the queue.
Err(e) if is_gone(&e) => {
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
self.log_activity(
ActivityKind::Rename,
&landed,
"destination folder no longer exists; the file was left in place"
.to_string(),
false,
);
}
Err(e) => return Err(e.into()),
}
}
self.db.delete_op(op.id)?;
info!(%uid, name = %landed, "pending rename landed");
Ok(())
}
/// Apply a queued trash to the remote.
///
/// A node that is already gone is a success, not a failure: the outcome the
/// op asked for holds either way, and retrying forever against a node the
/// server has forgotten would wedge the queue.
pub(crate) fn drain_trash(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let uid = parse_node_uid(&op.uid).ok_or("trash op has an unparseable uid")?;
let name = op.name.clone().unwrap_or_else(|| op.uid.clone());
match self
.rt
.block_on(self.client.trash_nodes(std::slice::from_ref(&uid)))
{
Ok(()) => {}
Err(e) if is_gone(&e) => {
debug!(%uid, name, "node was already gone remotely; trash op satisfied");
}
Err(e) => return Err(e.into()),
}
self.db.delete_op(op.id)?;
self.invalidate_trash();
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
info!(%uid, name, "pending trash landed");
Ok(())
}
/// Record the name the remote actually gave a node, after a conflict forced
/// it away from the one the user asked for. Best effort: the event sync
/// would correct the tree anyway, but not before the user has looked at it.
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
let mut st = self.state.lock();
let Some(&ino) = st.by_uid.get(uid) else {
return;
};
let Some(entry) = st.entries.get_mut(&ino) else {
return;
};
entry.node.name = name.to_string();
let node = entry.node.clone();
if let Err(e) = st.db.upsert_node(&node) {
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
}
}
/// Make a node that so far exists only on this machine real, and adopt the
/// uid the server gives it (offline.md Phase 3b).
pub(crate) fn drain_local_node(
&self,
op: &PendingOp,
) -> Result<(), Box<dyn std::error::Error>> {
let local = parse_node_uid(&op.uid).ok_or("pending op has an unparseable uid")?;
let parent_str = op.parent_uid.as_deref().ok_or("create op has no parent")?;
// `run_pending_drain` will not offer an op whose parent is still a
// placeholder, so reaching here with one is a bug, not a wait.
if is_local_uid_str(parent_str) {
return Err(format!("parent {parent_str} has not been created yet").into());
}
let parent = parse_node_uid(parent_str).ok_or("create op has an unparseable parent")?;
let wanted = op.name.clone().ok_or("create op has no name")?;
// Someone else may have taken the name while this sat in the queue —
// reachable only because the op waited, which is the whole point of the
// queue. Never overwrite theirs and never drop ours: land under a
// conflict name, exactly as the sync engine does.
let mut name = wanted.clone();
let mut real = self.create_drained_node(op, &parent, &name);
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
name = conflict_name(&wanted, now_secs());
warn!(%local, wanted, name, "name is taken remotely; creating under a conflict name");
real = self.create_drained_node(op, &parent, &name);
if real.is_ok() {
self.log_activity(
ActivityKind::Upload,
&wanted,
format!("name was taken remotely; created as {name}"),
false,
);
}
}
// The folder this node was created in may have been trashed remotely
// while the op waited, in which case nothing will ever make the op
// succeed as written and retrying it forever wedges the queue behind a
// file that has bytes to save. Re-home it to the root: not where the
// user put it, but it exists, it is visible, and the bytes are intact.
if let Some(root) = self.root_uid()
&& real.is_err()
&& self.parent_is_gone(&parent)
{
warn!(%local, name, "parent folder is gone remotely; creating in the root instead");
real = self.create_drained_node(op, &root, &name);
if real.as_ref().is_err_and(|e| is_already_exists(e.as_ref())) {
name = conflict_name(&wanted, now_secs());
real = self.create_drained_node(op, &root, &name);
}
if real.is_ok() {
self.log_activity(
ActivityKind::Upload,
&wanted,
format!("its folder was trashed remotely; created in the root as {name}"),
false,
);
}
}
let real = real?;
// Retire the op before touching anything else: if we crash here the node
// exists remotely and the local placeholder is reconciled by the event
// sync, whereas a surviving op would create the file a second time.
self.db.delete_op(op.id)?;
self.adopt_real_uid(&local, &real)?;
if let Some(blob) = op.blob_path.as_deref() {
self.cache.discard_staged(Path::new(blob));
}
self.pending.lock().remove(&local);
self.log_activity(ActivityKind::Upload, &name, "created", true);
info!(%local, %real, name, kind = %op.kind, "pending create landed");
Ok(())
}
/// Make one queued `create`/`mkdir` real under a given name, and hand back
/// the API's own error so the caller can tell a name clash from a failure.
///
/// Split out because the conflict path has to run it twice: the second time
/// under a different name.
pub(crate) fn create_drained_node(
&self,
op: &PendingOp,
parent: &NodeUid,
name: &str,
) -> Result<NodeUid, Box<dyn std::error::Error>> {
match op.kind == OP_MKDIR {
true => {
Ok(self
.rt
.block_on(self.client.create_folder(parent, name, Some(now_secs())))?)
}
false => self.upload_created_file(op, parent, name),
}
}
/// Upload the bytes a queued create accumulated, if any. A file that was
/// created but never written (`touch`) has no blob and uploads as empty.
pub(crate) fn upload_created_file(
&self,
op: &PendingOp,
parent: &NodeUid,
name: &str,
) -> Result<NodeUid, Box<dyn std::error::Error>> {
let Some(blob) = op.blob_path.as_deref() else {
return Ok(self.rt.block_on(self.client.upload_file(
parent,
name,
media_type_for(name),
b"",
))?);
};
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
// An incomplete blob would be authored bytes over zeros, and there is no
// base to repair it from — the file has never existed remotely. Refusing
// to queue that is `queue_revision`'s job, so reaching here means the blob
// is whole.
if !meta.complete {
return Err("queued create holds an incomplete blob".into());
}
let guard = self
.transfers
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
let reader = CountingReader::new(File::open(blob)?, &guard);
let uid = self.rt.block_on(self.client.upload_file_from(
parent,
name,
media_type_for(name),
reader,
meta.len as i64,
Vec::new(),
None,
false,
))?;
Ok(uid)
}
/// Swap a placeholder uid for the real one across everything that keyed off
/// it: queued children, the DB, the in-memory tree, and the caches.
///
/// The inode is deliberately kept, so anything already holding the file open
/// keeps working across the drain.
pub(crate) fn adopt_real_uid(
&self,
local: &NodeUid,
real: &NodeUid,
) -> Result<(), Box<dyn std::error::Error>> {
let node = self
.fetch_node(real)
.map_err(|e| self.errno_error(e, "fetch node"))?;
// Repoints queued children and node rows, and drops the placeholder row.
self.db
.remap_local_uid(&local.to_string(), &real.to_string())?;
let mut st = self.state.lock();
if let Some(ino) = st.by_uid.remove(local) {
st.by_uid.insert(real.clone(), ino);
if let Some(e) = st.entries.get_mut(&ino) {
e.uid = real.clone();
e.node = node.clone();
}
// Where the op said the node goes and where it actually landed can
// differ — a conflict re-homes it — so the tree follows the parent
// the server reports rather than the one we asked for.
if let Some(parent) = node.parent_uid.clone()
&& let Some(&pino) = st.by_uid.get(&parent)
&& st.entries.get(&ino).is_some_and(|e| e.parent != pino)
{
let old = st.entries.get(&ino).map(|e| e.parent);
if let Some(old) = old
&& let Some(kids) = st.children.get_mut(&old)
{
kids.retain(|&k| k != ino);
}
if let Some(e) = st.entries.get_mut(&ino) {
e.parent = pino;
}
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
}
}
drop(st);
// Write the real node through, now that nothing points at the old uid.
if let Err(e) = self.db.upsert_node(&node) {
warn!(%real, error = %e, "db upsert_node failed after remap");
}
if node.is_folder() {
// It was recorded as listed while local (it was empty and had nothing
// to enumerate). That still holds: its queued children re-intern under
// the real uid as they drain.
if let Err(e) = self.db.set_listed(real, true) {
warn!(%real, error = %e, "db set_listed(true) failed after remap");
}
}
Ok(())
}
/// Why a queued write must not be applied to its node, or `None` when it
/// still can be.
///
/// A queued write is an edit of a specific revision. Time passes before it
/// drains — indefinitely, if that is how long the network is gone — and in
/// that window the node can be rewritten by another device, trashed, or
/// deleted outright. Sending the blob anyway would silently drop whatever
/// happened in between, which is exactly the thing the sync engine refuses
/// to do (offline.md Phase 3b).
///
/// Only checkable against a recorded baseline: a write staged before
/// [`StagedWrite::based_on`] existed, or one against a node that has never
/// existed remotely, has nothing to compare and is applied as before.
pub(crate) fn revision_conflict(
&self,
uid: &NodeUid,
meta: &StagedWrite,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
let Some(ref base) = meta.based_on else {
return Ok(None);
};
let Some(node) = self.fetch_node_remote(uid)? else {
return Ok(Some("the file no longer exists remotely".into()));
};
if node.trashed {
return Ok(Some("the file was trashed remotely".into()));
}
let (mtime, size) = (node.modification_time, node_size(&node));
if mtime != base.mtime || size != base.size {
return Ok(Some(format!(
"the remote revision changed under the queued write \
(expected {} bytes at mtime {}, found {size} at {mtime})",
base.size, base.mtime
)));
}
Ok(None)
}
/// Land a queued write that can no longer be applied to its own node as a
/// *new* file beside it, and retire the op.
///
/// The non-destructive resolution, and the same one the sync engine reaches
/// for: the remote keeps whatever it has, the user keeps their bytes, and
/// the name says which is which.
///
/// An incomplete blob is gap-filled from whatever the node holds *now* —
/// mixing revisions, which is only defensible because the result is
/// explicitly a conflict copy rather than anyone's file. When even that is
/// impossible the op is dropped but the staged bytes are deliberately left
/// on disk: unreachable through the mount, but not destroyed, and the
/// activity log says where they are.
pub(crate) fn keep_as_conflict_copy(
&self,
op: &PendingOp,
blob: &Path,
meta: &StagedWrite,
uid: &NodeUid,
reason: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let place = self.node_place(uid);
let name = place
.as_ref()
.map(|(_, n)| n.clone())
.unwrap_or_else(|| self.node_name(uid));
// A node the tree has forgotten still has bytes worth keeping, so the
// copy falls back to the root rather than being abandoned.
let Some(parent) = place.map(|(p, _)| p).or_else(|| self.root_uid()) else {
return self.abandon_to_staging(op, blob, uid, &name, reason);
};
warn!(%uid, name, reason, "queued write conflicts; keeping a conflict copy");
if !meta.complete {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(blob)?;
let mut written = Intervals::default();
for &(s, e) in &meta.authored {
written.add(s, e);
}
if let Err(e) = self.fill_gaps(
uid,
&file,
meta.len,
meta.base_mtime,
meta.base_size,
&written,
) {
error!(%uid, name, error = ?e, "cannot complete a conflicted partial write");
return self.abandon_to_staging(op, blob, uid, &name, reason);
}
}
let alt = conflict_name(&name, now_secs());
let guard =
self.transfers
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
let reader = CountingReader::new(File::open(blob)?, &guard);
self.rt.block_on(self.client.upload_file_from(
&parent,
&alt,
media_type_for(&alt),
reader,
meta.len as i64,
Vec::new(),
None,
false,
))?;
drop(guard);
self.db.delete_op(op.id)?;
// Dropping the pending entry hands the node back to the remote's truth:
// reads stop coming from the staged blob, and the event sync stops
// skipping it as "ahead of the server" (offline.md Phase 3a).
self.pending.lock().remove(uid);
self.cache.discard_staged(blob);
self.cache.evict(uid);
self.evict_reader(uid);
// The conflict copy is a node the tree has never seen.
let mut st = self.state.lock();
if let Some(&ino) = st.by_uid.get(&parent) {
st.invalidate_listing(ino);
}
drop(st);
self.log_activity(
ActivityKind::Upload,
&name,
format!("{reason}; local changes uploaded as {alt}"),
false,
);
info!(%uid, name, alt, "queued write landed as a conflict copy");
Ok(())
}
/// Give up on placing a queued write anywhere the mount can see, without
/// destroying it: the op goes (it could only fail forever) but the staged
/// blob deliberately stays on disk, and the activity log says where.
///
/// The last resort of the conflict path, and the same bargain
/// [`Core::stage_orphaned_write`] strikes at the other end: bytes we cannot
/// place are still bytes we do not get to delete.
pub(crate) fn abandon_to_staging(
&self,
op: &PendingOp,
blob: &Path,
uid: &NodeUid,
name: &str,
reason: &str,
) -> Result<(), Box<dyn std::error::Error>> {
error!(%uid, name, reason, staged = %blob.display(),
"cannot place a conflicted write; bytes kept in staging");
self.db.delete_op(op.id)?;
self.pending.lock().remove(uid);
self.cache.evict(uid);
self.evict_reader(uid);
self.log_activity(
ActivityKind::Upload,
name,
format!("{reason}; local changes kept at {}", blob.display()),
false,
);
Ok(())
}
/// Whether a folder a queued op targets has stopped being a place a node can
/// go. A failure to ask is not an answer: only a definite "trashed" or "not
/// there" counts, so a network fault leaves the op to retry normally.
pub(crate) fn parent_is_gone(&self, parent: &NodeUid) -> bool {
match self.fetch_node_remote(parent) {
Ok(Some(node)) => node.trashed,
Ok(None) => true,
Err(_) => false,
}
}
/// A node's parent uid and name, as the tree currently has them.
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
{
let st = self.state.lock();
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
&& let Some(parent) = entry.node.parent_uid.clone()
{
return Some((parent, entry.node.name.clone()));
}
}
// The in-memory tree only holds what has been walked to since the daemon
// started, and the drain routinely runs before anything has walked to
// this file — after a restart, or for a write that arrived by path. The
// DB knows it anyway. Without this fallback a conflict copy was named
// after the node's *uid* and dumped in the root, which is how a file
// called `G88km…==~c2do…== (sync-conflict 1784429627)` appears at the
// top of someone's Drive.
let node = self.db.node_by_uid(&uid.to_string()).ok().flatten()?;
Some((node.parent_uid.clone()?, node.name.clone()))
}
/// The name to show a user for `uid`, for a transfer entry or an activity
/// log line.
///
/// Falls back to something short and human rather than to the uid: a uid is
/// 130 characters of base64 that identifies the file to us and to nobody
/// else, and it has ended up in both the activity log and on real files in
/// the Drive root. The link prefix keeps it traceable without pretending to
/// be a name.
pub(crate) fn node_name(&self, uid: &NodeUid) -> String {
self.node_place(uid).map(|(_, n)| n).unwrap_or_else(|| {
let short: String = uid.link_id.to_string().chars().take(8).collect();
format!("recovered-{short}")
})
}
/// The uid of the My Files root, which every node in the mount descends
/// from — the last resort for placing a file whose own parent is unknown.
///
/// `None` only for a [`Core::fork_state`] sibling that has not interned its
/// root yet, which is not where the drain runs; the drain must not panic
/// over it regardless, since that would stop the queue for good.
pub(crate) fn root_uid(&self) -> Option<NodeUid> {
let st = self.state.lock();
st.entries.get(&ROOT_INO).map(|e| e.uid.clone())
}
/// Upload a staged revision of a file the server already knows about.
pub(crate) fn drain_revision(&self, op: &PendingOp) -> Result<(), Box<dyn std::error::Error>> {
let blob = op
.blob_path
.clone()
.ok_or("pending op has no staged blob")?;
let blob = PathBuf::from(blob);
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
if let Some(reason) = self.revision_conflict(&uid, &meta)? {
return self.keep_as_conflict_copy(op, &blob, &meta, &uid, &reason);
}
// An incomplete blob is authored bytes over zeros; the untouched ranges
// have to be filled from the base before it can be sent. This is the case
// the write could not resolve at release time (it was offline), and the
// reason it is safe to do now is that we are not.
if !meta.complete {
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&blob)?;
let mut written = Intervals::default();
for &(s, e) in &meta.authored {
written.add(s, e);
}
self.fill_gaps(
&uid,
&file,
meta.len,
meta.base_mtime,
meta.base_size,
&written,
)
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
}
let name = self.node_name(&uid);
let guard =
self.transfers
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
let reader = CountingReader::new(File::open(&blob)?, &guard);
self.rt.block_on(self.client.upload_new_revision_from(
&uid,
reader,
meta.len as i64,
Vec::new(),
None,
))?;
drop(guard);
// Retire the op before dropping the blob: a crash between the two leaves
// an orphaned file (harmless), whereas the reverse would leave a queued
// op pointing at nothing.
self.db.delete_op(op.id)?;
{
let mut p = self.pending.lock();
if let Some(op_in_mem) = p.get(&uid)
&& op_in_mem.path == blob
{
p.remove(&uid);
}
}
// The staged blob now matches the sealed revision, so a pinned file keeps
// it as its cached content rather than re-downloading what we just sent.
// Adopted by link, not by value: the blob is the whole file, and a pinned
// video read into a `Vec` here is an OOM. `discard_staged` below drops the
// staging name only — the cache keeps the inode.
if self.cache.is_pinned(&uid) {
let _ = self.cache.store_file(&uid, now_secs(), meta.len, &blob);
}
self.cache.discard_staged(&blob);
self.evict_reader(&uid);
self.refresh_after_upload(&uid);
self.log_activity(ActivityKind::Upload, &name, "uploaded", true);
info!(%uid, len = meta.len, "pending upload landed");
Ok(())
}
/// Adopt the server's metadata for a node we have just uploaded a revision
/// for.
///
/// [`State::record_pending_write`] deliberately stamps the node with the
/// moment we *accepted* the write, so `ls` reflects it before the upload
/// lands. The server stamps the sealed revision with its own time, and the
/// two differ by however long the upload took. That difference is not
/// cosmetic: [`Core::remote_baseline`] reads this node to build
/// [`StagedWrite::based_on`], and [`Core::revision_conflict`] compares that
/// baseline against the remote — so leaving our optimistic time in place
/// makes the *next* write to this file look like another device changed it
/// underneath us, and diverts it into a conflict copy over nothing.
///
/// A write queued while this upload was on the wire is handled instead by
/// [`Core::rebaseline_pending`]: its optimistic size/mtime must stay on the
/// node (the same reason `apply_event` refuses to overwrite a node with a
/// queued write), but its *baseline* still has to learn about the revision
/// we just sealed. Skipping both, as this used to, is what produced a file
/// that conflicted with itself and left a full-size duplicate behind.
///
/// Best effort: a failure here costs a spurious conflict copy on the next
/// write, not this upload, which has already landed.
pub(crate) fn refresh_after_upload(&self, uid: &NodeUid) {
let node = match self.fetch_node_remote(uid) {
Ok(Some(node)) => node,
// Trashed or deleted under us: the tree will hear it from the event
// sync, which is better placed to unhook the inode than we are.
Ok(None) => return,
Err(e) => {
warn!(%uid, error = %e,
"refreshing metadata after an upload failed; \
the next write to this file may conflict with itself");
return;
}
};
// Rebase any *open* write handles targeting this node. A handle opened
// before this upload carries a stale base — its `base_mtime`/`base_size`
// name the revision we just replaced. Without this update,
// `remote_baseline` will build a `based_on` from those stale values, the
// drain will compare them against the revision we sealed, and the write
// will be diverted into a conflict copy of itself.
//
// This is the open-handle counterpart of `rebaseline_pending`, which
// covers the same gap for *queued* ops.
{
let sealed_mtime = node.modification_time;
let sealed_size = node_size(&node);
let mut st = self.state.lock();
for aw in st.active_writes.values_mut() {
if aw.uid == *uid {
aw.base_mtime = sealed_mtime;
aw.base_size = sealed_size;
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
"rebased open write handle onto the revision just uploaded");
}
}
}
// Ordered so that a write queued *during* the fetch above is still
// caught: it took its baseline from the node's optimistic stamp, and
// this overwrites it with the revision the server actually holds.
if self.rebaseline_pending(uid, &node) {
return;
}
let mut st = self.state.lock();
let Some(parent) = st
.by_uid
.get(uid)
.and_then(|ino| st.entries.get(ino))
.map(|e| e.parent)
else {
return;
};
st.intern(parent, node);
}
/// Point a still-queued write at the revision this upload just sealed, and
/// report whether there was one.
///
/// The revision we sent *is* the base the queued write will be applied over,
/// but nothing else says so. [`Core::remote_baseline`] carries a baseline
/// across a supersede because the op it inherits from "is the last one that
/// actually observed the remote" — true only until that op drains. Once it
/// has, the inherited baseline names a revision we replaced ourselves, and
/// [`Core::revision_conflict`] reads that as another device having moved the
/// file. It is a self-conflict, and it is expensive: the whole staged blob
/// is re-uploaded as a second file.
///
/// Both copies of the sidecar are updated — the in-memory one the next
/// `release` inherits from, and the persisted one the drain reloads after a
/// restart. Best effort on the DB half; the in-memory half is what the
/// immediate next write reads.
fn rebaseline_pending(&self, uid: &NodeUid, sealed: &Node) -> bool {
let base = Baseline {
mtime: sealed.modification_time,
size: node_size(sealed),
hash: None,
};
let mut pending = self.pending.lock();
let Some(p) = pending.get_mut(uid) else {
return false;
};
p.meta.based_on = Some(base.clone());
match serde_json::to_string(&p.meta) {
Ok(json) => {
if let Err(e) = self.db.update_op_meta(&uid.to_string(), OP_REVISION, &json) {
warn!(%uid, error = %e,
"restamping a queued write's baseline failed; \
it may land as a conflict copy of itself");
}
}
Err(e) => warn!(%uid, error = %e, "serializing a restamped sidecar failed"),
}
debug!(%uid, mtime = base.mtime, size = base.size,
"queued write rebaselined onto the revision just uploaded");
true
}
}
File diff suppressed because it is too large. Load diff
+2548 -2252
View File
File diff suppressed because it is too large. Load diff
+392
View File
@@ -0,0 +1,392 @@
//! Mount lifecycle and background-service orchestration.
use super::*;
/// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean
/// shutdown) or remount (the mount went away under it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountOutcome {
/// The daemon was asked to stop (SIGTERM/SIGINT) and we lazily unmounted
/// ourselves. The caller should exit cleanly.
Shutdown,
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
/// The caller may want to remount.
Unmounted,
}
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
/// daemon killed before it could unmount leaves behind.
///
/// The kernel answers every operation on such a path with `ENOTCONN`, which the
/// ordinary existence checks (`is_dir`, `exists`) report as plain `false`: the
/// path looks *absent* rather than broken. Callers that would otherwise treat
/// that as "nothing to mount here" use this to tell the two apart.
pub(crate) fn is_stale_mount(path: &Path) -> bool {
matches!(
std::fs::metadata(path).map_err(|e| e.raw_os_error()),
Err(Some(libc::ENOTCONN))
)
}
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE
/// connection id so teardown can abort a mid-transfer connection rather than
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`].
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>);
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
/// number of the mountpoint's device. Must be read *while still mounted*; after
/// unmount the path resolves to the underlying directory on another device.
/// `None` when the path can't be stat'd.
pub(super) fn fuse_connection_id(mountpoint: &Path) -> Option<u32> {
use std::os::unix::fs::MetadataExt;
let dev = std::fs::metadata(mountpoint).ok()?.dev();
Some(libc::minor(dev))
}
/// Force the kernel to abort FUSE connection `id`, erroring every in-flight
/// request.
///
/// On a stop signal we unmount lazily (`MNT_DETACH`) so the call succeeds even
/// mid-transfer — but detach only removes the mountpoint; it does *not* end the
/// connection while a request is still in flight. fuser's session loop then
/// blocks on `/dev/fuse` waiting for a next request that never comes, so `join`
/// hangs — long enough during a transfer that systemd's stop timeout SIGKILLs
/// the daemon mid-unmount, stranding the on-demand mounts as dead endpoints.
/// Writing the connection's `abort` file makes the pending reads fail with
/// `ENODEV`, so the loop returns and `join` completes at once. Best-effort:
/// there is nothing more to do on the shutdown path if it fails.
pub(super) fn abort_fuse_connection(id: u32) {
let path = format!("/sys/fs/fuse/connections/{id}/abort");
if std::fs::write(&path, b"1").is_ok() {
info!(id, "aborted FUSE connection to unblock unmount");
}
}
/// Unmount a background session that may be mid-transfer without wedging: abort
/// its connection first (so the session loop exits promptly), then lazily
/// unmount and join. `conn` is the id captured at mount time; `None` skips the
/// abort and just unmounts (a healthy idle mount joins on its own `Destroy`).
pub(super) fn umount_session_unblocked(
session: BackgroundSession,
conn: Option<u32>,
) -> std::io::Result<()> {
if let Some(id) = conn {
abort_fuse_connection(id);
}
session.umount_and_join()
}
/// Best-effort teardown of a stale mount left behind by a crashed daemon. A
/// previous run that died without unmounting leaves the kernel mount in place,
/// so the fresh `Session::new` below would fail with EBUSY ("Device or resource
/// busy"). `fusermount3 -u -z` is the lazy (detach) unmount, which succeeds even
/// when the old mount is still busy. Swallow all output/errors: if there is no
/// stale mount this is simply a no-op.
pub(super) fn clear_stale_mount(mountpoint: &Path) {
for bin in ["fusermount3", "fusermount"] {
let ok = std::process::Command::new(bin)
.arg("-u")
.arg("-z")
.arg(mountpoint)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
info!(mountpoint = %mountpoint.display(), "cleared stale mount before remount");
return;
}
}
}
/// `sync_state` key holding the uid of the My Files root, so a later run can
/// recover it from `nodes` without the API (offline.md Phase 1).
const ROOT_UID_KEY: &str = "root_uid";
/// The My Files root, and whether we got it from the API (`true`) or from the
/// cache because the API was unreachable (`false`).
///
/// A successful fetch also records the root's uid, which is what makes the
/// fallback possible on a later run: `nodes` is keyed by uid, so without this we
/// would have the root's row on disk and no way to tell which one it is.
///
/// Failing to fetch is only fatal on a genuinely cold start — no cached root
/// means an empty tree, and mounting that would show the user an empty Drive
/// rather than an honest error.
fn fetch_or_recall_root(
client: &ProtonDriveClient,
rt: &tokio::runtime::Handle,
db: &Db,
) -> std::io::Result<(Node, bool)> {
let err = match rt.block_on(client.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));
}
Err(e) => e,
};
let cached = db
.state_str(ROOT_UID_KEY)
.ok()
.flatten()
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
match cached {
Some(root) => {
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
Ok((root, false))
}
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
}
}
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
/// daemon is asked to stop.
///
/// Resolves the My Files root up front — from the API, or from the cached tree
/// when the API is unreachable (`fetch_or_recall_root`) — then spawns the
/// Phase 2 event-sync task, the
/// Phase 4 control socket, and runs the FUSE session on its own thread while
/// this thread waits for either a stop signal (SIGTERM/SIGINT) or the kernel
/// mount ending on its own. On a stop signal we lazily unmount ourselves
/// (`umount_and_join`, the MNT_DETACH path that succeeds even while downloads
/// are in flight), so `systemctl --user stop` is always a clean teardown.
/// `rt` must be a handle to a *running* multi-threaded runtime.
pub fn mount(
client: ProtonDriveClient,
rt: tokio::runtime::Handle,
mountpoint: &Path,
cache: ContentCache,
control_socket: &Path,
db: Arc<Db>,
username: String,
) -> std::io::Result<MountOutcome> {
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
let scope = root.tree_event_scope_id();
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
// nudged over this channel; the sender lives in Core so control-socket
// handlers can trigger reconciles.
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
let core = Core {
client: client.clone(),
rt: rt.clone(),
state: Arc::new(Mutex::new(State {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino: 2,
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
db: db.clone(),
})),
cache: Arc::new(cache),
readers: Arc::new(Mutex::new(HashMap::new())),
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
db,
online: Arc::new(AtomicBool::new(online)),
pending: Arc::new(Mutex::new(HashMap::new())),
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
notifier: Arc::new(OnceLock::new()),
transfers: TransferRegistry::new(),
indexing: Arc::new(AtomicBool::new(false)),
sync_progress: Arc::new(Mutex::new(HashMap::new())),
sync_tx,
mounts: Arc::new(Mutex::new(HashMap::new())),
sync_locks: Arc::new(Mutex::new(HashMap::new())),
};
// Writes queued by a previous run (or left behind by a crash) are still owed
// an upload, and reads must be served from their staged blobs until they land.
core.hydrate_pending();
// Then the writes that were fsynced but never closed, which the cache moved
// aside at open. After `hydrate_pending`, so a recovered partial write can
// see an already-queued write to the same node.
core.recover_fsynced_writes();
{
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-drain".into())
.spawn(move || core.run_pending_drain())?;
}
// Start the folder-sync engine. It watches every mirror folder, polls the
// remotes, and reconciles on its own thread — never in front of a FUSE call.
sync::spawn(core.clone(), sync_rx);
// Mounted from the cache: watch for the network coming back so the mount can
// stop being read-only-ish without the user restarting the daemon.
if !online {
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-online-probe".into())
.spawn(move || core.run_online_probe())?;
}
// Re-establish on-demand mounts left over from a previous run (devices.md
// Phase 4). On its own thread: each remount fetches a remote node, and we
// must not block the main mount below on the network.
{
let core = core.clone();
std::thread::Builder::new()
.name("pdfs-restore-ondemand".into())
.spawn(move || core.restore_ondemand_mounts())?;
}
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
{
let db = core.db.clone();
let indexing = core.indexing.clone();
let transfers = core.transfers.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-localindex".into())
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
}
let mut config = Config::default();
config.mount_options = vec![
MountOption::FSName("protondrive".to_string()),
MountOption::Subtype("protondrive".to_string()),
MountOption::DefaultPermissions,
];
// A crashed previous run can leave the kernel mount in place, which makes
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
clear_stale_mount(mountpoint);
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
// Bind the control socket before the FUSE session takes over the thread. A
// stale socket file from a previous run would block the bind, so clear it.
let _ = std::fs::remove_file(control_socket);
let old_umask = unsafe { libc::umask(0o77) };
let listener_res = UnixListener::bind(control_socket);
unsafe { libc::umask(old_umask) };
let listener = listener_res?;
// Owner-only before anything can connect: a peer on this socket commands the
// daemon's authenticated session without a credential of its own (B6).
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
let _ = std::fs::remove_file(control_socket);
return Err(std::io::Error::other(format!(
"control socket permissions: {e}"
)));
}
{
let core = core.clone();
let username = username.clone();
let mountpoint = mountpoint.to_path_buf();
std::thread::Builder::new()
.name("pdfs-control".into())
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
}
let fs = ProtonFs::new(core.clone(), root);
// Warm the in-memory maps from the DB so a cold start serves previously
// seen metadata without re-hitting the API (plan.md P1).
core.hydrate();
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
// for the event task. `spawn` runs the session loop on its own background
// thread; we then wait here for either a stop signal or the mount ending.
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
// The connection id, captured now while the mount is live, so a stop signal
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
let main_conn = fuse_connection_id(mountpoint);
let notifier = bg.notifier();
// Same channel, kept on the `Core` so background work (a size upgrade, say)
// can invalidate kernel-cached metadata without threading a handle through.
let _ = core.notifier.set(notifier.clone());
rt.spawn(run_event_sync(
client,
scope,
core.state,
core.cache,
core.db,
core.pending,
notifier,
));
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
// are delivered onto the async runtime; bridge them onto a sync channel so
// the loop below can react without blocking a worker thread. A bounded
// channel of 1 is enough — we only need to know that *a* stop arrived.
let (sig_tx, sig_rx) = std::sync::mpsc::sync_channel::<()>(1);
rt.spawn(async move {
let mut sigterm =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGTERM handler failed");
return;
}
};
let mut sigint =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "install SIGINT handler failed");
return;
}
};
tokio::select! {
_ = sigterm.recv() => info!("received SIGTERM"),
_ = sigint.recv() => info!("received SIGINT"),
}
let _ = sig_tx.try_send(());
});
// Wait for whichever happens first: a stop signal (→ we unmount ourselves
// via the lazy MNT_DETACH path, clean even mid-download), or the kernel
// mount ending on its own (→ the session thread finishes). Poll instead of
// blocking on `join` so we can also notice the signal.
let outcome = loop {
match sig_rx.recv_timeout(Duration::from_millis(500)) {
Ok(()) => {
info!("stop requested; unmounting");
if let Err(e) = umount_session_unblocked(bg, main_conn) {
warn!(error = %e, "umount_and_join failed");
}
break MountOutcome::Shutdown;
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if bg.guard.is_finished() {
info!("mount ended externally");
if let Err(e) = bg.join() {
warn!(error = %e, "session join failed");
}
break MountOutcome::Unmounted;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// Signal task gone (failed to install); fall back to join.
let _ = bg.join();
break MountOutcome::Unmounted;
}
}
};
// Unmount every on-demand sync folder too, or the kernel mounts linger as
// stale and the next start fails with EBUSY (devices.md Phase 3).
let secondaries: Vec<_> = core.mounts.lock().drain().collect();
for (id, (session, conn)) in secondaries {
if let Err(e) = umount_session_unblocked(session, conn) {
warn!(id, error = %e, "unmount on-demand folder failed");
}
}
let _ = std::fs::remove_file(control_socket);
Ok(outcome)
}
+488
View File
@@ -0,0 +1,488 @@
//! The photos timeline and its thumbnails.
//!
//! The timeline is a flat, newest-first projection of the photo share, persisted
//! by [`pdfs_core::db`] so the gallery opens instantly and refreshes behind the
//! user. Thumbnails the server has none for (anything a camera wrote rather than
//! a phone) are generated locally and stored as if the server had served them.
use std::collections::HashMap;
use std::path::PathBuf;
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
use pdfs_core::db::{self, StoredPhoto};
use pdfs_core::{CoreError, CoreResult};
use std::sync::atomic::Ordering;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{NodeKind, ThumbnailType};
use tracing::{info, warn};
use super::{
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, node_size,
now_ms, parse_uid,
};
/// Longest edge, in px, of a thumbnail generated locally for a photo the server
/// has none for. Matches the server's own thumbnail scale closely enough that a
/// tile can't tell them apart.
const THUMB_EDGE: u32 = 512;
/// JPEG quality of a locally generated thumbnail.
const THUMB_QUALITY: u8 = 82;
/// How many photos may be downloaded at once to generate their missing
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
/// saturate the link and starve the rest of the daemon.
const THUMB_GEN_CONCURRENCY: usize = 4;
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
/// pixels are decoded. `None` when the format is unknown or the header is torn.
fn ratio_of(bytes: &[u8]) -> Option<f64> {
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()?;
(height > 0).then(|| f64::from(width) / f64::from(height))
}
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
/// made from (free at that point — the full image had to be decoded anyway).
struct GeneratedThumb {
bytes: Vec<u8>,
ratio: f64,
}
/// How one attempt at generating a missing thumbnail ended. The distinction that
/// matters is *permanent* versus *transient*: only bytes we cannot decode prove
/// the photo will never have a thumbnail, and only that verdict is persisted.
enum ThumbAttempt {
Made(GeneratedThumb),
/// Decoded nothing — a format this build has no decoder for. Permanent.
Undecodable,
/// The photo couldn't be downloaded. Transient: try again next time.
Unavailable,
}
/// Scale a full-size photo down to a thumbnail: at most [`THUMB_EDGE`] on its
/// longest side, JPEG, aspect ratio preserved. `None` when the bytes aren't an
/// image this build can decode — the caller then writes the photo off as
/// un-thumbnailable.
///
/// CPU-bound (a 20 MP JPEG is real work), so callers run it on the blocking pool.
fn scale_thumbnail(bytes: &[u8]) -> Option<GeneratedThumb> {
let image = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let (width, height) = (image.width(), image.height());
if width == 0 || height == 0 {
return None;
}
let ratio = f64::from(width) / f64::from(height);
// `thumbnail` fits the image *inside* the box, so the longest edge lands on
// THUMB_EDGE and the ratio is untouched.
let thumb = image.thumbnail(THUMB_EDGE, THUMB_EDGE).to_rgb8();
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, THUMB_QUALITY)
.encode_image(&thumb)
.ok()?;
Some(GeneratedThumb { bytes, ratio })
}
impl Core {
pub(crate) fn photos_timeline(
&self,
offset: usize,
limit: usize,
kind: Option<PhotoKind>,
range: Option<(i64, i64)>,
) -> CoreResult<Option<Vec<PhotoItem>>> {
let count = self.db.photos_count().map_err(CoreError::from)?;
if count == 0 {
// Nothing to serve, so this one request has to wait for the fetch —
// unless we already know the account has no photos volume and the
// answer is a fresh "no".
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
if known_empty && !self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
return Ok(None);
}
if !self.rt.block_on(self.refresh_timeline())? {
return Ok(None);
}
} else if self.listing_stale(PHOTOS_SYNCED_MS, TIMELINE_TTL) {
self.spawn_timeline_refresh();
}
let page = self
.db
.photos_page(offset, limit, kind, range)
.map_err(CoreError::from)?;
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
}
/// Project a persisted photo into the wire item the front-end paints: its
/// learned aspect ratio, its thumbnail verdict, and the on-disk path of its
/// thumbnail when one is cached (tagged with the capture time, which is the
/// only revision handle a photo carries).
pub(crate) fn photo_item(&self, photo: StoredPhoto) -> PhotoItem {
let thumb_path = parse_uid(&photo.uid).and_then(|uid| {
self.cache
.cached_thumbnail_path(&uid, ThumbnailType::Thumbnail.as_i32(), photo.capture_time)
.map(|p| p.display().to_string())
});
PhotoItem {
uid: photo.uid,
capture_time: photo.capture_time,
thumb_path,
name: photo.name,
ratio: photo.ratio,
no_thumb: photo.thumb_state == db::THUMB_NONE,
kind: photo.kind,
}
}
/// Thumbnails for `uids`, served from the cache, fetched from the server for
/// whatever is missing, and — for the photos the server has no thumbnail for
/// at all — *generated locally* from the full file (see
/// [`Core::generate_thumbs`]). Requested on demand as tiles scroll into view,
/// so a cold timeline paints immediately and only the photos actually on
/// screen cost a round-trip.
///
/// A photo absent from the persisted timeline is skipped: its capture time is
/// the cache's validity tag, and guessing that would poison the cache.
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
let tags: HashMap<String, i64> = stored
.iter()
.map(|p| (p.uid.clone(), p.capture_time))
.collect();
// Ask the server only for photos that are missing a cached thumbnail and
// haven't already been written off as un-thumbnailable.
let want: Vec<NodeUid> = uids
.iter()
.filter(|uid| {
let key = uid.to_string();
stored
.iter()
.find(|p| p.uid == key)
.is_some_and(|p| p.thumb_state != db::THUMB_NONE)
&& tags.get(&key).is_some_and(|&tag| {
self.cache.cached_thumbnail_path(uid, ttype, tag).is_none()
})
})
.cloned()
.collect();
if !want.is_empty() {
let mut missing = want.clone();
match self.rt.block_on(
self.photos()
.enumerate_thumbnails(&want, ThumbnailType::Thumbnail),
) {
Ok(thumbs) => {
for ft in thumbs {
let Some(&tag) = tags.get(&ft.file_uid.to_string()) else {
continue;
};
let Ok(bytes) = ft.result else { continue };
if bytes.is_empty() {
continue;
}
if self
.cache
.store_thumbnail(&ft.file_uid, ttype, tag, &bytes)
.is_ok()
{
missing.retain(|uid| uid != &ft.file_uid);
self.record_thumb(&ft.file_uid, db::THUMB_HAVE, ratio_of(&bytes));
}
}
}
// A failed batch is not a verdict: leave every uid in `missing` so
// the local fallback still gives those tiles an image.
Err(e) => warn!(error = %e, "batch photo thumbnails failed"),
}
// Whatever the server had nothing for gets a thumbnail made from its
// own bytes — this is what fills in camera photos uploaded by clients
// that never generated one. Off the request path: a full-size photo
// takes far longer to fetch than the whole rest of the batch, and the
// thumbnails that *are* ready must not wait behind it.
if !missing.is_empty() {
self.spawn_generate_thumbs(missing, &tags);
}
}
let pending = self.thumb_gen.lock();
uids.iter()
.map(|uid| PhotoThumb {
uid: uid.to_string(),
path: tags.get(&uid.to_string()).and_then(|&tag| {
self.cache
.cached_thumbnail_path(uid, ttype, tag)
.map(|p| p.display().to_string())
}),
pending: pending.contains(uid),
})
.collect()
}
/// Generate the missing thumbnails on the runtime, skipping any photo already
/// being generated. The uids are marked in-flight before the task starts, so
/// the reply this call is about to send already reports them as pending.
pub(crate) fn spawn_generate_thumbs(&self, uids: Vec<NodeUid>, tags: &HashMap<String, i64>) {
let fresh: Vec<NodeUid> = {
let mut inflight = self.thumb_gen.lock();
uids.into_iter()
.filter(|uid| inflight.insert(uid.clone()))
.collect()
};
if fresh.is_empty() {
return;
}
let core = self.clone();
let tags = tags.clone();
// `generate_thumbs` blocks on the runtime itself, so it belongs on the
// blocking pool rather than on an async worker.
self.rt.spawn_blocking(move || {
core.generate_thumbs(&fresh, &tags);
let mut inflight = core.thumb_gen.lock();
for uid in &fresh {
inflight.remove(uid);
}
});
}
/// Make thumbnails for photos the server has none for: download each full
/// file once, scale it to [`THUMB_EDGE`], and store the result in the thumbnail
/// cache exactly as if the server had served it.
///
/// Bounded by [`THUMB_GEN_CONCURRENCY`] — these are full-size originals, and a
/// screenful of them at once would saturate the link. A photo whose bytes
/// can't be decoded (a codec `image` doesn't speak) is marked
/// [`db::THUMB_NONE`] and never attempted again.
pub(crate) fn generate_thumbs(&self, uids: &[NodeUid], tags: &HashMap<String, i64>) {
info!(
count = uids.len(),
"generating thumbnails the server has none for"
);
let results: Vec<(NodeUid, ThumbAttempt)> = self.rt.block_on(async {
let mut out = Vec::with_capacity(uids.len());
for chunk in uids.chunks(THUMB_GEN_CONCURRENCY) {
let mut set = tokio::task::JoinSet::new();
for uid in chunk {
let client = self.client.clone();
let uid = uid.clone();
set.spawn(async move {
let bytes = match client.download_file(&uid).await {
Ok(bytes) => bytes,
Err(e) => {
warn!(%uid, error = %e, "photo download for thumbnail failed");
return (uid, ThumbAttempt::Unavailable);
}
};
// Decoding + scaling a 20 MP JPEG is CPU-bound and would
// stall the runtime's worker; hand it to the blocking pool.
let made = tokio::task::spawn_blocking(move || scale_thumbnail(&bytes))
.await
.unwrap_or(None);
match made {
Some(thumb) => (uid, ThumbAttempt::Made(thumb)),
None => (uid, ThumbAttempt::Undecodable),
}
});
}
while let Some(joined) = set.join_next().await {
if let Ok(result) = joined {
out.push(result);
}
}
}
out
});
let ttype = ThumbnailType::Thumbnail.as_i32();
for (uid, attempt) in results {
match attempt {
ThumbAttempt::Made(thumb) => {
let Some(&tag) = tags.get(&uid.to_string()) else {
continue;
};
match self.cache.store_thumbnail(&uid, ttype, tag, &thumb.bytes) {
Ok(()) => self.record_thumb(&uid, db::THUMB_HAVE, Some(thumb.ratio)),
Err(e) => warn!(%uid, error = %e, "storing generated thumbnail failed"),
}
}
// The photo's own bytes aren't an image we can read: no thumbnail
// will ever exist for it, so stop trying.
ThumbAttempt::Undecodable => self.record_thumb(&uid, db::THUMB_NONE, None),
// The download failed — a dropped connection, an expired link. That
// is not a verdict on the photo: leave its state alone so the next
// scroll past it tries again.
ThumbAttempt::Unavailable => {}
}
}
}
/// Persist what a thumbnail attempt learned about a photo.
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
warn!(%uid, error = %e, "recording thumbnail state failed");
}
}
/// Whether the listing stamped under `key` is older than `ttl` (or was never
/// fetched).
pub(crate) async fn refresh_timeline(&self) -> CoreResult<bool> {
let photos = self.photos();
if photos
.get_photos_root()
.await
.map_err(|e| CoreError::from_api(&e, "photos root"))?
.is_none()
{
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
return Ok(false);
}
let items = photos
.enumerate_timeline()
.await
.map_err(|e| CoreError::from_api(&e, "timeline"))?;
// The timeline DTO carries only a uid and capture time, but the Photos
// page has to split into Photos / Videos / Raw — which needs each photo's
// name and media type. Resolve those in batches off the request path.
// Best-effort: a photo whose node we fail to resolve keeps whatever was
// learned before (or classifies from nothing, i.e. a still photo), so a
// partial resolve never blanks the timeline.
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
match photos.enumerate_nodes(chunk).await {
Ok(nodes) => {
for node in nodes {
let media_type = match &node.kind {
NodeKind::File { media_type, .. } => Some(media_type.clone()),
NodeKind::Folder => None,
};
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
}
}
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
}
}
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
.iter()
.map(|it| {
let key = it.uid.to_string();
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
(key, it.capture_time, name, media_type)
})
.collect();
self.db.photos_replace(&rows).map_err(CoreError::from)?;
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
Ok(true)
}
/// Refresh the timeline off the request path, so a stale page is still served
/// at DB speed. At most one refresh runs at a time.
pub(crate) fn spawn_timeline_refresh(&self) {
if self.timeline_refreshing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
self.rt.spawn(async move {
if let Err(e) = core.refresh_timeline().await {
warn!(error = %e, "background timeline refresh failed");
}
core.timeline_refreshing.store(false, Ordering::SeqCst);
});
}
/// Download a photo's full content into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult<PathBuf> {
let photos = self.photos();
let node = self
.rt
.block_on(photos.get_node(uid))
.map_err(|e| CoreError::from_api(&e, "photo node"))?
.ok_or_else(|| CoreError::not_found("photo not found"))?;
let (mtime, size) = (node.modification_time, node_size(&node));
if let Some(p) = self.cache.cached_content_path(uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_photo_tracked(&photos, uid, &node.name, size)
.map_err(|e| CoreError::from_api(&e, "download photo"))?;
self.cache
.store(uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
Ok(self.cache.content_path(uid))
}
}
#[cfg(test)]
mod thumb_tests {
use super::{THUMB_EDGE, ratio_of, scale_thumbnail};
/// A `width`×`height` JPEG, standing in for a camera photo the server never
/// generated a thumbnail for.
fn jpeg(width: u32, height: u32) -> Vec<u8> {
let image = image::RgbImage::from_fn(width, height, |x, y| {
image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
.encode_image(&image)
.unwrap();
bytes
}
#[test]
fn scaling_fits_the_long_edge_and_keeps_the_aspect_ratio() {
let photo = jpeg(4000, 3000);
let thumb = scale_thumbnail(&photo).expect("a JPEG scales");
let (width, height) = image::ImageReader::new(std::io::Cursor::new(&thumb.bytes))
.with_guessed_format()
.unwrap()
.into_dimensions()
.unwrap();
assert_eq!(width, THUMB_EDGE, "the long edge lands on the target");
assert_eq!(height, THUMB_EDGE * 3 / 4, "and nothing is cropped");
assert!((thumb.ratio - 4.0 / 3.0).abs() < 1e-6);
assert!(
thumb.bytes.len() < photo.len(),
"a thumbnail that isn't smaller than its photo is no thumbnail"
);
}
#[test]
fn a_portrait_photo_fits_its_long_edge_too() {
let thumb = scale_thumbnail(&jpeg(1000, 2000)).expect("a JPEG scales");
assert!(thumb.ratio < 1.0, "portrait stays portrait");
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
}
#[test]
fn undecodable_bytes_are_not_a_thumbnail() {
// What a photo in a format this build has no decoder for looks like: the
// caller writes it off as un-thumbnailable rather than retrying forever.
assert!(scale_thumbnail(b"not an image at all").is_none());
assert!(ratio_of(b"not an image at all").is_none());
}
#[test]
fn ratio_is_read_from_the_header_alone() {
assert_eq!(ratio_of(&jpeg(300, 200)), Some(1.5));
}
}
+394
View File
@@ -0,0 +1,394 @@
//! Reading and writing this machine's `profile.json` in its device root, and
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
//!
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
//! that talks to Drive. Saving is best-effort and never blocks a user action:
//! the profile is a convenience for a *future* machine, so failing to write it
//! must not fail the folder add that triggered it.
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use pdfs_core::control::RestorableFolder;
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use tracing::{info, warn};
use super::sync;
use super::{Core, now_secs, parse_uid, this_hostname};
/// Coalescing flags for background profile saves.
///
/// A single burst of user actions (adding three folders, pinning a tree) should
/// cost one upload, not one per action. `SAVING` admits one worker at a time and
/// `DIRTY` records that something changed while it was busy, so the worker takes
/// exactly one more lap afterwards.
///
/// Process-global rather than a [`Core`] field because on-demand mounts fork
/// their own `Core` (`Core::fork_state`) and all of them describe the same
/// machine — there is one profile per daemon, so there is one flag pair.
static SAVE_STATE: OnceLock<(AtomicBool, AtomicBool)> = OnceLock::new();
fn save_state() -> &'static (AtomicBool, AtomicBool) {
SAVE_STATE.get_or_init(|| (AtomicBool::new(false), AtomicBool::new(false)))
}
impl Core {
/// Note that the arrangement this machine's profile describes has changed,
/// and upload a fresh profile in the background.
///
/// Fire-and-forget by design: callers are mutation paths (add/remove a sync
/// folder, switch a mode, pin a node, save settings) whose success does not
/// depend on the backup succeeding.
pub(crate) fn touch_profile(&self) {
let (saving, dirty) = save_state();
dirty.store(true, Ordering::SeqCst);
// Someone is already uploading; they will see DIRTY and take another lap.
if saving.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
std::thread::spawn(move || {
let (saving, dirty) = save_state();
while dirty.swap(false, Ordering::SeqCst) {
if let Err(e) = core.save_profile() {
warn!(error = %e.message, "profile backup failed");
break;
}
}
saving.store(false, Ordering::SeqCst);
});
}
/// Serialize this machine's arrangement and upload it to `profile.json` in
/// the device root, replacing any existing one.
///
/// Does nothing when no device is registered: a machine that has never
/// synced a folder has nothing to describe, and registering a device just to
/// hold an empty profile would put a stray computer in the user's Drive.
pub(crate) fn save_profile(&self) -> CoreResult<()> {
let Some(device) = self
.db
.device_get()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
else {
return Ok(());
};
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
let profile = self.build_profile(&device.uid)?;
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
let len = bytes.len() as i64;
// Replace the existing document rather than adding a second file of the
// same name: the profile is a single mutable record, and a device root
// with `profile.json` and `profile (1).json` in it teaches the restore
// nothing about which to trust.
let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?;
match existing {
Some(uid) => self
.rt
.block_on(self.client.upload_new_revision_from(
&uid,
Cursor::new(bytes),
len,
Vec::new(),
Some(now_secs()),
))
.map(|_| ())
.map_err(|e| CoreError::from_api(&e, "upload profile revision"))?,
None => {
self.rt
.block_on(self.client.upload_file_replacing_draft_from(
&root_uid,
PROFILE_FILE_NAME,
"application/json",
Cursor::new(bytes),
len,
Vec::new(),
Some(now_secs()),
false,
))
.map_err(|e| CoreError::from_api(&e, "upload profile"))?;
}
}
info!(folders = profile.folders.len(), "profile backed up");
Ok(())
}
/// Snapshot the local arrangement into a [`Profile`].
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
let folders = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| ProfileFolder {
remote_uid: f.remote_uid,
local_path: f.local_path,
// A queued switch is where the user asked the folder to be, so
// that is what the next machine should set up.
mode: f.pending_mode.unwrap_or(f.mode),
})
.collect();
let pins = self
.db
.pin_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|(uid, path, recursive)| ProfilePin {
uid,
path,
recursive,
})
.collect();
let cfg = pdfs_core::config::AppDirs::new()
.map(|d| d.load_config())
.unwrap_or_default();
Ok(Profile {
version: PROFILE_VERSION,
device_uid: device_uid.to_string(),
hostname: this_hostname(),
saved_at: now_secs(),
folders,
pins,
ignore_patterns: cfg.ignore_patterns,
cache_budget: cfg.cache_budget,
mountpoint: cfg.mountpoint,
})
}
/// An untrashed *file* named `name` directly under the device root.
///
/// The sibling of `find_device_child_folder`; kept separate because a folder
/// named `profile.json` must not be mistaken for the profile.
fn find_device_child_file(
&self,
root_uid: &NodeUid,
name: &str,
) -> CoreResult<Option<NodeUid>> {
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
if uids.is_empty() {
return Ok(None);
}
let nodes = self
.rt
.block_on(self.client.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))
}
/// Download and parse the profile in the device root, if there is one.
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
return Ok(None);
};
let mut buf: Vec<u8> = Vec::new();
self.rt
.block_on(self.client.download_file_to(&uid, &mut buf))
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
}
// ---- restore (features.md 5.2) ----------------------------------------
/// The folders under this machine's device that could be synced here, each
/// carrying a proposed local path.
///
/// The proposal comes from the profile when one exists and its path is
/// usable on this machine, and from `~/<name>` otherwise — a restored
/// `/home/other-user/Documents` would be wrong on a machine where that user
/// does not exist, and silently creating it would be worse.
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
let device = self.ensure_device()?;
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
})?;
let profile = match self.load_profile(&root_uid) {
Ok(p) => p,
// A missing or unreadable profile is not fatal: the remote tree
// alone is enough to offer a restore, just with plainer defaults.
Err(e) => {
warn!(error = %e.message, "could not read profile; proposing defaults");
None
}
};
let uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&root_uid))
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
let nodes = if uids.is_empty() {
Vec::new()
} else {
self.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?
};
// Folders already synced here are not restorable — they are already
// restored — but they are still listed, flagged, so the picker can show
// the whole device rather than a confusing subset.
let existing: Vec<String> = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
.into_iter()
.map(|f| f.remote_uid)
.collect();
Ok(nodes
.into_iter()
.filter(|n| n.is_folder() && !n.trashed)
.map(|n| {
let uid = n.uid.to_string();
let saved = profile
.as_ref()
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
let proposed = saved
.map(|f| PathBuf::from(&f.local_path))
.filter(|p| path_is_usable_here(p))
.unwrap_or_else(|| default_local_path(&n.name));
RestorableFolder {
already_synced: existing.iter().any(|e| e == &uid),
remote_uid: uid,
name: n.name,
local_path: proposed.to_string_lossy().to_string(),
mode: saved
.map(|f| f.mode.clone())
.unwrap_or_else(|| "mirror".into()),
}
})
.collect())
}
/// Attach remote device folders to local paths and let the sync engine fill
/// them in.
///
/// The download is not written here: a new sync-folder row starts with an
/// empty baseline, and an empty baseline against an empty local directory
/// and a populated remote already classifies as "download everything". The
/// restore is therefore just bookkeeping plus a `Reconcile`, and it reuses
/// the engine's conflict handling for the case where the local directory
/// turns out not to be empty after all.
pub(crate) fn restore_sync_folders(
&self,
items: &[pdfs_core::control::RestoreItem],
) -> CoreResult<String> {
if items.is_empty() {
return Err(CoreError::invalid("nothing to restore"));
}
let device = self.ensure_device()?;
let existing = self
.db
.sync_folder_list()
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
let mut restored = 0usize;
let mut skipped: Vec<String> = Vec::new();
for item in items {
let local = PathBuf::from(&item.local_path);
if !local.is_absolute() {
skipped.push(format!("{}: local path must be absolute", item.local_path));
continue;
}
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
skipped.push(format!("{}: already synced", item.local_path));
continue;
}
if existing
.iter()
.any(|f| Path::new(&f.local_path) == local.as_path())
{
skipped.push(format!("{}: path already in use", item.local_path));
continue;
}
if let Err(e) = std::fs::create_dir_all(&local) {
skipped.push(format!("{}: {e}", item.local_path));
continue;
}
let mode = match item.mode.as_str() {
"mirror" | "ondemand" => item.mode.as_str(),
other => {
skipped.push(format!("{}: unknown mode {other}", item.local_path));
continue;
}
};
let id = self
.db
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
// `ondemand` is applied through the normal request path, which
// mounts the FUSE session and handles the folder being busy — the
// restore does not get its own copy of that logic.
if mode == "ondemand" {
let _ = self.request_sync_folder_mode(id, "ondemand");
}
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
restored += 1;
}
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
let mut message = format!("restored {restored} folder(s)");
if !skipped.is_empty() {
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
}
info!(restored, skipped = skipped.len(), "restore complete");
Ok(message)
}
}
/// Whether a path recorded on another machine can be reused verbatim here.
///
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
/// username is a fine proposal even though the directory itself is gone (the
/// restore creates it), but `/home/someone-else/Documents` is not.
fn path_is_usable_here(path: &Path) -> bool {
path.is_absolute() && path.parent().is_some_and(|p| p.is_dir())
}
/// `~/<name>`, or `/<name>` if the home directory cannot be determined.
fn default_local_path(name: &str) -> PathBuf {
match pdfs_core::config::AppDirs::new()
.ok()
.and_then(|d| d.home_dir())
{
Some(home) => home.join(name),
None => PathBuf::from("/").join(name),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn foreign_home_is_not_reused() {
assert!(!path_is_usable_here(Path::new(
"/home/definitely-not-a-user-here/Documents"
)));
assert!(!path_is_usable_here(Path::new("relative/path")));
}
/// A path whose parent exists but which does not itself is exactly the
/// restore case — the directory is about to be created.
#[test]
fn missing_leaf_under_existing_parent_is_reused() {
let dir = std::env::temp_dir().join("pdfs-restore-probe");
std::fs::create_dir_all(&dir).unwrap();
assert!(path_is_usable_here(&dir.join("Documents")));
let _ = std::fs::remove_dir_all(&dir);
}
}
+645
View File
@@ -0,0 +1,645 @@
//! Revision-reader cache and streamed range-read subsystem.
use super::*;
/// A read of an unpinned video at least this large streams *without* persisting
/// its blocks to the on-disk cache. Playing a 2 GB film would otherwise pour it
/// through the block LRU and evict everything else the user actually wants kept —
/// and it re-streams cheaply enough on a rewatch that keeping it was never worth
/// that. Pinned videos (kept offline on purpose) and anything smaller cache as
/// usual.
pub(super) const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024;
/// How many bytes of streamed blocks the in-memory ring keeps. Bypassing the
/// on-disk cache must not mean re-fetching: the kernel asks for a streamed file
/// in reads far smaller than [`BLOCK_SIZE`], so without a ring every 128 KiB the
/// player consumes would download and decrypt a whole 4 MiB block again. Sized
/// for a handful of blocks per concurrently-streamed file.
const STREAM_RING_BYTES: u64 = 128 * 1024 * 1024;
/// Blocks fetched *past* the one a sequential streaming read needed, warmed into
/// the ring in the background so the player's next read is already in memory
/// instead of waiting a round-trip. Kept small so it cannot crowd out the
/// in-flight block governor.
const STREAM_READAHEAD: u64 = 4;
/// How many [`RevisionReader`]s stay open at once.
///
/// A reader holds its revision's content key and block table — a few KB even for
/// a large file, so this is bounded for tidiness and staleness rather than
/// memory. Evicted least-recently-used; a dropped reader costs one re-open
/// (two API calls and a node-key unlock) the next time that file is read.
const MAX_OPEN_READERS: usize = 64;
/// An open reader plus the node metadata it was opened against.
///
/// The SDK pins a reader to the revision that was active at `open_revision`, so
/// a reader is only reusable while the node still reports the same
/// `(mtime, size)` — the same validity pair the content cache uses (a new
/// revision bumps mtime). On a mismatch the reader is dropped and reopened.
pub(super) struct CachedReader {
reader: Arc<RevisionReader>,
mtime: i64,
size: u64,
/// For LRU eviction.
last_used: Instant,
}
/// What the `readers` map holds for a node: an open reader, or the fact that
/// someone is already opening one.
///
/// The `Pending` arm is what makes the map single-flight rather than merely a
/// cache. Opening resolves link details, ancestor keys, an S2K node-key unlock
/// and the block table — five round-trips — and read-ahead on a cold file puts
/// several workers into the open at the same moment, so without this each
/// racer would redo all of it and discard every result but one.
pub(super) enum ReaderSlot {
Ready(CachedReader),
Pending(PendingOpen),
}
/// An `open_revision` in flight. Racers clone `rx` and await the leader's result
/// instead of opening their own.
pub(super) struct PendingOpen {
/// Identifies this attempt so the leader can tell "my slot is still there"
/// from "someone evicted or replaced it while I was on the network", and
/// only publish its reader in the first case.
id: u64,
/// What the leader is opening against. A racer wanting a different revision
/// must not join this open — it would get a reader for the wrong bytes.
mtime: i64,
size: u64,
rx: tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>,
}
/// Distinguishes `PendingOpen`s. Only uniqueness matters, so a plain counter
/// does; ids are never compared for order.
static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
/// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads
/// to the async SDK, the inode bookkeeping, and the on-disk content cache.
///
/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task
/// gets its own copy while the FUSE session keeps another.
/// In-memory LRU of blocks belonging to a file streaming past the on-disk cache
/// (see [`STREAM_BYPASS_MIN`]). Bypassing the disk stops one film evicting the
/// block LRU; it must not also mean the same 4 MiB block is downloaded once per
/// 128 KiB the player reads out of it.
///
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
/// whose tag no longer matches has its blocks dropped rather than served, so a
/// new revision can never be stitched together from the old one.
#[derive(Default)]
pub(super) struct StreamRing {
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
/// Keys oldest-first, for eviction.
order: VecDeque<(NodeUid, u64)>,
/// Per-node validity tag, `(mtime, size)`.
tags: HashMap<NodeUid, (i64, u64)>,
bytes: u64,
}
impl StreamRing {
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
if self.tags.get(uid) != Some(&(mtime, size)) {
return None;
}
self.blocks.get(&(uid.clone(), idx)).cloned()
}
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
if self.tags.get(uid) != Some(&(mtime, size)) {
// Revision changed under us (or first sight): anything held for this
// node describes the old one.
self.drop_node(uid);
self.tags.insert(uid.clone(), (mtime, size));
}
let key = (uid.clone(), idx);
if self.blocks.contains_key(&key) {
return;
}
self.bytes += bytes.len() as u64;
self.blocks.insert(key.clone(), bytes);
self.order.push_back(key);
while self.bytes > STREAM_RING_BYTES {
let Some(victim) = self.order.pop_front() else {
break;
};
if let Some(dropped) = self.blocks.remove(&victim) {
self.bytes -= dropped.len() as u64;
}
}
}
fn drop_node(&mut self, uid: &NodeUid) {
self.order.retain(|(u, _)| u != uid);
let mut freed = 0u64;
self.blocks.retain(|(u, _), bytes| {
if u == uid {
freed += bytes.len() as u64;
false
} else {
true
}
});
self.bytes -= freed;
self.tags.remove(uid);
}
}
impl Core {
/// An open [`RevisionReader`] for `uid`, reusing the cached one when it is
/// still valid for `(mtime, fsize)` and opening a fresh one otherwise.
///
/// Opening resolves the file's link details, ancestor keys, node key (an S2K
/// unlock) and block table. Doing that once per file rather than once per
/// block is the whole point: a cold 100 MB read is 25 block misses, which
/// used to mean 25 full resolutions (50 API calls, 25 unlocks) and now means
/// one.
///
/// Racing reads of one cold file open it **once**: the first caller claims
/// the slot and the rest await its result. That matters now that `read` is
/// served off the dispatch loop, because read-ahead on a first touch issues
/// several parallel reads of the same file as a matter of course.
async fn revision_reader(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
) -> Result<Arc<RevisionReader>, Errno> {
loop {
// Decide under the lock, act outside it — network I/O must never run
// under a std `Mutex`, and an await must never hold one.
enum Act {
Hit(Arc<RevisionReader>),
Join(tokio::sync::watch::Receiver<Option<Result<Arc<RevisionReader>, Errno>>>),
Lead(
u64,
tokio::sync::watch::Sender<Option<Result<Arc<RevisionReader>, Errno>>>,
),
}
let act = {
let mut readers = self.readers.lock();
match readers.get_mut(uid) {
Some(ReaderSlot::Ready(entry))
if entry.mtime == mtime && entry.size == fsize =>
{
entry.last_used = Instant::now();
Act::Hit(entry.reader.clone())
}
Some(ReaderSlot::Pending(p)) if p.mtime == mtime && p.size == fsize => {
Act::Join(p.rx.clone())
}
// Either nothing here, or something for a revision we no
// longer want (a stale reader, or an open for one). Ours
// replaces it: the newer `(mtime, size)` is the truth.
_ => {
let id = NEXT_OPEN_ID.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = tokio::sync::watch::channel(None);
readers.insert(
uid.clone(),
ReaderSlot::Pending(PendingOpen {
id,
mtime,
size: fsize,
rx,
}),
);
Act::Lead(id, tx)
}
}
};
match act {
Act::Hit(reader) => return Ok(reader),
Act::Join(mut rx) => {
// `changed()` errors only if the leader dropped its sender
// without publishing — it panicked. Retry from the top
// rather than fail: the slot it left is gone, so this pass
// leads its own open.
loop {
if let Some(result) = rx.borrow_and_update().clone() {
return result;
}
if rx.changed().await.is_err() {
break;
}
}
continue;
}
Act::Lead(id, tx) => {
debug!(%uid, mtime, fsize, "opening revision");
let result = match self.client.open_revision(uid).await {
Ok(reader) => Ok(Arc::new(reader)),
Err(e) => {
warn!(%uid, error = %e, "open_revision failed");
Err(Errno::EIO)
}
};
{
let mut readers = self.readers.lock();
// Publish only into the slot we claimed. If it is gone
// or belongs to a later attempt, an eviction or a newer
// revision overtook us: our reader still answers the
// callers waiting on it, but it must not be cached.
let ours = matches!(
readers.get(uid),
Some(ReaderSlot::Pending(p)) if p.id == id
);
if ours {
match &result {
Ok(reader) => {
readers.insert(
uid.clone(),
ReaderSlot::Ready(CachedReader {
reader: reader.clone(),
mtime,
size: fsize,
last_used: Instant::now(),
}),
);
}
// Never cache a failure: the next read retries.
Err(_) => {
readers.remove(uid);
}
}
}
Self::trim_readers(&mut readers);
}
// After the map is updated, so a racer that wakes and
// re-enters finds the finished slot rather than racing us.
let _ = tx.send(Some(result.clone()));
return result;
}
}
}
}
/// Bound the reader map: drop least-recently-used entries until it is back
/// at [`MAX_OPEN_READERS`]. Only `Ready` slots are eviction candidates — a
/// `Pending` has callers waiting on it and no reader to drop yet.
fn trim_readers(readers: &mut HashMap<NodeUid, ReaderSlot>) {
while readers.len() > MAX_OPEN_READERS {
let victim = readers
.iter()
.filter_map(|(uid, slot)| match slot {
ReaderSlot::Ready(entry) => Some((uid, entry.last_used)),
ReaderSlot::Pending(_) => None,
})
.min_by_key(|(_, last_used)| *last_used)
.map(|(uid, _)| uid.clone());
let Some(victim) = victim else {
break; // every slot is an open in flight; nothing to drop
};
readers.remove(&victim);
}
}
/// Drop any open reader for `uid`, so the next read reopens against the
/// current revision. Called wherever cached content is evicted.
///
/// Drops an in-flight open too: its leader finds the slot gone and declines
/// to cache what it opened, which is what we want — the eviction says that
/// revision is no longer the truth.
pub(super) fn evict_reader(&self, uid: &NodeUid) {
self.readers.lock().remove(uid);
}
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
/// the on-disk caches before the network: a whole-file blob (pinned files)
/// first, then the block cache — fetching only the [`BLOCK_SIZE`]-aligned
/// blocks that overlap the request and caching each. `mtime`/`fsize` validate
/// both caches. Network I/O runs without any lock held.
pub(super) fn read_range(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
offset: u64,
len: u64,
cache_blocks: bool,
) -> Result<Vec<u8>, Errno> {
// A queued write has not reached the remote yet, so the remote's current
// revision is stale and the staged blob is the truth. Serve from it until
// the drain worker lands the upload (offline.md Phase 3).
if let Some(pending) = self.pending.lock().get(uid).cloned() {
return self.read_pending(&pending, offset, len);
}
// A node created offline and never written has no blob and no remote: it
// is an empty file, and asking the API about a `local~` uid would only
// earn a 404 (offline.md Phase 3b).
if is_local_uid(uid) {
return Ok(Vec::new());
}
self.read_range_remote(uid, mtime, fsize, offset, len, cache_blocks)
}
/// Serve a read from a staged blob, falling back to the remote base for any
/// range the write did not author (an incomplete [`StagedWrite`] holds zeros
/// there, which must never be handed out as content).
fn read_pending(
&self,
pending: &PendingRevision,
offset: u64,
len: u64,
) -> Result<Vec<u8>, Errno> {
let m = &pending.meta;
if offset >= m.len || len == 0 {
return Ok(Vec::new());
}
let uid = parse_node_uid(&m.uid).ok_or(Errno::EIO)?;
let file = File::open(&pending.path).map_err(|e| {
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
Errno::EIO
})?;
let mut written = Intervals::default();
for &(s, e) in &m.authored {
written.add(s, e);
}
// Same merge as `serve_open_read`, but resolving gaps against the remote
// rather than through `read_range` — going through `read_range` would find
// this very pending op and recurse.
let end = offset.saturating_add(len).min(m.len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "staged blob read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
continue;
}
let bend = e.min(m.base_size);
if s < bend {
out.extend_from_slice(&self.read_range_remote(
&uid,
m.base_mtime,
m.base_size,
s,
bend - s,
true,
)?);
}
// Past the base: a hole the write extended over.
out.resize(out.len() + e.saturating_sub(s.max(m.base_size)) as usize, 0);
}
Ok(out)
}
/// Warm the [`STREAM_READAHEAD`] blocks after `last` into the ring, in the
/// background, so a player reading forward finds its next block already in
/// memory rather than paying a fetch + decrypt round-trip at each boundary.
///
/// Fire-and-forget: a failure here is not an error, it only means the read
/// that needs those bytes will fetch them itself.
fn stream_readahead(&self, uid: &NodeUid, mtime: i64, fsize: u64, last: u64) {
let last_block = (fsize.saturating_sub(1)) / BLOCK_SIZE;
let wanted: Vec<u64> = ((last + 1)..=(last + STREAM_READAHEAD).min(last_block))
.filter(|&bidx| {
self.stream_ring
.lock()
.get(uid, mtime, fsize, bidx)
.is_none()
})
.collect();
if wanted.is_empty() {
return;
}
let core = self.clone();
let uid = uid.clone();
self.rt.spawn(async move {
let Ok(reader) = core.revision_reader(&uid, mtime, fsize).await else {
return;
};
// Concurrently, for the same reason the demand path fetches its
// misses concurrently: serially awaited blocks cost the sum of their
// round-trips, and the player catches up with the readahead before it
// finishes. The SDK's in-flight block governor bounds the fan-out.
let mut set = tokio::task::JoinSet::new();
for bidx in wanted {
let reader = reader.clone();
let bstart = bidx * BLOCK_SIZE;
let blen = BLOCK_SIZE.min(fsize - bstart);
set.spawn(async move { (bidx, reader.read_at(bstart, blen).await) });
}
while let Some(joined) = set.join_next().await {
let Ok((bidx, read)) = joined else { continue };
match read {
Ok(bytes) => {
core.stream_ring
.lock()
.insert(&uid, mtime, fsize, bidx, Arc::new(bytes))
}
Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"),
}
}
});
}
/// Read from the content cache, else the remote. The base-content path, with
/// no awareness of queued writes — callers wanting the file's *current*
/// content want [`Core::read_range`]. Gap-filling a staged blob is the one
/// caller that genuinely means "the base", since that is what its zeroed
/// ranges have to be filled from.
fn read_range_remote(
&self,
uid: &NodeUid,
mtime: i64,
fsize: u64,
offset: u64,
len: u64,
cache_blocks: bool,
) -> Result<Vec<u8>, Errno> {
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
return Ok(bytes);
}
if offset >= fsize || len == 0 {
return Ok(Vec::new());
}
let end = offset.saturating_add(len).min(fsize);
let mut out = Vec::with_capacity((end - offset) as usize);
let first = offset / BLOCK_SIZE;
let last = (end - 1) / BLOCK_SIZE;
// Collect the blocks overlapping the request, serving any already cached
// and fetching the rest concurrently. A multi-block read (e.g. a media
// player buffering, or a large sequential read split into one FUSE call)
// would otherwise stall on each block round-trip in turn; downloading the
// misses in parallel saturates the connection and bounds latency at the
// slowest single block instead of their sum.
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
let mut misses: Vec<u64> = Vec::new();
for bidx in first..=last {
// A streaming read keeps its blocks only in memory, so the ring is
// the only cache it has — check it before the disk (which will not
// hold them) and before the network (which would refetch the same
// 4 MiB block for every 128 KiB the player consumes).
let hit = if cache_blocks {
self.cache
.cached_block(uid, mtime, fsize, bidx)
.map(Arc::new)
} else {
self.stream_ring.lock().get(uid, mtime, fsize, bidx)
};
match hit {
Some(b) => blocks.push(Some(b)),
None => {
blocks.push(None);
misses.push(bidx);
}
}
}
if !misses.is_empty() {
let fetched = self.rt.block_on(async {
// Resolve the file's keys and block table once, then read every
// missing block through the shared reader. Previously each block
// called `download_range`, which redid that resolution per block.
let reader = self.revision_reader(uid, mtime, fsize).await?;
let mut set = tokio::task::JoinSet::new();
for &bidx in &misses {
let reader = reader.clone();
let uid = uid.clone();
let bstart = bidx * BLOCK_SIZE;
let blen = BLOCK_SIZE.min(fsize - bstart);
set.spawn(async move {
reader
.read_at(bstart, blen)
.await
.map(|bytes| (bidx, bytes))
.map_err(|e| {
warn!(%uid, bstart, blen, error = %e, "block read failed");
Errno::EIO
})
});
}
let mut out = Vec::with_capacity(misses.len());
while let Some(joined) = set.join_next().await {
// A join error means the task panicked; surface it as EIO.
out.push(joined.map_err(|_| Errno::EIO)??);
}
Ok::<_, Errno>(out)
})?;
for (bidx, bytes) in fetched {
// A streaming read (large unpinned video) skips the on-disk cache
// so it can't evict the rest of it; it keeps the block in the
// in-memory ring instead, which is bounded and dies with the read.
let bytes = Arc::new(bytes);
if cache_blocks {
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
} else {
self.stream_ring
.lock()
.insert(uid, mtime, fsize, bidx, bytes.clone());
}
blocks[(bidx - first) as usize] = Some(bytes);
}
if !cache_blocks {
self.stream_readahead(uid, mtime, fsize, last);
}
}
for (i, block) in blocks.into_iter().enumerate() {
let bidx = first + i as u64;
let bstart = bidx * BLOCK_SIZE;
// Every slot is populated: cache hits up front, misses by the fetch above.
let block = block.expect("block fetched or cached");
let s = (offset.max(bstart) - bstart) as usize;
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
if s < e {
out.extend_from_slice(&block[s..e]);
}
}
Ok(out)
}
/// Serve a read against an open write handle: stitch authored ranges (from
/// the scratch file) and untouched ranges (from the remote base via the
/// block cache) in order, zero-filling any region past the base.
#[allow(clippy::too_many_arguments)]
pub(super) fn serve_open_read(
&self,
file: &Arc<File>,
len: u64,
uid: &NodeUid,
base_mtime: i64,
base_size: u64,
written: &Intervals,
offset: u64,
size: u64,
) -> Result<Vec<u8>, Errno> {
if offset >= len || size == 0 {
return Ok(Vec::new());
}
let end = offset.saturating_add(size).min(len);
let mut out = Vec::with_capacity((end - offset) as usize);
for (s, e, authored) in written.segments(offset, end) {
if authored {
let mut buf = vec![0u8; (e - s) as usize];
file.read_exact_at(&mut buf, s).map_err(|err| {
warn!(%uid, error = %err, "scratch read failed");
Errno::EIO
})?;
out.extend_from_slice(&buf);
} else {
let bend = e.min(base_size);
if s < bend {
out.extend_from_slice(&self.read_range(
uid,
base_mtime,
base_size,
s,
bend - s,
true,
)?);
}
// Anything past the base is a hole: zero-fill.
let zeros = e.saturating_sub(s.max(base_size));
out.resize(out.len() + zeros as usize, 0);
}
}
Ok(out)
}
/// Fill every unauthored range of a scratch/staged file that overlaps its
/// base with the base's bytes, so the file becomes the complete new content.
///
/// This is the step a partial overwrite cannot skip: only the authored bytes
/// were ever written to disk, and a revision upload sends the whole file.
/// The gaps come from the *remote base* (through the block cache, so a small
/// edit of a large file does not pull all of it), which is exactly why this
/// can fail with no network — see `StagedWrite`.
pub(super) fn fill_gaps(
&self,
uid: &NodeUid,
file: &File,
len: u64,
base_mtime: i64,
base_size: u64,
written: &Intervals,
) -> Result<(), Errno> {
file.set_len(len).map_err(|e| {
error!(%uid, error = %e, "resize scratch file failed");
Errno::EIO
})?;
for (s, e, authored) in written.segments(0, len) {
if authored {
continue;
}
let bend = e.min(base_size);
if s >= bend {
continue; // wholly past the base: already zero-filled on disk
}
let bytes = self.read_range_remote(uid, base_mtime, base_size, s, bend - s, true)?;
file.write_all_at(&bytes, s).map_err(|err| {
error!(%uid, error = %err, "scratch gap-fill write failed");
Errno::EIO
})?;
}
Ok(())
}
}
+480
View File
@@ -0,0 +1,480 @@
//! Sharing: per-node share membership, public links, the invitations this
//! account has received, saved public-link bookmarks, and the reverse view of
//! everything this account shares out.
//!
//! All of it is remote-only state — nothing here touches the inode maps or the
//! content cache, so every method is a round-trip wrapped in the crate error
//! shape the control socket expects.
use std::path::Path;
use pdfs_core::control::{
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{MemberRole, NodeKind};
use proton_drive_rs::Node;
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
/// A node from a *shared* listing as a [`DirEntry`]. Shared nodes live outside
/// the mount, so the entry is uid-addressed: `path` is empty, and the local-state
/// flags (`pinned`, `cached`) are false — they describe my own tree, not this one.
fn shared_entry(n: Node) -> DirEntry {
let is_dir = n.is_folder();
let size = match &n.kind {
NodeKind::File {
claimed_size,
total_size_on_storage,
..
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
NodeKind::Folder => 0,
};
DirEntry {
name: n.name,
is_dir,
size,
modified: n.modification_time,
pinned: false,
cached: false,
uid: n.uid.to_string(),
path: String::new(),
}
}
impl Core {
// ---- sharing a node ---------------------------------------------------
/// Invite Proton and/or external emails to the node at `rel` at `role`.
/// Returns `(proton_invited, external_invited)`.
pub(crate) fn share_node(
&self,
rel: &Path,
emails: &[String],
role: &str,
message: Option<&str>,
) -> CoreResult<(usize, usize)> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
let invitees: Vec<(String, MemberRole)> =
emails.iter().map(|e| (e.clone(), role)).collect();
self.rt
.block_on(self.client.invite_users(&uid, &invitees, message))
.map_err(|e| CoreError::from_api(&e, "share"))
}
/// List the members, pending invitations and public link of the node at `rel`.
pub(crate) fn list_share(
&self,
rel: &Path,
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
let (_ino, uid) = self.resolve(rel)?;
let mut entries = Vec::new();
for m in self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
{
entries.push(ShareEntry {
id: m.membership_id.to_string(),
email: m.email,
role: role_to_str(m.role).to_string(),
kind: ShareEntryKind::Member,
});
}
for inv in self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
{
entries.push(ShareEntry {
id: inv.invitation_id,
email: inv.invitee_email,
role: role_to_str(inv.role).to_string(),
kind: ShareEntryKind::ProtonInvite,
});
}
for ext in self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
{
entries.push(ShareEntry {
id: ext.invitation_id,
email: ext.invitee_email,
role: role_to_str(ext.role).to_string(),
kind: ShareEntryKind::ExternalInvite,
});
}
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.map(public_link_info);
Ok((entries, link))
}
/// Change the role of a member or pending Proton invitation on the node at
/// `rel`. External invitations have no role-update endpoint.
pub(crate) fn update_share_role(
&self,
rel: &Path,
id: &str,
kind: ShareEntryKind,
role: &str,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.update_member_role(&member, role))
.map_err(|e| CoreError::from_api(&e, "update role"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.update_invitation_role(&inv, role))
.map_err(|e| CoreError::from_api(&e, "update role"))
}
ShareEntryKind::ExternalInvite => Err(CoreError::invalid(
"an external invitation's role cannot be changed",
)),
}
}
/// Remove a member, pending Proton invite, or external invite from the node
/// at `rel`.
pub(crate) fn remove_share_entry(
&self,
rel: &Path,
id: &str,
kind: ShareEntryKind,
) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
match kind {
ShareEntryKind::Member => {
let member = self
.rt
.block_on(self.client.list_share_members(&uid))
.map_err(|e| CoreError::from_api(&e, "list members"))?
.into_iter()
.find(|m| m.membership_id.to_string() == id)
.ok_or_else(|| CoreError::not_found("member not found"))?;
self.rt
.block_on(self.client.remove_member(&member))
.map_err(|e| CoreError::from_api(&e, "remove member"))
}
ShareEntryKind::ProtonInvite => {
let inv = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("invitation not found"))?;
self.rt
.block_on(self.client.delete_invitation(&inv))
.map_err(|e| CoreError::from_api(&e, "revoke invitation"))
}
ShareEntryKind::ExternalInvite => {
let ext = self
.rt
.block_on(self.client.list_external_invitations(&uid))
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
.into_iter()
.find(|i| i.invitation_id == id)
.ok_or_else(|| CoreError::not_found("external invitation not found"))?;
self.rt
.block_on(self.client.delete_external_invitation(&ext))
.map_err(|e| CoreError::from_api(&e, "revoke external invitation"))
}
}
}
/// Create a public link on the node at `rel`, returning its info (with URL).
pub(crate) fn create_public_link(
&self,
rel: &Path,
role: &str,
password: Option<&str>,
expires: Option<i64>,
) -> CoreResult<PublicLinkInfo> {
let (_ino, uid) = self.resolve(rel)?;
let role = role_from_str(role)?;
let link = self
.rt
.block_on(
self.client
.create_public_link(&uid, role, password, expires),
)
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
Ok(public_link_info(link))
}
/// Remove the public link `id` from the node at `rel`.
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
let (_ino, uid) = self.resolve(rel)?;
let link = self
.rt
.block_on(self.client.get_public_link(&uid))
.map_err(|e| CoreError::from_api(&e, "get public link"))?
.filter(|l| l.public_link_id == id)
.ok_or_else(|| CoreError::not_found("public link not found"))?;
self.rt
.block_on(self.client.remove_public_link(&link))
.map_err(|e| CoreError::from_api(&e, "remove public link"))
}
// ---- shared with me ---------------------------------------------------
/// List nodes shared with me that I have accepted.
pub(crate) fn list_shared_with_me(&self) -> CoreResult<Vec<DirEntry>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_with_me_node_uids())
.map_err(|e| CoreError::from_api(&e, "enumerate shared"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes.into_iter().map(shared_entry).collect())
}
/// List the children of a folder shared with me, addressed by uid.
///
/// A shared subtree has no path in this account's mount — the FUSE tree only
/// spans my own volume — so browsing into one is uid-addressed the whole way
/// down, each listing handing the front-end the uids it descends with next.
pub(crate) fn list_shared_folder(&self, uid: &str) -> CoreResult<Vec<DirEntry>> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
let child_uids = self
.rt
.block_on(self.client.enumerate_folder_children_node_uids(&uid))
.map_err(|e| CoreError::from_api(&e, "enumerate shared children"))?;
if child_uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&child_uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
Ok(nodes.into_iter().map(shared_entry).collect())
}
/// Download a file shared with me into the content cache, returning its
/// on-disk path (served from cache when a fresh blob already exists).
///
/// The by-uid twin of [`Core::open_file`]: same cache and same tracked
/// download, but the node is fetched from the server rather than resolved
/// through the inode tree, which does not cover other people's volumes.
///
/// [`Core::open_file`]: super::Core::open_file
pub(crate) fn open_shared_file(&self, uid: &str) -> CoreResult<std::path::PathBuf> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
let node = self
.rt
.block_on(self.client.get_node(&uid))
.map_err(|e| CoreError::from_api(&e, "get shared node"))?
.ok_or_else(|| CoreError::not_found("shared node not found"))?;
if !node.is_file() {
return Err(CoreError::invalid("not a regular file"));
}
let size = node_size(&node);
let mtime = node.modification_time;
if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
return Ok(p);
}
let bytes = self
.download_file_tracked(&uid, &node.name, size)
.map_err(|e| CoreError::from_api(&e, "download"))?;
self.cache
.store(&uid, mtime, size, &bytes)
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
Ok(self.cache.content_path(&uid))
}
/// Leave a shared node by its uid.
pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> {
let uid =
parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?;
self.rt
.block_on(self.client.leave_shared_node(&uid))
.map_err(|e| CoreError::from_api(&e, "leave shared"))
}
// ---- incoming invitations ---------------------------------------------
/// List invitations addressed to me, pending accept or reject.
pub(crate) fn list_invitations(&self) -> CoreResult<Vec<InvitationInfo>> {
let invitations = self
.rt
.block_on(self.client.list_incoming_invitations())
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
Ok(invitations
.into_iter()
.map(|i| InvitationInfo {
id: i.invitation_id,
inviter_email: i.inviter_email,
name: i.node_name,
is_dir: i.is_folder,
})
.collect())
}
/// Accept an invitation addressed to me by its id.
pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.accept_invitation(id))
.map_err(|e| CoreError::from_api(&e, "accept invitation"))
}
/// Reject an invitation addressed to me by its id.
pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.reject_invitation(id))
.map_err(|e| CoreError::from_api(&e, "reject invitation"))
}
// ---- bookmarks --------------------------------------------------------
/// List public links saved to my account.
pub(crate) fn list_bookmarks(&self) -> CoreResult<Vec<BookmarkInfo>> {
let bookmarks = self
.rt
.block_on(self.client.list_bookmarks())
.map_err(|e| CoreError::from_api(&e, "list bookmarks"))?;
Ok(bookmarks
.into_iter()
.map(|b| BookmarkInfo {
token: b.token,
url: b.url,
name: b.node_name,
is_dir: b.is_folder,
})
.collect())
}
/// Save a public link (optionally password-protected) as a bookmark.
pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> {
self.rt
.block_on(self.client.create_bookmark(url, password))
.map_err(|e| CoreError::from_api(&e, "create bookmark"))
}
/// Remove a saved bookmark by its token.
pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> {
self.rt
.block_on(self.client.delete_bookmark(token))
.map_err(|e| CoreError::from_api(&e, "delete bookmark"))
}
// ---- account ----------------------------------------------------------
/// Total account storage usage `(max_space, used_space)` in bytes, across all
/// Proton products (not Drive-only). A remote round-trip; nothing here is
/// cached.
pub(crate) fn account_quota(&self) -> CoreResult<(i64, i64)> {
let q = self
.rt
.block_on(self.client.quota())
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
Ok((q.max_space, q.used_space))
}
// ---- shared by me -----------------------------------------------------
/// List the nodes I have shared with others, each with a summary of its share
/// state (members, pending invitations, public link). One list call enumerates
/// 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.
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
let uids = self
.rt
.block_on(self.client.enumerate_shared_by_me_node_uids())
.map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?;
if uids.is_empty() {
return Ok(Vec::new());
}
let nodes = self
.rt
.block_on(self.client.enumerate_nodes(&uids))
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
let mut items = Vec::with_capacity(nodes.len());
for n in nodes {
let uid = n.uid.clone();
let members = self
.rt
.block_on(self.client.list_share_members(&uid))
.map(|m| m.len())
.unwrap_or(0);
let proton_invites = self
.rt
.block_on(self.client.list_share_invitations(&uid))
.map(|i| i.len())
.unwrap_or(0);
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);
items.push(SharedItem {
uid: uid.to_string(),
is_dir: n.is_folder(),
name: n.name,
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
member_count: members,
invite_count: proton_invites + external_invites,
link,
});
}
Ok(items)
}
/// Best-effort mountpoint-relative path for a node already interned in the live
/// tree, by walking parent inodes to the root. `None` when the node has never
/// been seen through the mount (e.g. shared but not browsed to this session) —
/// the caller then leaves the path empty.
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
let st = self.state.lock();
let mut ino = *st.by_uid.get(uid)?;
let mut parts = Vec::new();
while ino != ROOT_INO {
let entry = st.entries.get(&ino)?;
parts.push(entry.node.name.clone());
ino = entry.parent;
}
parts.reverse();
Some(parts.join("/"))
}
}
+612
View File
@@ -0,0 +1,612 @@
//! Inode bookkeeping: the maps that give a remote node a stable kernel inode,
//! the open write handles, and the interval set that tracks which bytes of a
//! staged write are authored locally.
use std::collections::HashMap;
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use pdfs_core::cache::StagedWrite;
use pdfs_core::db::Db;
use proton_drive_rs::proton_sdk::ids::NodeUid;
use proton_drive_rs::{Node, NodeKind};
use tracing::warn;
/// A node known to the filesystem, addressed by its kernel inode.
pub(crate) struct Entry {
pub(crate) uid: NodeUid,
pub(crate) parent: u64,
pub(crate) node: Node,
pub(crate) lookup_count: u64,
pub(crate) open_count: u32,
pub(crate) unlinked: bool,
}
/// A set of non-overlapping `[start, end)` byte ranges, kept sorted and merged.
/// Tracks which bytes of a [`WriteHandle`]'s scratch file were authored locally
/// (vs. still living only in the remote base), so reads and the commit gap-fill
/// know which regions to pull from the network.
#[derive(Clone, Default)]
pub(crate) struct Intervals(pub(crate) Vec<(u64, u64)>);
impl Intervals {
/// Mark `[start, end)` as authored, coalescing with any touching ranges.
pub(crate) fn add(&mut self, start: u64, end: u64) {
if start >= end {
return;
}
self.0.push((start, end));
self.0.sort_by_key(|&(s, _)| s);
let mut merged: Vec<(u64, u64)> = Vec::with_capacity(self.0.len());
for &(s, e) in &self.0 {
match merged.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => merged.push((s, e)),
}
}
self.0 = merged;
}
/// Drop everything at or beyond `len` (a shrink/truncate).
pub(crate) fn clip(&mut self, len: u64) {
self.0.retain(|&(s, _)| s < len);
for iv in &mut self.0 {
iv.1 = iv.1.min(len);
}
}
/// Split `[start, end)` into contiguous `(s, e, authored)` segments, in
/// order. `authored == true` means the bytes live in the scratch file;
/// `false` means they must come from the remote base (or are a hole).
pub(crate) fn segments(&self, start: u64, end: u64) -> Vec<(u64, u64, bool)> {
let mut out = Vec::new();
let mut pos = start;
for &(s, e) in &self.0 {
if e <= start {
continue;
}
if s >= end {
break;
}
let ws = s.max(start);
let we = e.min(end);
if pos < ws {
out.push((pos, ws, false));
}
out.push((ws, we, true));
pos = we;
}
if pos < end {
out.push((pos, end, false));
}
out
}
}
/// State for a file opened for writing. Authored bytes are staged in an on-disk
/// scratch file (positional reads/writes) rather than RAM, so a multi-GiB write
/// never balloons the daemon. On flush/release the scratch file — gap-filled
/// from the remote base where untouched — is streamed up as one new revision,
/// since the SDK seals whole revisions rather than byte ranges.
pub(crate) struct WriteHandle {
pub(crate) ino: u64,
pub(crate) uid: NodeUid,
/// Disk-backed staging buffer. Shared (`Arc`) so reads can use it without
/// holding the state lock across I/O. Accessed positionally (`read_at`/
/// `write_at`), so a clone never disturbs another's file offset.
pub(crate) file: Arc<File>,
/// Scratch file path, removed on release.
pub(crate) path: PathBuf,
/// Byte ranges authored into `file`. Everything else in `[0, len)` is base.
pub(crate) written: Intervals,
/// Logical file size (may exceed authored bytes after a truncate-extend).
pub(crate) len: u64,
/// Size of the remote base at open, for serving untouched ranges.
pub(crate) base_size: u64,
/// Modification time of the remote base at open, validating its block cache.
pub(crate) base_mtime: i64,
/// Whether anything diverged from the remote and needs an upload.
pub(crate) dirty: bool,
/// Number of file handles currently sharing this scratch state.
pub(crate) open_count: usize,
}
/// A released write whose upload has not happened yet (offline.md Phase 3).
///
/// The bytes live in the content cache's staging dir and the intent lives in the
/// `pending_op` table; this pairs them in memory so a read can be served without
/// a database round trip.
#[derive(Clone)]
pub(crate) struct PendingRevision {
/// Staged blob holding the written bytes.
pub(crate) path: PathBuf,
/// Which of those bytes are real, and what base the gaps refer to.
pub(crate) meta: StagedWrite,
}
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
/// `Filesystem` trait through `&self`.
pub(crate) struct State {
/// inode -> node metadata.
pub(crate) entries: HashMap<u64, Entry>,
/// Dedupe inodes by node uid so a node keeps a stable inode across lookups.
pub(crate) by_uid: HashMap<NodeUid, u64>,
/// Cached directory listings: parent inode -> child inodes. Presence of a
/// key means the directory has been enumerated.
pub(crate) children: HashMap<u64, Vec<u64>>,
pub(crate) next_ino: u64,
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
pub(crate) active_writes: HashMap<u64, WriteHandle>,
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
/// have no entry here.
pub(crate) handles: HashMap<u64, u64>,
pub(crate) next_fh: u64,
/// Unified SQLite metadata cache. Every map mutation below writes through to
/// it inside the `State` lock so the DB stays the authoritative copy across
/// restarts (see plan.md P1).
pub(crate) db: Arc<Db>,
}
impl State {
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
}
self.intern_mem(parent, node)
}
/// Allocate (or reuse) a stable inode for a node, updating the hot-cache maps
/// only. Every caller owes the DB a write-through — see the callers below;
/// the split exists so a batch can pay for one transaction instead of `n`.
pub(crate) fn intern_mem(&mut self, parent: u64, node: Node) -> u64 {
if let Some(&ino) = self.by_uid.get(&node.uid) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node = node;
e.parent = parent;
}
return ino;
}
let ino = self.next_ino;
self.next_ino += 1;
self.by_uid.insert(node.uid.clone(), ino);
self.entries.insert(
ino,
Entry {
uid: node.uid.clone(),
parent,
node,
lookup_count: 1,
open_count: 0,
unlinked: false,
},
);
ino
}
/// Decrement lookup count for an inode and prune if lookup_count == 0 && open_count == 0 && unlinked.
pub(crate) fn forget_lookup(&mut self, ino: u64, nlookup: u64) -> Option<(u64, String)> {
if let Some(entry) = self.entries.get_mut(&ino) {
entry.lookup_count = entry.lookup_count.saturating_sub(nlookup);
if entry.lookup_count == 0 && entry.open_count == 0 && entry.unlinked {
let uid = entry.uid.clone();
return self.forget(&uid);
}
}
None
}
/// Allocate (or reuse) a stable inode for a node that came *from* the
/// database, which is why nothing is written back.
pub(crate) fn intern_from_db(&mut self, parent: u64, node: Node) -> u64 {
self.intern_mem(parent, node)
}
/// Allocate (or reuse) stable inodes for a whole listing, writing every node
/// through in a single transaction.
///
/// This is what keeps `ls` on a large folder quick: one commit for the
/// listing, rather than one autocommit — and one fsync — per child.
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
if let Err(e) = self.db.upsert_nodes(&nodes) {
warn!(error = %e, "db upsert_nodes failed");
}
nodes
.into_iter()
.map(|node| self.intern_mem(parent, node))
.collect()
}
/// Check if a directory inode has any child nodes in memory or in the database.
pub(crate) fn has_children(&self, parent: u64) -> bool {
if let Some(kids) = self.children.get(&parent)
&& !kids.is_empty()
{
return true;
}
if let Some(entry) = self.entries.get(&parent)
&& let Ok(has_kids) = self.db.has_children(&entry.uid)
{
return has_kids;
}
false
}
/// Check if `ancestor_ino` is `target_ino` or an ancestor of `target_ino` (for rename cycle prevention).
pub(crate) fn is_ancestor_of(&self, ancestor_ino: u64, mut target_ino: u64) -> bool {
if ancestor_ino == target_ino {
return true;
}
let mut visited = std::collections::HashSet::new();
visited.insert(target_ino);
while let Some(entry) = self.entries.get(&target_ino) {
let parent = entry.parent;
if parent == ancestor_ino {
return true;
}
if parent == 0 || !visited.insert(parent) {
break;
}
target_ino = parent;
}
false
}
/// Forget a node or, if open handles exist (open_count > 0), mark it unlinked
/// and remove it from parent children so lookups fail while open reads succeed.
pub(crate) fn forget_or_unlink(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
if let Some(&ino) = self.by_uid.get(uid)
&& let Some(entry) = self.entries.get_mut(&ino)
&& entry.open_count > 0
{
entry.unlinked = true;
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&k| k != ino);
}
return Some((entry.parent, entry.node.name.clone()));
}
self.forget(uid)
}
/// Forget a node entirely: drop its inode, its uid mapping, its own cached
/// listing, its slot in its parent's listing, and its DB row. Returns
/// `(parent_ino, name)` when the node was known, so the caller can notify
/// the kernel.
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
let ino = self.by_uid.remove(uid)?;
if let Err(e) = self.db.delete_node(uid) {
warn!(%uid, error = %e, "db delete_node failed");
}
let entry = self.entries.remove(&ino)?;
self.children.remove(&ino);
if let Some(kids) = self.children.get_mut(&entry.parent) {
kids.retain(|&k| k != ino);
}
Some((entry.parent, entry.node.name))
}
/// Move a node to a new parent and/or name within the tree, writing it
/// through like any other mutation.
///
/// The online rename instead forgets the node and lets the destination
/// re-enumerate, which is the cheaper way to stay honest about what the
/// server did. A queued rename cannot: re-enumerating needs the network, and
/// the server has not been told yet in any case — so this *is* the tree's
/// new truth until the op drains (offline.md Phase 3b).
pub(crate) fn rename_in_place(
&mut self,
ino: u64,
new_parent: u64,
new_parent_uid: &NodeUid,
name: &str,
) {
let Some(entry) = self.entries.get_mut(&ino) else {
return;
};
let old_parent = entry.parent;
entry.parent = new_parent;
entry.node.name = name.to_string();
entry.node.parent_uid = Some(new_parent_uid.clone());
let node = entry.node.clone();
if old_parent != new_parent {
if let Some(kids) = self.children.get_mut(&old_parent) {
kids.retain(|&k| k != ino);
}
// Only if the destination is listed: pushing into a listing that was
// never enumerated would invent a one-child folder.
if let Some(kids) = self.children.get_mut(&new_parent)
&& !kids.contains(&ino)
{
kids.push(ino);
}
}
if let Err(e) = self.db.upsert_node(&node) {
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
}
}
/// Drop a directory's cached child listing and mark it unlisted in the DB,
/// so the next access re-enumerates instead of trusting a stale listing.
///
/// The DB flag is cleared whether or not the listing was resident. A folder
/// trimmed from the hot cache but still `listed` in the DB is exactly the
/// case that needs invalidating — returning early there would leave
/// `ensure_children` free to rebuild the stale listing from disk.
pub(crate) fn invalidate_listing(&mut self, ino: u64) {
self.children.remove(&ino);
if let Some(e) = self.entries.get(&ino) {
let uid = e.uid.clone();
if let Err(err) = self.db.set_listed(&uid, false) {
warn!(%uid, error = %err, "db set_listed(false) failed");
}
}
}
/// Settle the local state after a node has been moved (and possibly renamed)
/// on the remote: rewrite it in place, and drop **both** directories'
/// listings so each re-enumerates.
///
/// Both, because each is stale for its own reason. The destination has
/// gained a child it does not know about; the source has lost one. The
/// source is the subtler half, and was audit A5: pruning the moved node from
/// the source's in-memory children looks sufficient while that entry stays
/// resident, but the source's DB row is left `listed = 1`. Once it is
/// evicted from the hot cache, or the daemon restarts, `ensure_children`
/// rebuilds the listing from the DB and declares it complete, so anything
/// else that changed remotely under the source since is never seen.
///
/// What this must *not* do is `forget` the node. Forgetting drops its
/// `by_uid` mapping, so the re-enumeration hands it a fresh inode — while
/// the kernel has already carried the renamed dentry over to the *old* one.
/// Every lookup through that dentry then resolves to an inode `entries` no
/// longer holds and fails `ENOENT`, so the renamed directory reads as
/// missing (`ls` on it errors while `ls` of its parent lists it) until the
/// entry TTL expires. Keeping the inode keeps the kernel's dentry valid.
///
/// A pure rename (`from == to`) invalidates that one directory once.
pub(crate) fn relocate(
&mut self,
ino: u64,
from_parent: u64,
to_parent: u64,
to_parent_uid: &NodeUid,
name: &str,
) {
self.rename_in_place(ino, to_parent, to_parent_uid, name);
self.invalidate_listing(to_parent);
if from_parent != to_parent {
self.invalidate_listing(from_parent);
}
}
/// Update a file entry's recorded plaintext size so `getattr` reflects an
/// in-progress write before the new revision is sealed.
pub(crate) fn set_size(&mut self, ino: u64, size: u64) {
if let Some(e) = self.entries.get_mut(&ino)
&& let NodeKind::File { claimed_size, .. } = &mut e.node.kind
{
*claimed_size = Some(size as i64);
}
}
/// Update a file entry's modification time (epoch seconds).
pub(crate) fn touch_mtime(&mut self, ino: u64, secs: i64) {
if let Some(e) = self.entries.get_mut(&ino) {
e.node.modification_time = secs;
}
}
/// Record the size and mtime of a write that has been accepted but not yet
/// uploaded, persisting them like any other node mutation.
///
/// The write-through is what makes the new size outlive the process: until
/// the op drains, the remote still holds the old revision (or, for a node
/// created offline, nothing at all), so this row is the only record that the
/// file is as long as the caller was told it is. Without it a restart serves
/// the stale size and the file reads as truncated — or empty — while its
/// bytes sit safely in staging (offline.md Phase 3).
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
self.set_size(ino, size);
self.touch_mtime(ino, mtime);
if let Some(e) = self.entries.get(&ino)
&& let Err(err) = self.db.upsert_node(&e.node)
{
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
fn node(link: &str, parent: &str, name: &str, is_dir: bool) -> Node {
Node {
uid: uid(link),
parent_uid: Some(uid(parent)),
kind: if is_dir {
NodeKind::Folder
} else {
NodeKind::File {
media_type: "text/plain".into(),
total_size_on_storage: 0,
active_revision_state: None,
claimed_size: Some(0),
claimed_modification_time: None,
}
},
name: name.into(),
creation_time: 100,
modification_time: 100,
trashed: false,
is_shared: false,
is_shared_publicly: false,
signature_email: None,
verification: Default::default(),
}
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-state-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `Db::open_in_memory` is `#[cfg(test)]` inside `pdfs-core`, so it does not
/// exist for this crate's tests — a temp file is the equivalent here. The
/// directory outlives the state because the state holds the open database.
fn state() -> (State, TempDir) {
let dir = TempDir::new();
let db = Db::open(&dir.0.join("cache.db")).unwrap();
let st = State {
entries: HashMap::new(),
by_uid: HashMap::new(),
children: HashMap::new(),
next_ino: 1,
active_writes: HashMap::new(),
handles: HashMap::new(),
next_fh: 1,
db: Arc::new(db),
};
(st, dir)
}
/// Two folders, each holding one file, both listed — the state a move
/// starts from. Returns `(state, src_ino, dst_ino)`.
fn two_folders() -> (State, TempDir, u64, u64) {
let (mut st, dir) = state();
let src = st.intern(0, node("src", "root", "src", true));
let dst = st.intern(0, node("dst", "root", "dst", true));
let f = st.intern(src, node("f", "src", "f.txt", false));
st.children.insert(src, vec![f]);
st.children.insert(dst, vec![]);
st.db.set_listed(&uid("src"), true).unwrap();
st.db.set_listed(&uid("dst"), true).unwrap();
(st, dir, src, dst)
}
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
/// claiming a complete listing, so once the hot cache drops it, remote
/// changes under it stop being seen. `relocate` has to clear both sides.
#[test]
fn relocate_invalidates_the_source_as_well_as_the_destination() {
let (mut st, _dir, src, dst) = two_folders();
let f = st.by_uid[&uid("f")];
st.relocate(f, src, dst, &uid("dst"), "f.txt");
assert!(
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
"destination re-enumerates: it gained a child whose row was just deleted"
);
assert!(
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
"source re-enumerates: its listing predates the move, and a stale \
`listed` flag would hide every later remote change under it"
);
assert!(!st.children.contains_key(&src));
assert!(!st.children.contains_key(&dst));
assert_eq!(
st.by_uid.get(&uid("f")),
Some(&f),
"the inode survives the move: the kernel has already pointed the \
renamed dentry at it, so re-interning under a fresh one would make \
every lookup through that dentry ENOENT"
);
assert_eq!(st.entries[&f].parent, dst);
}
/// A pure rename never leaves the directory, so there is one listing to
/// drop, not two — and dropping it must not depend on the parents differing.
#[test]
fn relocate_within_one_directory_still_invalidates_it() {
let (mut st, _dir, src, _dst) = two_folders();
let f = st.by_uid[&uid("f")];
st.relocate(f, src, src, &uid("src"), "renamed.txt");
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
assert!(!st.children.contains_key(&src));
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
assert_eq!(st.entries[&f].node.name, "renamed.txt");
}
/// The failure mode the fix exists for, stated directly: forgetting the node
/// prunes it from the resident listing but leaves the DB claiming the source
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
#[test]
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
let (mut st, _dir, _src, _dst) = two_folders();
st.forget(&uid("f"));
let listed = st.db.children_if_listed(&uid("src")).unwrap();
assert!(
listed.is_some(),
"forget does not clear the flag — which is exactly why relocate must"
);
assert!(
listed.unwrap().is_empty(),
"and the listing it would serve is the moved file's absence, \
with no way to notice anything else changed"
);
}
#[test]
fn test_is_ancestor_of_hierarchy() {
let (mut st, _dir) = state();
let root = st.intern(0, node("root_id", "none", "root", true));
let p1 = st.intern(root, node("p1_id", "root_id", "p1", true));
let p2 = st.intern(p1, node("p2_id", "p1_id", "p2", true));
let child = st.intern(p2, node("child_id", "p2_id", "child", true));
assert!(st.is_ancestor_of(root, root), "self is ancestor");
assert!(st.is_ancestor_of(root, p1), "root is ancestor of p1");
assert!(
st.is_ancestor_of(root, child),
"root is ancestor of deep child"
);
assert!(st.is_ancestor_of(p1, child), "p1 is ancestor of deep child");
assert!(
!st.is_ancestor_of(child, root),
"child is not ancestor of root"
);
assert!(!st.is_ancestor_of(child, p1), "child is not ancestor of p1");
}
#[test]
fn test_state_has_children_mem_and_db() {
let (mut st, _dir) = state();
let folder = st.intern_mem(0, node("dir_uid", "none", "dir", true));
assert!(!st.has_children(folder), "initially empty");
// Memory-only children (using intern_mem so DB is not populated yet)
let f1 = st.intern_mem(folder, node("file1_uid", "dir_uid", "file1.txt", false));
st.children.insert(folder, vec![f1]);
assert!(st.has_children(folder), "has child in memory");
st.children.insert(folder, vec![]);
assert!(!st.has_children(folder), "cleared memory children");
// DB children
st.db
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
.unwrap();
assert!(st.has_children(folder), "has child in db");
}
}
File diff suppressed because it is too large. Load diff
+251
View File
@@ -0,0 +1,251 @@
//! Filesystem watcher, polling, and debounce control plane.
use super::*;
/// How long the watcher must go quiet before a burst counts as finished.
///
/// Measured from the *last* event, not the first: applications do not write a
/// file once. An editor saves by writing a temp file and renaming it over the
/// target; an export or a database dump writes continuously for as long as it
/// takes. Settling a fixed interval after the first event walks the tree while
/// the file is still growing, and uploads a torn snapshot as a real revision —
/// corrected on the next pass, but only after spending the encrypt-and-upload
/// on a file that was never in that state.
const DEBOUNCE: Duration = Duration::from_secs(2);
/// Ceiling on the settle wait, so a directory under sustained change still
/// syncs. Without it, copying a large tree in — events arriving forever —
/// would postpone the reconcile for as long as the copy ran.
const MAX_SETTLE: Duration = Duration::from_secs(30);
/// How often to re-walk remotes so changes made on other clients are pulled in.
const POLL_INTERVAL: Duration = Duration::from_secs(120);
/// How many uploads/downloads/folder-creations a reconcile runs at once. The
/// per-item work is a network round-trip, so a folder with thousands of files is
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
/// engine does.
pub(super) const SYNC_CONCURRENCY: usize = 8;
/// A message to the sync engine's control thread.
pub(crate) enum SyncMsg {
/// Reconcile one folder by id (a local change, or a targeted `SyncNow`).
Reconcile(i64),
/// Reconcile every mirror folder (periodic poll, or startup).
ReconcileAll,
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
Rewatch,
}
/// Start the sync engine: a control thread that owns the filesystem watcher and
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
/// on `rx`; senders live in [`Core::sync_tx`].
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
if let Err(e) = std::thread::Builder::new()
.name("pdfs-sync".into())
.spawn(move || engine_loop(core, rx))
{
warn!(error = %e, "failed to start sync engine");
}
}
/// The engine control loop. Single-threaded so reconcile passes never overlap.
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 tx_events = core.sync_tx.clone();
let mut watcher =
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
let Ok(event) = res else { return };
// Ignore pure metadata/access noise; content changes are what matter.
if matches!(event.kind, notify::EventKind::Access(_)) {
return;
}
let _ = tx_events.send(SyncMsg::ReconcileAll);
}) {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
// Still run the loop so polling and explicit SyncNow work.
poll_only_loop(&core, rx);
return;
}
};
// Periodic remote poll.
{
let tx = core.sync_tx.clone();
std::thread::Builder::new()
.name("pdfs-sync-poll".into())
.spawn(move || {
loop {
std::thread::sleep(POLL_INTERVAL);
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
}
})
.ok();
}
rewatch(&core, &mut watcher, &watched);
reconcile_all(&core);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
classify(msg, &mut ids, &mut all, &mut do_rewatch);
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
if do_rewatch {
rewatch(&core, &mut watcher, &watched);
}
if all {
reconcile_all(&core);
} else {
for id in ids {
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
}
}
}
}
/// Fallback loop when no filesystem watcher could be created: poll + SyncNow only.
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
{
let tx = core.sync_tx.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(POLL_INTERVAL);
if tx.send(SyncMsg::ReconcileAll).is_err() {
break;
}
}
});
}
reconcile_all(core);
while let Ok(msg) = rx.recv() {
let mut ids: HashSet<i64> = HashSet::new();
let mut all = false;
let mut do_rewatch = false;
classify(msg, &mut ids, &mut all, &mut do_rewatch);
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
if all {
reconcile_all(core);
} else {
for id in ids {
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
core.reconcile_folder(&folder);
}
}
}
}
}
/// Absorb the rest of an event burst, returning once the watcher has been quiet
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
/// first. The caller has already classified the event that opened the burst.
///
/// Split out with its timings as parameters so the settling behaviour can be
/// tested in milliseconds rather than in the tens of seconds the real constants
/// describe.
pub(super) fn settle_with(
rx: &Receiver<SyncMsg>,
ids: &mut HashSet<i64>,
all: &mut bool,
do_rewatch: &mut bool,
quiet: Duration,
cap: Duration,
) {
let deadline = Instant::now() + cap;
loop {
let now = Instant::now();
if now >= deadline {
break;
}
// Never wait past the cap, however quiet the watcher goes.
let wait = quiet.min(deadline - now);
match rx.recv_timeout(wait) {
// Another event: the burst is still going, so the quiet window
// starts again from here.
Ok(m) => classify(m, ids, all, do_rewatch),
// Quiet for a full window: whatever was being written has stopped.
Err(RecvTimeoutError::Timeout) => break,
// Every sender is gone; the caller's loop is about to end anyway.
Err(RecvTimeoutError::Disconnected) => break,
}
}
}
/// [`settle_with`] at the real timings.
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
}
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
match msg {
SyncMsg::Reconcile(id) => {
ids.insert(id);
}
SyncMsg::ReconcileAll => *all = true,
SyncMsg::Rewatch => *rewatch = true,
}
}
/// Reconcile every mirror folder in turn.
fn reconcile_all(core: &Core) {
let folders = match core.db.sync_folder_list() {
Ok(f) => f,
Err(e) => {
warn!(error = ?e, "sync: cannot list folders");
return;
}
};
for folder in folders {
core.reconcile_folder(&folder);
}
}
/// Bring the filesystem watches in line with the current mirror-folder set:
/// watch newly-added folders, drop removed ones.
fn rewatch(
core: &Core,
watcher: &mut notify::RecommendedWatcher,
watched: &Mutex<Vec<(PathBuf, i64)>>,
) {
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
Ok(f) => f
.into_iter()
.filter(|f| f.mode == "mirror")
.map(|f| (PathBuf::from(f.local_path), f.id))
.collect(),
Err(e) => {
warn!(error = ?e, "sync: cannot list folders to watch");
return;
}
};
let mut have = watched.lock();
// Drop watches no longer wanted.
have.retain(|(path, _)| {
if want.iter().any(|(p, _)| p == path) {
true
} else {
let _ = watcher.unwatch(path);
false
}
});
// Add newly wanted watches.
for (path, id) in &want {
if have.iter().any(|(p, _)| p == path) {
continue;
}
match watcher.watch(path, RecursiveMode::Recursive) {
Ok(()) => have.push((path.clone(), *id)),
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
}
}
}
// ---- reconcile ------------------------------------------------------------
+81
View File
@@ -0,0 +1,81 @@
//! Local open-for-write detection used to defer torn uploads.
use super::*;
// ---- open-for-write detection ---------------------------------------------
/// Scan `/proc/*/fd` once and return the set of **canonical** paths under
/// `root` that any process currently holds open for writing (`O_WRONLY` or
/// `O_RDWR`). The scan is best-effort: unreadable `/proc/*/fd` directories
/// (other users' processes, or kernel threads) are silently skipped.
///
/// Cost: one `readdir` of `/proc`, then for every process one `readdir` of its
/// `/proc/<pid>/fd/`, and one `readlink` + one `open` of `/proc/<pid>/fdinfo/<n>`
/// per open fd that resolves under `root`. For a typical desktop with a few
/// hundred processes and a handful of files inside the sync root, this
/// completes in low single-digit milliseconds.
pub(super) fn open_for_write_set(root: &Path) -> HashSet<PathBuf> {
let mut result = HashSet::new();
let Ok(root_canonical) = std::fs::canonicalize(root) else {
return result;
};
let Ok(proc_entries) = std::fs::read_dir("/proc") else {
return result;
};
for proc_entry in proc_entries.filter_map(|e| e.ok()) {
let pid_name = proc_entry.file_name();
// Skip non-numeric entries (kernel threads, /proc/self, etc.)
if !pid_name
.to_str()
.is_some_and(|s| s.bytes().all(|b| b.is_ascii_digit()))
{
continue;
}
let fd_dir = PathBuf::from("/proc").join(&pid_name).join("fd");
let Ok(fds) = std::fs::read_dir(&fd_dir) else {
continue;
};
for fd_entry in fds.filter_map(|e| e.ok()) {
// readlink on /proc/<pid>/fd/<n> gives the target path.
let link = fd_dir.join(fd_entry.file_name());
let Ok(target) = std::fs::read_link(&link) else {
continue;
};
// Quick prefix check before the more expensive fdinfo read.
if !target.starts_with(&root_canonical) {
continue;
}
// Read the fd's flags from /proc/<pid>/fdinfo/<n> to determine
// the access mode. The first line is "pos:\t<offset>", the second
// is "flags:\t<octal>". We only need the low two bits of flags:
// 0 = O_RDONLY, 1 = O_WRONLY, 2 = O_RDWR
let fdinfo_path = PathBuf::from("/proc")
.join(&pid_name)
.join("fdinfo")
.join(fd_entry.file_name());
if let Ok(contents) = std::fs::read_to_string(&fdinfo_path)
&& is_write_mode(&contents)
{
result.insert(target);
}
}
}
result
}
/// Parse the `flags:` line out of a `/proc/<pid>/fdinfo/<n>` file and return
/// true if the low two bits indicate write access (`O_WRONLY = 1` or
/// `O_RDWR = 2`).
pub(super) fn is_write_mode(fdinfo: &str) -> bool {
for line in fdinfo.lines() {
if let Some(rest) = line.strip_prefix("flags:\t") {
if let Ok(flags) = u32::from_str_radix(rest.trim_start_matches('0'), 8) {
let access = flags & 0o3; // O_ACCMODE
return access == 1 || access == 2; // O_WRONLY or O_RDWR
}
// An empty octal string ("0" stripped to "") means flags == 0 == O_RDONLY.
return false;
}
}
false
}
+164
View File
@@ -0,0 +1,164 @@
//! Reconciliation snapshots, queued operations, and pass outcomes.
use super::*;
pub(super) struct LocalItem {
pub(super) is_dir: bool,
pub(super) mtime: i64,
pub(super) size: i64,
/// True when another process holds this file open for writing (detected via
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
/// misread as a deletion — but treated as unchanged for upload purposes,
/// deferring the upload until the writer closes the file.
pub(super) open_for_write: bool,
}
/// One item found while walking a remote tree.
pub(super) struct RemoteItem {
pub(super) uid: NodeUid,
pub(super) is_dir: bool,
pub(super) mtime: i64,
pub(super) size: i64,
}
/// The result of a reconcile pass: what it moved, how many paths were kept as
/// conflict copies, and how many failed to apply (and so still need another
/// pass). The counts drive both the folder's state and its activity summary.
#[derive(Default)]
pub(super) struct Outcome {
pub(super) uploaded: usize,
pub(super) downloaded: usize,
pub(super) created: usize,
pub(super) deleted: usize,
pub(super) conflicts: usize,
pub(super) errors: usize,
/// Files skipped because another process held them open for writing.
pub(super) deferred: usize,
}
impl Outcome {
/// Fold in one applied op.
pub(super) fn record(&mut self, applied: &Applied) {
match applied {
Applied::Dir(..) => self.created += 1,
Applied::Uploaded => self.uploaded += 1,
Applied::Downloaded => self.downloaded += 1,
Applied::Conflict => self.conflicts += 1,
}
}
/// Whether the pass moved nothing at all — the common case on a poll of an
/// unchanged folder, which should not add a line to the activity feed.
pub(super) fn is_empty(&self) -> bool {
self.uploaded == 0
&& self.downloaded == 0
&& self.created == 0
&& self.deleted == 0
&& self.conflicts == 0
&& self.errors == 0
&& self.deferred == 0
}
/// A human summary of the pass: "3 uploaded, 1 downloaded, 2 failed".
pub(super) fn summary(&self) -> String {
let mut parts = Vec::new();
for (n, label) in [
(self.uploaded, "uploaded"),
(self.downloaded, "downloaded"),
(self.created, "folder(s) created"),
(self.deleted, "deleted"),
(self.conflicts, "conflicted"),
(self.deferred, "deferred (open for write)"),
(self.errors, "failed"),
] {
if n > 0 {
parts.push(format!("{n} {label}"));
}
}
parts.join(", ")
}
}
/// A network operation queued during classification and run concurrently in a
/// per-depth batch. Parent uids are resolved up front (the parent folder is one
/// depth shallower and already created), so tasks share nothing mutable.
pub(super) enum Pending {
/// Create a new remote folder under `parent`.
CreateDir { rel: String, parent: NodeUid },
/// Upload a brand-new local file into `parent`.
UploadNew { rel: String, parent: NodeUid },
/// Upload a changed local file as a new revision of `uid`.
UploadRevision { rel: String, uid: NodeUid },
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
/// remote's reported size, used as the transfer's expected total.
Download {
rel: String,
uid: NodeUid,
mtime: i64,
size: i64,
},
/// Both sides changed: set the local copy aside, then download remote `uid`.
Conflict {
rel: String,
uid: NodeUid,
mtime: i64,
size: i64,
},
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
/// copy into `parent` under a conflict name, leaving the remote file as it is.
PushConflict { rel: String, parent: NodeUid },
}
impl Pending {
/// The path this op acts on, relative to the folder root.
pub(super) fn rel(&self) -> &str {
match self {
Pending::CreateDir { rel, .. }
| Pending::UploadNew { rel, .. }
| Pending::UploadRevision { rel, .. }
| Pending::Download { rel, .. }
| Pending::Conflict { rel, .. }
| Pending::PushConflict { rel, .. } => rel,
}
}
/// How this op reads in the activity feed.
pub(super) fn kind(&self) -> ActivityKind {
match self {
Pending::CreateDir { .. } => ActivityKind::CreateFolder,
Pending::UploadNew { .. }
| Pending::UploadRevision { .. }
| Pending::PushConflict { .. } => ActivityKind::Upload,
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download,
}
}
/// The activity line's detail, which distinguishes ops that share a kind.
pub(super) fn detail(&self) -> &'static str {
match self {
Pending::CreateDir { .. } => "on Drive",
Pending::UploadNew { .. } => "new file",
Pending::UploadRevision { .. } => "new version",
Pending::Download { .. } => "from Drive",
Pending::Conflict { .. } => "local changes kept as a conflict copy",
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
}
}
}
/// Why a full reconcile pass stopped without a result.
pub(super) enum PassAbort {
/// An on-demand switch was queued while the pass was running. Everything left
/// to do is work on a local copy about to be evicted, so the pass gives up its
/// remaining work to a [`push pass`](Core::push_pass) instead. Not a failure —
/// nothing is left half-applied, because each step is applied whole.
Interrupted,
/// The pass could not establish its diff (a walk or the baseline load failed).
Failed(String),
}
impl From<String> for PassAbort {
fn from(e: String) -> Self {
PassAbort::Failed(e)
}
}
+353
View File
@@ -0,0 +1,353 @@
//! Pure reconciliation ordering, safety guards, signatures, and path rules.
use super::*;
/// The paths a reconcile pass will classify: the union of the local, remote,
/// and baseline states, minus anything the ignore rules exclude, shallowest
/// first so a parent folder is created before its children are placed in it.
///
/// The ignore filter belongs *here*, on the union, rather than on the local walk
/// alone — filtering the walk alone would be actively destructive. A file synced
/// before it became ignored is absent from `local` but present in `remote` and
/// `baseline`, which is precisely the "local deleted, remote untouched" shape
/// `do_reconcile` responds to by trashing the remote copy. Adding a line to
/// `.pdfsignore` must never delete anything from Drive.
///
/// Ignored baseline rows are deliberately left in the database rather than
/// removed: with the row intact, un-ignoring the path later re-adopts the
/// existing remote file instead of reading as a brand-new local one.
pub(super) fn classification_order<L, R, B>(
local: &HashMap<String, L>,
remote: &HashMap<String, R>,
baseline: &HashMap<String, B>,
rules: &IgnoreRules,
) -> Vec<String>
where
L: HasKind,
R: HasKind,
{
let mut paths: HashSet<String> = HashSet::new();
paths.extend(local.keys().cloned());
paths.extend(remote.keys().cloned());
paths.extend(baseline.keys().cloned());
let mut order: Vec<String> = paths.into_iter().collect();
if !rules.is_empty() {
order.retain(|rel| {
// A baseline-only path has no kind recorded, so it is tested as
// both; see `filter_baseline` for why erring towards "ignored" is
// the safe direction here.
let kind = local
.get(rel)
.map(HasKind::is_dir)
.or_else(|| remote.get(rel).map(HasKind::is_dir));
match kind {
Some(is_dir) => !rules.is_ignored(rel, is_dir),
None => !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true),
}
});
}
order.sort_by_key(|p| p.matches('/').count());
order
}
/// Lets [`classification_order`] ask either walk's item whether it is a
/// directory without knowing which walk it came from.
pub(super) trait HasKind {
fn is_dir(&self) -> bool;
}
impl HasKind for LocalItem {
fn is_dir(&self) -> bool {
self.is_dir
}
}
impl HasKind for RemoteItem {
fn is_dir(&self) -> bool {
self.is_dir
}
}
/// The baseline minus its ignored paths, for [`guard_local_wipe`].
///
/// The guard asks "did every synced path vanish locally?", and an ignored path
/// is absent from the local walk by rule rather than by loss. Left in, a rule
/// covering the whole tree would trip the guard on every pass and wedge that
/// folder's sync for good.
///
/// A baseline row does not record whether it was a file or a folder, so a path
/// counts as ignored if it matches as either. Erring towards "ignored" only ever
/// shrinks the set the guard checks, which weakens a safety net rather than
/// causing a deletion — the wrong direction to be wrong in is the other one.
pub(super) fn filter_baseline<'a, B>(
baseline: &'a HashMap<String, B>,
rules: &IgnoreRules,
) -> HashMap<String, &'a B> {
baseline
.iter()
.filter(|(rel, _)| !rules.is_ignored(rel, false) && !rules.is_ignored(rel, true))
.map(|(rel, entry)| (rel.clone(), entry))
.collect()
}
/// 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
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
/// One surviving path is enough to clear the guard.
pub(super) fn guard_local_wipe<B, L>(
baseline: &HashMap<String, B>,
local: &HashMap<String, L>,
) -> Result<(), String> {
if baseline.len() >= 2 && baseline.keys().all(|rel| !local.contains_key(rel)) {
return Err(format!(
"every one of the {} synced paths is missing locally; refusing to trash \
them on Drive. Check that the folder is mounted and readable.",
baseline.len()
));
}
Ok(())
}
/// The size the baseline recorded for `rel`, if its remote signature's mtime
/// still matches `mtime`.
pub(super) fn unchanged_remote_size(
baseline: &HashMap<String, StoredSyncEntry>,
rel: &str,
mtime: i64,
) -> Option<i64> {
match baseline.get(rel).and_then(remote_sig) {
Some((recorded, size)) if recorded == mtime => Some(size),
_ => None,
}
}
/// Join a child `name` onto a walk's `prefix`, giving a rel path.
pub(super) fn join_rel(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}/{name}")
}
}
/// The stored remote signature of a baseline row, if it has one.
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
match (&e.remote_rev, &e.remote_hash) {
(Some(m), Some(s)) => Some((m.parse().ok()?, s.parse().ok()?)),
_ => None,
}
}
/// The parent of a `/`-joined relative path (`""` for a top-level entry).
pub(super) fn parent_rel(rel: &str) -> &str {
match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
}
}
/// The final component of a `/`-joined relative path.
pub(crate) fn base_name(rel: &str) -> &str {
match rel.rfind('/') {
Some(i) => &rel[i + 1..],
None => rel,
}
}
/// Turn a `/`-joined relative path into an OS path (`/` is already the separator
/// on Linux, this keeps the intent explicit).
pub(super) fn rel_to_path(rel: &str) -> PathBuf {
rel.split('/').collect()
}
/// The name for a conflict copy of `path`, e.g. `notes (sync-conflict 1700000000).txt`.
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
let ext = path.extension().and_then(|s| s.to_str());
let name = match ext {
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"),
None => format!("{stem} (sync-conflict {stamp})"),
};
match path.parent() {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
/// A pure decision for one file-shaped path. Transfer identities and filesystem
/// effects are attached by the executor after this classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FilePlan {
Unchanged,
Deferred,
UploadNew,
UploadRevision,
Download,
Conflict,
DeleteLocal,
DeleteRemote,
ForgetBaseline,
}
pub(super) fn plan_file(
local: Option<&LocalItem>,
remote: Option<&RemoteItem>,
baseline: Option<&StoredSyncEntry>,
) -> FilePlan {
if local.is_some_and(|item| item.open_for_write) {
return FilePlan::Deferred;
}
let local_changed = local.is_some_and(|item| {
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
});
let remote_changed = remote.is_some_and(|item| {
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
});
match (local, remote) {
(Some(_), Some(_)) => match (local_changed, remote_changed) {
(false, false) => FilePlan::Unchanged,
(true, false) => FilePlan::UploadRevision,
(false, true) => FilePlan::Download,
(true, true) => FilePlan::Conflict,
},
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
(Some(_), None) => FilePlan::DeleteLocal,
(None, Some(_)) if baseline.is_none() || remote_changed => FilePlan::Download,
(None, Some(_)) => FilePlan::DeleteRemote,
(None, None) => FilePlan::ForgetBaseline,
}
}
#[cfg(test)]
mod file_plan_tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn local(mtime: i64, size: i64) -> LocalItem {
LocalItem {
is_dir: false,
mtime,
size,
open_for_write: false,
}
}
fn remote(mtime: i64, size: i64) -> RemoteItem {
RemoteItem {
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
is_dir: false,
mtime,
size,
}
}
fn baseline(
local_mtime: i64,
local_size: i64,
remote_mtime: i64,
remote_size: i64,
) -> StoredSyncEntry {
StoredSyncEntry {
rel_path: "file".into(),
remote_uid: Some("v~l".into()),
local_mtime,
local_size,
remote_rev: Some(remote_mtime.to_string()),
remote_hash: Some(remote_size.to_string()),
}
}
#[test]
fn file_plan_covers_every_presence_and_change_shape() {
let same_local = local(10, 20);
let changed_local = local(11, 20);
let same_remote = remote(30, 40);
let changed_remote = remote(31, 40);
let base = baseline(10, 20, 30, 40);
let cases = [
(None, None, None, FilePlan::ForgetBaseline),
(None, None, Some(&base), FilePlan::ForgetBaseline),
(Some(&same_local), None, None, FilePlan::UploadNew),
(Some(&same_local), None, Some(&base), FilePlan::DeleteLocal),
(Some(&changed_local), None, Some(&base), FilePlan::UploadNew),
(None, Some(&same_remote), None, FilePlan::Download),
(
None,
Some(&same_remote),
Some(&base),
FilePlan::DeleteRemote,
),
(None, Some(&changed_remote), Some(&base), FilePlan::Download),
(
Some(&same_local),
Some(&same_remote),
None,
FilePlan::Conflict,
),
(
Some(&same_local),
Some(&same_remote),
Some(&base),
FilePlan::Unchanged,
),
(
Some(&changed_local),
Some(&same_remote),
Some(&base),
FilePlan::UploadRevision,
),
(
Some(&same_local),
Some(&changed_remote),
Some(&base),
FilePlan::Download,
),
(
Some(&changed_local),
Some(&changed_remote),
Some(&base),
FilePlan::Conflict,
),
];
for (local, remote, baseline, expected) in cases {
assert_eq!(plan_file(local, remote, baseline), expected);
}
}
#[test]
fn an_open_writer_overrides_every_other_file_decision() {
let mut local = local(11, 20);
local.open_for_write = true;
let remote = remote(31, 40);
let base = baseline(10, 20, 30, 40);
for (remote, baseline) in [
(None, None),
(None, Some(&base)),
(Some(&remote), None),
(Some(&remote), Some(&base)),
] {
assert_eq!(
plan_file(Some(&local), remote, baseline),
FilePlan::Deferred
);
}
}
#[test]
fn a_missing_remote_signature_is_treated_as_changed() {
let local = local(10, 20);
let remote = remote(30, 40);
let mut base = baseline(10, 20, 30, 40);
base.remote_hash = None;
assert_eq!(
plan_file(Some(&local), Some(&remote), Some(&base)),
FilePlan::Download
);
}
}
+140 -16
View File
@@ -1,22 +1,28 @@
//! In-memory registry of in-flight transfers, behind [`Request::GetQueueStatus`].
//! In-memory registry of in-flight work, behind [`Request::GetQueueStatus`].
//!
//! The daemon downloads and uploads whole files through the SDK's streaming
//! [`Read`]/[`Write`] variants. Wrapping that reader/writer in a
//! Two kinds of work live here. **Transfers** move bytes: the daemon downloads
//! and uploads whole files through the SDK's streaming [`Read`]/[`Write`]
//! variants, and wrapping that reader/writer in a
//! [`CountingReader`]/[`CountingWriter`] lets each block of bytes tick a
//! per-transfer counter without the SDK knowing anything about progress. A
//! [`TransferGuard`] registers a transfer on creation and deregisters it on
//! drop, so the registry always reflects exactly what is in flight — even if a
//! transfer fails partway and unwinds.
//! per-transfer counter without the SDK knowing anything about progress.
//! **Jobs** ([`JobGuard`]) cover the long stretches around them that move no
//! bytes — walking a local tree, creating the remote folder skeleton, indexing
//! `$HOME` — which otherwise look to a front-end exactly like an idle daemon.
//!
//! Both register on creation and deregister on guard drop, so the registry
//! always reflects exactly what is in flight — even if the work fails partway
//! and unwinds.
//!
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
use parking_lot::Mutex;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use pdfs_core::control::{TransferDirection, TransferItem};
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
/// One registered transfer. `done` is bumped from the I/O wrapper without the
/// registry lock; the rest is immutable for the transfer's lifetime.
@@ -29,11 +35,22 @@ struct Entry {
started: Instant,
}
/// The set of transfers currently in flight. Cloned `Arc`-style across the FUSE
/// session and the control-socket task (both share one registry via [`Core`]).
/// One registered job. `detail`/`done`/`total` all move as the job runs; `total`
/// may grow when more work is discovered mid-job.
struct JobEntry {
title: String,
detail: Mutex<String>,
done: AtomicU64,
total: AtomicU64,
}
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
/// the FUSE session and the control-socket task (both share one registry via
/// [`Core`]).
#[derive(Default)]
pub struct TransferRegistry {
inner: Mutex<HashMap<u64, Arc<Entry>>>,
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
next: AtomicU64,
}
@@ -60,7 +77,7 @@ impl TransferRegistry {
done: AtomicU64::new(0),
started: Instant::now(),
});
self.inner.lock().unwrap().insert(id, entry.clone());
self.inner.lock().insert(id, entry.clone());
TransferGuard {
reg: self.clone(),
id,
@@ -68,14 +85,58 @@ impl TransferRegistry {
}
}
/// Snapshot every in-flight transfer for [`Response::Transfers`]. Speed is the
/// Register a long-running non-transfer job titled `title`, returning a guard
/// 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 {
let id = self.next.fetch_add(1, Ordering::Relaxed);
let entry = Arc::new(JobEntry {
title: title.into(),
detail: Mutex::new(String::new()),
done: AtomicU64::new(0),
total: AtomicU64::new(0),
});
self.jobs.lock().insert(id, entry.clone());
JobGuard {
reg: self.clone(),
id,
entry,
}
}
/// Snapshot every in-flight job for [`Response::Transfers`], oldest first, so
/// a front-end's rows keep their order across polls.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn jobs_snapshot(&self) -> Vec<JobItem> {
let map = self.jobs.lock();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| {
let e = &map[id];
JobItem {
title: e.title.clone(),
detail: e.detail.lock().clone(),
done: e.done.load(Ordering::Relaxed),
total: e.total.load(Ordering::Relaxed),
}
})
.collect()
}
/// Snapshot every in-flight transfer for [`Response::Transfers`], oldest
/// first so a front-end's rows keep their order across polls. Speed is the
/// running average since the transfer began — simple, and stable enough for a
/// progress widget without per-tick sampling state.
///
/// [`Response::Transfers`]: pdfs_core::control::Response::Transfers
pub fn snapshot(&self) -> Vec<TransferItem> {
let map = self.inner.lock().unwrap();
map.values()
let map = self.inner.lock();
let mut ids: Vec<_> = map.keys().copied().collect();
ids.sort_unstable();
ids.iter()
.map(|id| &map[id])
.map(|e| {
let done = e.done.load(Ordering::Relaxed);
let secs = e.started.elapsed().as_secs_f64();
@@ -116,7 +177,45 @@ impl TransferGuard {
impl Drop for TransferGuard {
fn drop(&mut self) {
self.reg.inner.lock().unwrap().remove(&self.id);
self.reg.inner.lock().remove(&self.id);
}
}
/// Lifetime handle for a registered job: describe what it is doing with
/// [`detail`], size it with [`set_total`], advance it with [`step`], and the job
/// leaves the registry when this drops. Shared across the tasks of one batch as
/// an `Arc`, so a concurrent phase can report a single "N of M" line.
///
/// [`detail`]: JobGuard::detail
/// [`set_total`]: JobGuard::set_total
/// [`step`]: JobGuard::step
pub struct JobGuard {
reg: Arc<TransferRegistry>,
id: u64,
entry: Arc<JobEntry>,
}
impl JobGuard {
/// Say what the job is working on right now; empty clears it.
pub fn detail(&self, what: impl Into<String>) {
*self.entry.detail.lock() = what.into();
}
/// Set how many steps the job now knows it has. Safe to raise mid-job as more
/// work is discovered.
pub fn set_total(&self, total: u64) {
self.entry.total.store(total, Ordering::Relaxed);
}
/// Mark one more step done.
pub fn step(&self) {
self.entry.done.fetch_add(1, Ordering::Relaxed);
}
}
impl Drop for JobGuard {
fn drop(&mut self) {
self.reg.jobs.lock().remove(&self.id);
}
}
@@ -148,6 +247,31 @@ impl<W: Write> Write for CountingWriter<'_, W> {
}
}
/// A [`Read`] that tallies bytes read and *owns* its [`TransferGuard`], so the
/// transfer stays registered exactly as long as the reader lives. Used by the
/// concurrent bulk uploader, where each task hands its reader to the SDK and has
/// nowhere separate to park the guard; the transfer deregisters when the SDK
/// drops the reader after sealing the revision. Owning (rather than borrowing)
/// also keeps each upload future `Send + 'static` for [`tokio::task::spawn`].
pub struct OwnedCountingReader<R> {
inner: R,
guard: TransferGuard,
}
impl<R: Read> OwnedCountingReader<R> {
pub fn new(inner: R, guard: TransferGuard) -> Self {
Self { inner, guard }
}
}
impl<R: Read> Read for OwnedCountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.guard.add(n as u64);
Ok(n)
}
}
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
pub struct CountingReader<'a, R> {
inner: R,
+298
View File
@@ -0,0 +1,298 @@
//! Bulk local-to-Drive upload workflow.
use super::*;
/// How many files the bulk uploader ships at once. Overlaps the per-file network
/// round-trips without letting an unbounded number of block buffers pile up.
const UPLOAD_CONCURRENCY: usize = 4;
/// One file queued for bulk upload, resolved during the directory walk so the
/// concurrent phase carries everything it needs (no shared state, no `block_on`).
struct UploadTask {
/// Inode of the (already-created) remote parent folder, for interning the
/// uploaded node afterwards.
parent_ino: u64,
parent_uid: NodeUid,
name: String,
/// Local filesystem path to stream from.
path: PathBuf,
size: u64,
}
/// Tally of a completed [`Core::upload_paths`] batch, for the daemon log.
#[derive(Default)]
pub(super) struct UploadStats {
pub(super) uploaded: usize,
pub(super) failed: usize,
/// Total plaintext bytes of the files that uploaded successfully.
pub(super) bytes: u64,
/// Folders created (or reused) to mirror the local tree.
pub(super) folders: usize,
}
/// Upload every [`UploadTask`] with at most `limit` in flight at once, each
/// streamed straight from disk and ticking its own transfer-registry guard.
/// Returns, per file, either `(parent_ino, new_uid)` for the caller to intern or
/// `(name, error)` to log — one failure never sinks the batch.
///
/// `job` counts files finished (either way: a failure is still one file the batch
/// no longer waits on), so a front-end can show "12 of 40" over the per-file bars.
async fn run_uploads(
core: Core,
tasks: Vec<UploadTask>,
limit: usize,
job: Arc<JobGuard>,
) -> Vec<Result<(u64, NodeUid, u64), (String, String)>> {
let sem = Arc::new(tokio::sync::Semaphore::new(limit));
let mut set = tokio::task::JoinSet::new();
for t in tasks {
let core = core.clone();
let sem = sem.clone();
set.spawn(async move {
let _permit = sem.acquire_owned().await.expect("semaphore not closed");
let file = match std::fs::File::open(&t.path) {
Ok(f) => f,
Err(e) => return Err((t.name, format!("open {}: {e}", t.path.display()))),
};
let mtime = std::fs::metadata(&t.path)
.and_then(|m| m.modified())
.ok()
.and_then(|mt| mt.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let guard = core
.transfers
.begin(&t.name, "", TransferDirection::Upload, t.size);
let reader = OwnedCountingReader::new(file, guard);
match core
.client
.upload_file_from(
&t.parent_uid,
&t.name,
media_type_for(&t.name),
reader,
t.size as i64,
Vec::new(),
mtime,
false,
)
.await
{
Ok(uid) => Ok((t.parent_ino, uid, t.size)),
Err(e) => Err((t.name, format!("upload: {e}"))),
}
});
}
let mut out = Vec::new();
while let Some(joined) = set.join_next().await {
job.step();
match joined {
Ok(result) => out.push(result),
Err(e) => warn!(error = %e, "upload task panicked"),
}
}
out
}
impl Core {
/// Bulk-upload local files and directory trees under `sources` into the
/// mountpoint-relative `parent_rel` folder. Directories are recreated (or
/// merged into an existing same-named folder) and walked; the resulting flat
/// set of files is uploaded with bounded concurrency, each ticking the
/// transfer registry so a front-end sees live progress. Runs on a background
/// thread — a large tree far outlasts the control socket's read timeout — so
/// it reports only a summary for the log. Individual failures are counted and
/// logged rather than aborting the whole batch.
pub(super) fn upload_paths(
&self,
parent_rel: &Path,
sources: &[PathBuf],
) -> CoreResult<UploadStats> {
let (pino, parent_uid) = self.resolve(parent_rel)?;
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
// Phase 1 (sequential): build the remote folder skeleton and collect the
// flat list of files to upload. Folders must exist before their children,
// so this can't be parallelised. On a deep tree this is a folder-creation
// round-trip per directory before a single byte moves — long enough that
// it needs a job of its own, or the daemon looks idle for minutes.
let mut tasks = Vec::new();
let mut folders = 0usize;
{
let job = self.transfers.begin_job("Preparing upload");
for src in sources {
if let Err(e) =
self.collect_uploads(pino, &parent_uid, src, &mut tasks, &mut folders, &job)
{
warn!(source = %src.display(), error = %e, "skipping source");
}
}
}
// Phase 2 (concurrent): upload the files, up to UPLOAD_CONCURRENCY at once.
// Each file reports its own bytes; this job is the batch's "N of M files",
// which is the number a user actually waits on.
let job = Arc::new(self.transfers.begin_job(match tasks.len() {
1 => "Uploading 1 file".to_string(),
n => format!("Uploading {n} files"),
}));
job.set_total(tasks.len() as u64);
let outcomes = self.rt.block_on(run_uploads(
self.clone(),
tasks,
UPLOAD_CONCURRENCY,
job.clone(),
));
drop(job);
// Phase 3 (sequential): intern each uploaded node so it shows up in the
// listing without a re-enumeration. fetch_node uses `block_on`, so it must
// run here rather than inside the async batch — and it is a round-trip per
// file, so it too gets a job rather than a silent tail.
let mut stats = UploadStats {
folders,
..UploadStats::default()
};
let job = self.transfers.begin_job("Finishing upload");
job.set_total(outcomes.len() as u64);
for outcome in outcomes {
job.step();
match outcome {
Ok((parent_ino, uid, size)) => {
stats.uploaded += 1;
stats.bytes += size;
match self.fetch_node(&uid) {
Ok(node) => {
let mut st = self.state.lock();
let ino = st.intern(parent_ino, node);
if let Some(kids) = st.children.get_mut(&parent_ino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
}
// Uploaded fine but the metadata refresh failed; it will
// appear on the next directory enumeration regardless.
Err(e) => warn!(%uid, error = ?e, "uploaded node metadata refresh failed"),
}
}
Err((name, msg)) => {
stats.failed += 1;
warn!(name, error = %msg, "file upload failed");
}
}
}
info!(
uploaded = stats.uploaded,
failed = stats.failed,
"bulk upload finished"
);
Ok(stats)
}
/// Resolve a remote child folder named `name` under `pino`, creating it if it
/// doesn't exist, and return its `(inode, uid)`. Reusing an existing same-named
/// folder makes re-uploading a directory merge into it rather than fail on a
/// duplicate name.
fn ensure_remote_folder(
&self,
pino: u64,
parent_uid: &NodeUid,
name: &str,
) -> CoreResult<(u64, NodeUid)> {
if name.is_empty() || name.contains('/') {
return Err(CoreError::invalid(format!("invalid folder name: {name:?}")));
}
self.ensure_children(pino)
.map_err(|e| self.errno_error(e, "enumerate"))?;
{
let st = self.state.lock();
if let Some(kids) = st.children.get(&pino) {
for &ino in kids {
if let Some(e) = st.entries.get(&ino)
&& e.node.is_folder()
&& e.node.name == name
{
return Ok((ino, e.uid.clone()));
}
}
}
}
let new_uid = self
.rt
.block_on(
self.client
.create_folder(parent_uid, name, Some(now_secs())),
)
.map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?;
let node = self
.fetch_node(&new_uid)
.map_err(|e| self.errno_error(e, "fetch node"))?;
let mut st = self.state.lock();
let ino = st.intern(pino, node);
if let Some(kids) = st.children.get_mut(&pino)
&& !kids.contains(&ino)
{
kids.push(ino);
}
Ok((ino, new_uid))
}
/// Walk one local source path, appending its files to `tasks`. A file becomes
/// one task; a directory is recreated remotely and recursed into (children
/// sorted for a stable order). Symlinks and other special files are skipped.
///
/// `job` narrates the walk with the folder currently being mirrored. It stays
/// indeterminate: nothing knows the size of the tree until the walk has ended.
fn collect_uploads(
&self,
pino: u64,
parent_uid: &NodeUid,
src: &Path,
tasks: &mut Vec<UploadTask>,
folders: &mut usize,
job: &JobGuard,
) -> CoreResult<()> {
let meta = std::fs::symlink_metadata(src)
.map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?;
let name = src
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CoreError::invalid(format!("unusable name: {}", src.display())))?
.to_string();
if meta.is_dir() {
job.detail(&name);
let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?;
*folders += 1;
let mut entries: Vec<PathBuf> = std::fs::read_dir(src)
.map_err(|e| CoreError::internal(format!("read dir {}: {e}", src.display())))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
for entry in entries {
if let Err(e) =
self.collect_uploads(child_ino, &child_uid, &entry, tasks, folders, job)
{
warn!(source = %entry.display(), error = %e, "skipping entry");
}
}
// Deeper folders have retitled the job by now; put this one back so the
// line tracks the walk's position rather than its deepest leaf.
job.detail(&name);
} else if meta.is_file() {
if name.contains('/') {
return Err(CoreError::invalid(format!("invalid file name: {name:?}")));
}
tasks.push(UploadTask {
parent_ino: pino,
parent_uid: parent_uid.clone(),
name,
path: src.to_path_buf(),
size: meta.len(),
});
}
Ok(())
}
}
+297
View File
@@ -0,0 +1,297 @@
//! The thread pool that serves the FUSE handlers which touch the network.
//!
//! fuser drives `Filesystem` from a single dispatch loop, so a cold read that
//! goes to the wire would block every cheap metadata call behind it. Those
//! handlers hand their work here instead and reply from the worker.
//!
//! The pool is split into two lanes because moving the work off the dispatch
//! loop is not by itself enough: with one shared queue, eight concurrent block
//! downloads occupy every worker, and a `lookup` that needs one network round
//! trip waits behind megabytes of transfer (audit A6). Some threads are
//! therefore reserved for metadata and never accept a transfer.
use std::collections::VecDeque;
use std::sync::Arc;
use parking_lot::{Condvar, Mutex};
use tracing::warn;
/// How many FUSE handlers may run off the dispatch loop at once.
///
/// `fuser`'s session loop is non-concurrent: it reads one request, calls the
/// handler, and only then reads the next. A handler that touches the network
/// therefore stalls every `getattr`/`lookup` on the mount behind it. The slow
/// handlers hand their `Reply` to this pool instead and answer from a worker,
/// which frees the loop immediately.
///
/// Bounded on purpose: one worker can hold a 4 MiB block in flight, and an
/// unbounded pool would let read-ahead on a big file spawn threads without
/// limit. Sized so the SDK's in-flight block semaphore, not thread count, is
/// what bounds download memory.
///
/// Counted as `META_WORKERS` reserved threads *plus* the eight that transfers
/// had before the lanes were split. Taking the reservation out of the original
/// eight instead would have cut concurrent reads to five — a throughput
/// regression smuggled in with a latency fix. Threads are the cheap resource
/// here; block buffers are the expensive one, and those are capped in the SDK.
pub(crate) const FUSE_WORKERS: usize = 11;
/// How many of [`FUSE_WORKERS`] serve metadata *only*.
///
/// This is the whole guarantee: these threads never accept a [`Lane::Transfer`]
/// job, so no number of concurrent downloads can leave a `lookup` or `readdir`
/// without a thread to run on. Three is enough because metadata jobs are short
/// — one round trip, no block fetch — so they queue behind each other briefly
/// rather than for the length of a transfer.
///
/// The remaining workers are general: they prefer transfers and fall back to
/// metadata when there is no transfer waiting. That direction is safe (a
/// general thread picking up a cheap job frees itself again quickly) while the
/// reverse — metadata threads helping with transfers — would reintroduce
/// exactly the blocking this split exists to prevent.
const META_WORKERS: usize = 3;
type Job = Box<dyn FnOnce() + Send + 'static>;
/// Which lane a job belongs in.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Lane {
/// Short, network-bound metadata work: `lookup`, `readdir`. Guaranteed a
/// thread by the reserved workers.
Meta,
/// Bulk data movement: block reads. May occupy every general worker.
Transfer,
}
#[derive(Default)]
struct Queues {
meta: VecDeque<Job>,
transfer: VecDeque<Job>,
/// Set when the pool is dropped, to wake and retire every worker.
closed: bool,
}
/// The queues and the two wait sets over them.
///
/// Two condvars, not one, because the workers are not interchangeable: a
/// meta-only thread cannot take a transfer. With a single wait set,
/// `notify_one` for a transfer could wake a meta-only thread, which would find
/// nothing it is allowed to run and go back to sleep — leaving the transfer
/// unclaimed while a general worker sat idle. Waking *every* thread instead
/// would work but costs a thundering herd per job. So each lane notifies only
/// threads that can actually serve it.
struct Pool {
queues: Mutex<Queues>,
/// Waited on by the reserved metadata workers.
meta_cv: Condvar,
/// Waited on by the general workers.
general_cv: Condvar,
}
/// Bounded thread pool behind the network-touching FUSE handlers.
///
/// Shared by every session forked off one [`Core`] (the main mount plus each
/// on-demand sync folder), so the bound is per daemon rather than per mount.
pub(crate) struct Workers {
inner: Arc<Pool>,
}
impl Workers {
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
let inner = Arc::new(Pool {
queues: Mutex::new(Queues::default()),
meta_cv: Condvar::new(),
general_cv: Condvar::new(),
});
// Never leave the pool without a general worker, however small `n` is.
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
for i in 0..n {
let inner = inner.clone();
let meta_only = i < meta_workers;
std::thread::Builder::new()
.name(format!(
"pdfs-fuse-{}{i}",
if meta_only { "meta-" } else { "" }
))
.spawn(move || {
let cv = if meta_only {
&inner.meta_cv
} else {
&inner.general_cv
};
loop {
let mut q = inner.queues.lock();
let job = loop {
// A general worker takes transfers first: metadata
// already has threads of its own, so draining the
// bulk queue is the useful thing for it to do.
let picked = if meta_only {
q.meta.pop_front()
} else {
q.transfer.pop_front().or_else(|| q.meta.pop_front())
};
if let Some(job) = picked {
break Some(job);
}
if q.closed {
break None;
}
cv.wait(&mut q);
};
drop(q);
let Some(job) = job else { break };
// A panicking handler must not cost the pool a worker
// for the rest of the run. The dropped `Reply` answers
// EIO on its own, so the caller of the failed op is
// told; the next job is unaffected.
//
// This only holds while no shared state a job touches
// sits behind a *poisoning* lock. A `std::sync::Mutex`
// held at the point of the panic comes back poisoned,
// and the worker we just rescued dies on its next
// acquisition — as does every other thread. The rescue
// would then convert one recoverable EIO into a
// permanently broken daemon: strictly worse than not
// catching at all.
//
// This crate's state is behind `parking_lot`, which
// does not poison. **The requirement crosses the crate
// boundary**, though: a job runs deep into
// `proton-sdk`, whose entity cache is a `std` mutex. It
// recovers the guard rather than unwrapping (see
// `InMemoryCacheRepository::state`); its session and
// HTTP state are `tokio::sync::Mutex`, which has no
// poisoning at all. Anything new reached from a job owes
// the same check — an SDK-side `.lock().unwrap()`
// silently invalidates this comment.
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
}
})?;
}
Ok(Self { inner })
}
/// Queue `job` in `lane`.
///
/// Neither queue is bounded, and deliberately so: the only thing that could
/// apply backpressure here is the fuser dispatch loop, and blocking *that*
/// is the stall this module exists to avoid. Queue depth is cheap anyway —
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
/// semaphore is what bounds how many of those exist at once.
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
let mut q = self.inner.queues.lock();
if q.closed {
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
// rather than a mount that answers every read with EIO.
drop(q);
warn!("fuse worker pool is gone; serving inline");
job();
return;
}
match lane {
Lane::Meta => q.meta.push_back(Box::new(job)),
Lane::Transfer => q.transfer.push_back(Box::new(job)),
}
drop(q);
match lane {
// Either class can serve metadata, and only one of them needs to:
// whichever wakes first takes it, and the other finds the queue
// empty and waits again.
Lane::Meta => {
self.inner.meta_cv.notify_one();
self.inner.general_cv.notify_one();
}
// Only general workers may take a transfer, so waking a reserved
// one would be the lost wakeup this split is careful to avoid.
Lane::Transfer => {
self.inner.general_cv.notify_one();
}
}
}
}
impl Drop for Workers {
fn drop(&mut self) {
self.inner.queues.lock().closed = true;
self.inner.meta_cv.notify_all();
self.inner.general_cv.notify_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
/// Audit A6. Transfers filling every general worker must not delay a
/// metadata job: that is what the reserved threads are for.
#[test]
fn saturated_transfers_do_not_block_metadata() {
let pool = Workers::new(FUSE_WORKERS).unwrap();
let general = FUSE_WORKERS - META_WORKERS;
// Occupy every general worker with a transfer that will not finish
// until we say so, and wait until they are all actually running.
let (release_tx, release_rx) = mpsc::channel::<()>();
let release_rx = Arc::new(Mutex::new(release_rx));
let (started_tx, started_rx) = mpsc::channel();
for _ in 0..general {
let release_rx = release_rx.clone();
let started_tx = started_tx.clone();
pool.run(Lane::Transfer, move || {
started_tx.send(()).unwrap();
let _ = release_rx.lock().recv();
});
}
for _ in 0..general {
started_rx
.recv_timeout(Duration::from_secs(5))
.expect("every general worker picks up a transfer");
}
// Queue more transfers than there are threads, so the lane is backed up
// in the way that used to starve metadata.
for _ in 0..16 {
let release_rx = release_rx.clone();
pool.run(Lane::Transfer, move || {
let _ = release_rx.lock().recv();
});
}
let (meta_tx, meta_rx) = mpsc::channel();
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
meta_rx
.recv_timeout(Duration::from_secs(5))
.expect("a metadata job runs while every transfer thread is stuck");
for _ in 0..(general + 16) {
let _ = release_tx.send(());
}
}
/// The reverse direction is allowed and matters for throughput: with no
/// transfers in flight, a general worker serves metadata rather than idling.
#[test]
fn general_workers_fall_back_to_metadata() {
// One general worker and no reserved ones, so anything that completes
// must have been served by the general worker.
let pool = Workers::new(1).unwrap();
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("a general worker takes metadata when no transfer is waiting");
}
/// A panicking job costs its caller an EIO, never the worker.
#[test]
fn a_panicking_job_does_not_retire_its_worker() {
let pool = Workers::new(1).unwrap();
pool.run(Lane::Meta, || panic!("boom"));
let (tx, rx) = mpsc::channel();
pool.run(Lane::Meta, move || tx.send(()).unwrap());
rx.recv_timeout(Duration::from_secs(5))
.expect("the pool still serves work after a panic");
}
}
+2 -1
View File
@@ -12,7 +12,7 @@ path = "src/main.rs"
[[bin]]
name = "pdfs-app"
path = "src/app.rs"
path = "src/app/main.rs"
[[bin]]
name = "pdfs-prompt"
@@ -30,3 +30,4 @@ gtk4 = { version = "0.9", features = ["v4_12"] }
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
async-channel = "2"
exif = { version = "0.6", package = "kamadak-exif" }
webkit6 = "0.4"
+28
View File
@@ -0,0 +1,28 @@
use std::env;
use std::process::Command;
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let status = Command::new("glib-compile-resources")
.args([
"--target",
&format!("{}/pdfs.gresource", out_dir),
"--sourcedir",
"resources/icons",
"resources/pdfs.gresource.xml",
])
.status()
.expect("Failed to execute glib-compile-resources");
if !status.success() {
panic!(
"glib-compile-resources failed with exit code: {:?}",
status.code()
);
}
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
println!(
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
);
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Upload-a-folder action: a folder silhouette with an up arrow knocked out of
it (one path, fill-rule evenodd, so symbolic recolouring keeps working).
Matches the Papirus folder shape (width 14, height 12) with a pixel-aligned,
proportionate up arrow cutout for size consistency. -->
<path fill="#222222" fill-rule="evenodd" d="M 1,3 V 14 C 1,14 1,15 2,15 H 14 C 14,15 15,15 15,14 V 5 C 15,4 14,4 14,4 H 9 L 7,2 H 2 C 2,2 1,2 1,3 Z M 7,12 V 8 H 5 L 8,5 L 11,8 H 9 V 12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<!-- Icons we ship ourselves because no icon theme has them. Registered as an
icon-theme resource path at startup, so they resolve by name (and recolour
with the theme) exactly like a stock symbolic icon. -->
<gresource prefix="/de/nils/protondrivelinux/icons">
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
</gresource>
</gresources>
+89
View File
@@ -0,0 +1,89 @@
//! Shared Drive-result activation policy for the GTK browser and quick prompt.
//!
//! Media should be handed to its mounted path so players can issue range reads
//! through FUSE. Other files retain the materialize-then-open behaviour because
//! many desktop applications expect a stable ordinary file.
use std::path::{Path, PathBuf};
use pdfs_core::control::PhotoKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DriveActivation {
Folder,
MountedMedia,
Materialize,
}
pub(crate) fn drive_activation(name: &str, is_dir: bool) -> DriveActivation {
if is_dir {
DriveActivation::Folder
} else if is_streamable_media(name) {
DriveActivation::MountedMedia
} else {
DriveActivation::Materialize
}
}
pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf {
mountpoint.join(relative_path)
}
fn is_streamable_media(name: &str) -> bool {
if PhotoKind::classify(Some(name), None) == PhotoKind::Video {
return true;
}
let extension = Path::new(name)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
matches!(
extension.to_ascii_lowercase().as_str(),
"mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folders_use_the_mount_regardless_of_extension() {
assert_eq!(
drive_activation("recordings.mp4", true),
DriveActivation::Folder
);
}
#[test]
fn media_extensions_use_fuse_path_case_insensitively() {
for name in ["movie.MKV", "voice.opus", "song.FLAC", "clip.m4v"] {
assert_eq!(
drive_activation(name, false),
DriveActivation::MountedMedia,
"{name}"
);
}
}
#[test]
fn ordinary_files_are_materialized() {
for name in ["report.pdf", "notes.txt", "archive.zip", "no-extension"] {
assert_eq!(
drive_activation(name, false),
DriveActivation::Materialize,
"{name}"
);
}
}
#[test]
fn mounted_paths_preserve_relative_components() {
assert_eq!(
mounted_path(Path::new("/mnt/drive"), "Videos/a movie.mkv"),
Path::new("/mnt/drive/Videos/a movie.mkv")
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+311
View File
@@ -0,0 +1,311 @@
use crate::*;
pub(crate) struct ActivityState {
// Activity page: one section, newest-first, rebuilt wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
/// page polls every couple of seconds and usually gets back exactly what it
/// is already showing; rebuilding all 200 rows for that would throw away the
/// user's scroll position several times a minute.
pub(crate) key: RefCell<Option<String>>,
}
/// Widgets the Activity page's load/repaint touch.
pub(crate) struct ActivityWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Activity page: a newest-first feed of the mutations and transfers the
/// daemon performed this session (uploads, deletes, shares, …).
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
let title = gtk4::Label::builder()
.label("Activity")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("document-open-recent-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
ActivityWidgets {
content,
status,
group,
retry,
refresh,
},
)
}
/// Install the Activity page's retry button.
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_activity(&ui_retry);
});
}
/// Show a status page in place of the Activity list.
pub(crate) fn activity_status(
ui: &Rc<Ui>,
icon: &str,
title: &str,
description: &str,
retry: bool,
) {
ui.activity.status.set_icon_name(Some(icon));
ui.activity.status.set_title(title);
ui.activity.status.set_description(Some(description));
ui.activity.retry.set_visible(retry);
ui.activity.content.set_visible_child_name("status");
// The list is no longer what is on screen, so the next repaint must not skip
// itself as a no-op and leave this status view up.
*ui.activity.key.borrow_mut() = None;
}
/// Refresh the Activity feed in place, with no status flash and no spinner.
/// Driven by the periodic tick while the page is on screen, so a running sync
/// pass fills the feed as it works rather than only once it is done. Anything
/// other than a good answer leaves the rows alone until the next tick.
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.activity.inflight.set(false);
// The page may have been navigated away from while the request was in
// flight.
if let Ok(Ok(Response::Activity { items })) = result
&& ui.stack.visible_child_name().as_deref() == Some("activity")
{
repaint_activity(&ui, &items);
}
});
}
pub(crate) fn load_activity(ui: &Rc<Ui>) {
if ui.activity.inflight.get() {
return;
}
ui.activity.inflight.set(true);
activity_status(
ui,
"document-open-recent-symbolic",
"Loading…",
"Reading recent activity.",
false,
);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::ListActivity { limit: 200 },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.activity.inflight.set(false);
match result {
Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items),
Ok(Ok(Response::Error { message, .. })) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
&message,
false,
),
Ok(Ok(_)) => activity_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read activity",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
}
});
}
/// The daemon didn't answer the Activity page.
pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
activity_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
activity_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("activity") {
load_activity(&ui);
}
});
}
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
/// The log is append-only and newest-first, so a feed with the same length and
/// the same newest entry is the same feed.
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
match items.first() {
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
None => String::new(),
}
}
/// Rebuild the Activity section from a fresh log, unless it is already showing
/// exactly this feed.
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
let key = activity_key(items);
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
return;
}
*ui.activity.key.borrow_mut() = Some(key);
for row in ui.activity.rows.borrow_mut().drain(..) {
ui.activity.group.remove(&row);
}
if items.is_empty() {
activity_status(
ui,
"document-open-recent-symbolic",
"Nothing yet",
"Uploads, moves, shares and other changes appear here as they happen.",
false,
);
return;
}
ui.activity.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for a in items {
let mut subtitle = activity_time(a.time);
if !a.detail.is_empty() {
subtitle = format!("{subtitle} · {}", a.detail);
}
let row = adw::ActionRow::builder()
.title(format!("{} {}", activity_verb(a.kind), a.target))
.subtitle(subtitle)
.build();
let icon = if a.ok {
activity_icon(a.kind)
} else {
"dialog-warning-symbolic"
};
row.add_prefix(&gtk4::Image::from_icon_name(icon));
row.set_activatable(false);
ui.activity.group.add(&row);
rows.push(row.upcast());
}
*ui.activity.rows.borrow_mut() = rows;
}
/// A human verb for an activity kind, used as the row title's lead word.
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "Uploaded",
ActivityKind::Download => "Downloaded",
ActivityKind::Sync => "Synced",
ActivityKind::Rename => "Renamed",
ActivityKind::Move => "Moved",
ActivityKind::CreateFolder => "Created folder",
ActivityKind::Trash => "Trashed",
ActivityKind::Restore => "Restored",
ActivityKind::DeleteForever => "Deleted",
ActivityKind::EmptyTrash => "Emptied trash —",
ActivityKind::Share => "Shared",
ActivityKind::PublicLink => "Linked",
ActivityKind::Unshare => "Unshared",
}
}
/// A themed icon for an activity kind.
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
match kind {
ActivityKind::Upload => "document-send-symbolic",
ActivityKind::Download => "document-save-symbolic",
ActivityKind::Sync => "emblem-synchronizing-symbolic",
ActivityKind::Rename => "document-edit-symbolic",
ActivityKind::Move => "go-jump-symbolic",
ActivityKind::CreateFolder => "folder-new-symbolic",
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
"user-trash-symbolic"
}
ActivityKind::Restore => "edit-undo-symbolic",
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
ActivityKind::Unshare => "action-unavailable-symbolic",
}
}
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
pub(crate) fn activity_time(secs: i64) -> String {
glib::DateTime::from_unix_local(secs)
.and_then(|dt| dt.format("%b %-d, %H:%M"))
.map(|s| s.to_string())
.unwrap_or_default()
}
File diff suppressed because it is too large. Load diff
+944
View File
@@ -0,0 +1,944 @@
use crate::*;
pub(crate) struct DevicesState {
// Devices page: two sections (synced folders + other devices), rebuilt
// wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) sync_group: adw::PreferencesGroup,
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
/// "Rename" action in the "This computer" header. Insensitive until the
/// device list identifies this machine's own device.
pub(crate) rename_this: gtk4::Button,
/// This machine's own device `(uid, name)`, from the last device list. The
/// current device is filtered out of the "Other computers" rows, so this is
/// where its rename target lives. `None` until identified.
pub(crate) this_device: RefCell<Option<(String, String)>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Devices page's load/repaint touch.
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// "This computer" — the local folders synced to this machine's device.
pub(crate) sync_group: adw::PreferencesGroup,
/// "Rename" in the "This computer" header — renames this machine's device.
pub(crate) rename_this: gtk4::Button,
/// "Other computers" — the account's *other* registered devices. This
/// machine's own device is deliberately not among them; see
/// [`repaint_devices`].
pub(crate) group: adw::PreferencesGroup,
pub(crate) add_folder: gtk4::Button,
/// "Restore Folders" — re-attach this device's remote folders to local
/// directories after adopting it on a new machine.
pub(crate) restore: gtk4::Button,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Computers page: a "This computer" section listing the local folders synced
/// to this machine's device (each row offering a mode toggle and Remove), plus an
/// "Other devices" section listing the account's other registered devices.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let title = gtk4::Label::builder()
.label("Computers")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let add_folder = gtk4::Button::builder()
.label("Add Folder")
.valign(gtk4::Align::Center)
.build();
add_folder.add_css_class("flat");
let restore = gtk4::Button::builder()
.label("Restore Folders")
.tooltip_text("Sync this computer's Drive folders back to local directories")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&restore);
header.append(&add_folder);
// The description spells out what the per-row On-demand switch does to the
// local copy, because the switch itself can't: turning it on *deletes* the
// files from this disk, which is not something to discover afterwards.
let sync_group = adw::PreferencesGroup::builder()
.title("This computer")
.description(
"Local folders backed up to this device. Synced folders keep a full copy on \
this disk; on-demand folders keep the files in Proton Drive only and fetch \
them as you open them.",
)
.build();
// Rename control for *this* machine's device, in the section header. The
// current device is filtered out of "Other computers", so this is the only
// place it can be renamed; insensitive until the device list identifies it.
let rename_this = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text("Rename this computer")
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
rename_this.add_css_class("flat");
sync_group.set_header_suffix(Some(&rename_this));
let group = adw::PreferencesGroup::builder()
.title("Other computers")
.description("Other devices backing up to this account.")
.build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&sync_group);
groups.append(&group);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("computer-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
DevicesWidgets {
content,
status,
sync_group,
rename_this,
group,
add_folder,
restore,
retry,
refresh,
},
)
}
/// Install the Devices page's retry button and the "Add Folder" action.
pub(crate) fn wire_devices(
ui: &Rc<Ui>,
retry: &gtk4::Button,
add_folder: &gtk4::Button,
restore: &gtk4::Button,
) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_devices(&ui_retry);
});
let ui_add = ui.clone();
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
let ui_restore = ui.clone();
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
let ui_ren = ui.clone();
ui.devices.rename_this.connect_clicked(move |_| {
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
prompt_rename_device(&ui_ren, &uid, &name);
}
});
}
/// Show a status page in place of the Devices list.
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title);
ui.devices.status.set_description(Some(description));
ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status");
}
/// Fetch this machine's synced folders and the account's other devices, then
/// repaint both sections. The two requests are chained so a single unreachable
/// daemon collapses the whole page to a status view.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
devices_status(
ui,
"computer-symbolic",
"Loading…",
"Reading your devices.",
false,
);
ui.busy_begin();
// The two lists are independent and the daemon serves requests concurrently,
// so fire both up front and collect them, rather than paying two round trips
// back to back.
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sync = rx.recv().await;
let devices_reply = rx2.recv().await;
let folders = match sync {
Ok(Ok(Response::SyncFolders { items })) => items,
// The daemon answered but not as expected — treat as empty and carry
// on to the devices list rather than failing the whole page.
Ok(Ok(_)) => Vec::new(),
Ok(Err(_)) | Err(_) => {
ui.busy_end();
ui.devices.inflight.set(false);
ui.devices.loaded_at.set(None);
devices_unreachable(&ui);
return;
}
};
// Daemon reachable: show the list and paint the synced-folders section.
ui.devices.content.set_visible_child_name("list");
repaint_sync_folders(&ui, &folders);
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
ui.busy_end();
ui.devices.inflight.set(false);
repaint_devices(&ui, &devices);
ui.devices.loaded_at.set(Some(Instant::now()));
});
}
/// Refresh just the synced-folders section, with no status flash, no spinner and
/// no devices round-trip. Driven by the periodic tick while the Devices page is
/// on screen: a folder's sync progress is only live if something re-reads it.
///
/// A hiccup leaves the page as it is — the next tick tries again, and a real
/// outage is reported by [`load_devices`] on the next visit.
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
if ui.devices.inflight.get() {
return;
}
ui.devices.inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.devices.inflight.set(false);
let Ok(Ok(Response::SyncFolders { items })) = result else {
return;
};
// The page may have been navigated away from, or collapsed to a status
// view, while the request was in flight.
if ui.stack.visible_child_name().as_deref() == Some("devices")
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
{
repaint_sync_folders(&ui, &items);
}
});
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
devices_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
devices_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal
/// state (this machine may be the only device), so it shows a placeholder row
/// rather than collapsing the page.
///
/// This machine's own device is filtered out. Deleting a device deletes its root
/// folder — every file backed up from it — and for *this* machine that would also
/// pull the ground out from under the synced folders listed directly above, so it
/// is not offered as a peer of "remove some other laptop". Removing this
/// computer's backup means removing its folders, each of which asks about its
/// cloud copy on its own terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row);
}
// Identify this machine's own device so the "This computer" header's Rename
// action has a target. It never appears in the rows below, so this is the
// only place its name/uid is captured.
match devices.iter().find(|d| d.this_device) {
Some(me) => {
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
ui.devices.rename_this.set_sensitive(true);
ui.devices
.rename_this
.set_tooltip_text(Some(&format!("Rename this computer ({})", me.name)));
}
None => {
*ui.devices.this_device.borrow_mut() = None;
ui.devices.rename_this.set_sensitive(false);
}
}
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
if others.is_empty() {
let row = adw::ActionRow::builder()
.title("No other computers")
.subtitle("Desktop apps syncing to this account appear here.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
ui.devices.group.add(&row);
*ui.devices.rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for dev in others {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove this computer and everything it backed up")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let rename = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.tooltip_text("Rename computer")
.valign(gtk4::Align::Center)
.build();
rename.add_css_class("flat");
// Adoption is how a reinstalled or renamed machine re-attaches to the
// device it used to be, instead of registering a duplicate. It only
// makes sense on *another* device's row, which is the only place this
// loop paints.
let adopt = gtk4::Button::builder()
.icon_name("insert-object-symbolic")
.tooltip_text("Use this computer's identity for this machine")
.valign(gtk4::Align::Center)
.build();
adopt.add_css_class("flat");
let ui_ad = ui.clone();
let uid_ad = dev.uid.clone();
let name_ad = dev.name.clone();
adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad));
row.add_suffix(&adopt);
let ui_ren = ui.clone();
let uid_ren = dev.uid.clone();
let name_ren = dev.name.clone();
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
let ui_rm = ui.clone();
let uid_rm = dev.uid.clone();
let name_rm = dev.name.clone();
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
row.add_suffix(&rename);
row.add_suffix(&remove);
ui.devices.group.add(&row);
rows.push(row.upcast());
}
*ui.devices.rows.borrow_mut() = rows;
}
/// A device row's subtitle: its platform, and when it last synced. A device that
/// has never synced says so — an unexplained missing date reads as a bug, and
/// "never" is the fact that tells the user this computer isn't backing anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
match dev.last_sync {
Some(secs) if secs > 0 => {
format!("{} · last synced {}", dev.device_type, activity_time(secs))
}
_ => format!("{} · never synced", dev.device_type),
}
}
/// Rebuild the "This computer" section from a fresh synced-folders listing.
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
ui.devices.sync_group.remove(&row);
}
if folders.is_empty() {
let row = adw::ActionRow::builder()
.title("No synced folders")
.subtitle("Add a folder to back it up to this device.")
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
ui.devices.sync_group.add(&row);
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
return;
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
for f in folders {
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
// otherwise fall back to the folder's resting state.
let status = match &f.progress {
Some(p) => sync_progress_label(p),
None => sync_state_label(&f.state).to_string(),
};
// A queued switch is where the folder is *going*, which is what the user
// just asked for and is waiting to see happen — so it leads the subtitle
// instead of the mode the folder is still technically in.
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
(Some(_), _) => format!("Switching to synced · {status}"),
(None, "ondemand") => format!("On-demand · {status}"),
(None, _) => format!("Synced · {status}"),
};
let row = adw::ActionRow::builder()
.title(&f.local_path)
.subtitle(&subtitle)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("folder-symbolic"));
let id = f.id;
// A pass that knows how far it has got shows it: the subtitle alone
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
// holds for scanning as much as applying — a big folder spends minutes there
// — but a folder syncing for the first time has no estimate to draw against
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
// pulse to read as motion. So it stays text-only until real counts exist.
if let Some(p) = &f.progress
&& p.total > 0
{
let bar = gtk4::ProgressBar::builder()
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
.valign(gtk4::Align::Center)
.width_request(120)
.build();
row.add_suffix(&bar);
}
// Sync now — only meaningful for mirror folders (on-demand has no local
// copy to reconcile). The engine also syncs on file changes and on a
// 120s poll; this is the "don't wait" button.
if f.mode != "ondemand" {
let sync_now = gtk4::Button::builder()
.icon_name("view-refresh-symbolic")
.tooltip_text("Sync this folder now")
.valign(gtk4::Align::Center)
.build();
sync_now.add_css_class("flat");
let ui_sync = ui.clone();
sync_now.connect_clicked(move |_| {
let rx = spawn_request(
ui_sync.dirs.control_socket(),
Request::SyncNow { id: Some(id) },
);
let ui_sync = ui_sync.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
}
_ => toast_error(
&ui_sync,
"Couldn't sync",
"The mount service didn't respond.",
),
}
});
});
row.add_suffix(&sync_now);
}
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
// frees the disk. Set the state before wiring the handler so painting the
// current mode doesn't fire a spurious request.
//
// A queued switch paints as already flipped: the daemon accepted the
// request and will act on it, so snapping the switch back to the current
// mode would read as "it didn't take" and invite the user to toggle again.
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
let ondemand = gtk4::Switch::builder()
.tooltip_text(
"On-demand: free this disk by keeping the files in Proton Drive only, \
fetching each as you open it. Turn off to download them back and keep a \
full local copy.",
)
.valign(gtk4::Align::Center)
.active(target == "ondemand")
.build();
let ui_mode = ui.clone();
ondemand.connect_state_set(move |_, on| {
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
glib::Propagation::Proceed
});
row.add_suffix(&ondemand);
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Stop syncing this folder")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let ui_rm = ui.clone();
let path = f.local_path.clone();
// The folder's *current* mode, not a queued one: a switch that hasn't
// landed yet has not moved the files anywhere.
let ondemand = f.mode == "ondemand";
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
row.add_suffix(&remove);
ui.devices.sync_group.add(&row);
rows.push(row.upcast());
}
*ui.devices.sync_rows.borrow_mut() = rows;
}
/// Human label for a synced folder's `state` column.
pub(crate) fn sync_state_label(state: &str) -> &str {
match state {
"syncing" => "syncing…",
"error" => "sync error",
"conflict" => "needs attention",
_ => "up to date",
}
}
/// Human label for a sync pass in flight: what it is doing, to which file, and
/// how far along it is. Neither phase's total is exact — the scan's is an estimate
/// from the last pass, and the applying total grows as deeper paths are classified
/// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
// Before the first pass finishes there is no estimate, so the count would
// be "checked 12 of 12" — worse than saying nothing.
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
SyncPhase::Scanning => format!(
"checking for changes — {} of {}",
p.done,
p.total.max(p.done)
),
SyncPhase::Applying => {
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
if p.current.is_empty() {
format!("syncing {count}")
} else {
format!("syncing {} — {count}", p.current)
}
}
}
}
/// Pick a local folder and hand it to the daemon to sync to this device.
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Add Folder to Sync")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
return;
};
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AddSyncFolder {
local_path: local_path.clone(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
// The daemon acks before the row exists — registering the
// device and creating the remote folder is off-socket network
// work. A fixed delay here either fires too early (empty list)
// or too late (row flashes in). Instead let the periodic
// `refresh_sync_folders` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too.
toast(&ui, "Syncing folder…");
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't add folder", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't add folder",
"The mount service didn't respond.",
),
}
});
});
}
/// Ask the daemon what this machine's device holds, then show a picker mapping
/// each remote folder onto a local directory.
///
/// The daemon's paths are proposals — from the device's `profile.json` when it
/// makes sense here, else `~/<name>` — so every one of them is editable and
/// nothing is restored without being ticked.
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't list folders to restore",
"The mount service didn't respond.",
),
}
});
}
/// The restore picker itself: one editable row per restorable folder.
fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
let win = ui_window(ui);
let candidates: Vec<RestorableFolder> =
items.into_iter().filter(|f| !f.already_synced).collect();
if candidates.is_empty() {
toast(
ui,
"Nothing to restore — this computer's folders are all synced here.",
);
return;
}
let group = adw::PreferencesGroup::builder()
.description(
"Tick the folders to sync to this machine, and adjust where each one should live.",
)
.build();
// Held so the response handler can read back what the user ticked and typed.
let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new();
for f in candidates {
let check = gtk4::CheckButton::builder()
.active(true)
.valign(gtk4::Align::Center)
.build();
let row = adw::EntryRow::builder().title(&f.name).build();
row.set_text(&f.local_path);
row.add_prefix(&check);
group.add(&row);
controls.push((f.remote_uid, f.mode, check, row));
}
let scroll = gtk4::ScrolledWindow::builder()
.propagate_natural_height(true)
.max_content_height(420)
.child(&group)
.build();
let dialog = adw::AlertDialog::builder()
.heading("Restore folders")
.body("These folders are backed up under this computer in Proton Drive.")
.extra_child(&scroll)
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("restore", "Restore");
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("restore"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp != "restore" {
return;
}
let items: Vec<RestoreItem> = controls
.iter()
.filter(|(_, _, check, _)| check.is_active())
.map(|(uid, mode, _, row)| RestoreItem {
remote_uid: uid.clone(),
local_path: row.text().to_string(),
mode: mode.clone(),
})
.collect();
if items.is_empty() {
return;
}
// The daemon acks before the downloads finish, like AddSyncFolder — the
// rows appear as the periodic refresh picks them up.
run_devices_mutation(
&ui,
Request::RestoreSyncFolders { items },
"Restoring folders…",
"Couldn't restore folders",
);
});
dialog.present(win.as_ref());
}
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
id,
mode: mode.to_string(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't change mode", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't change mode",
"The mount service didn't respond.",
),
}
if ui.stack.visible_child_name().as_deref() == Some("devices") {
load_devices(&ui);
}
});
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
// An on-demand folder's are not: the path is a mount over content that lives
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
format!(
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \
Drive, not on this disk, so the folder will be empty once it is unmounted. To \
keep a local copy, cancel, turn off On-demand, and wait for the download to \
finish before removing it."
)
} else {
format!(
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \
being synced. Choose whether to also delete the copy in Proton Drive."
)
};
let dialog = adw::AlertDialog::builder()
.heading("Stop syncing folder")
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder()
.title("Also delete from Proton Drive")
.subtitle(if ondemand {
"Deletes the only copy of these files."
} else {
"The local copy is unaffected."
})
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Stop Syncing");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
},
"Stopped syncing folder",
"Couldn't stop syncing the folder",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a new device name and rename it.
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Rename device")
.body(format!("Rename “{current}”."))
.build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
.title("New name")
.activates_default(true)
.build();
row.set_text(current);
group.add(&row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("rename", "Rename");
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "rename" {
return;
}
let name = row.text().trim().to_string();
if name.is_empty() {
toast_error(&ui, "Couldn't rename device", "A name is required.");
return;
}
run_devices_mutation(
&ui,
Request::RenameDevice {
uid: uid.clone(),
name,
},
"Device renamed",
"Couldn't rename the device",
);
});
dialog.present(win.as_ref());
}
/// Confirm, then remove (deregister) a device.
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Remove computer")
.body(format!(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
deleted along with it. The files on that computer itself are not touched — but \
this cannot be undone from here."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Remove");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::DeleteDevice { uid: uid.clone() },
"Device removed",
"Couldn't remove the device",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then adopt another device as this machine's identity.
///
/// Worth a confirmation rather than a plain click: adoption re-points this
/// machine's syncing at another computer's device folder, and the folders
/// already synced here keep pointing at the old one until they are removed. The
/// dialog says so, because the alternative is a user discovering it by watching
/// their folders diverge.
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Use this computer's identity")
.body(format!(
"Treat this machine as “{name}”?\n\nNew synced folders are created under that \
computer in Proton Drive, and this machine keeps that identity even if its hostname \
changes. Folders already synced here are not moved.\n\nUse “Restore folders” \
afterwards to bring back what “{name}” was syncing."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("adopt", "Use identity");
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "adopt" {
run_devices_mutation(
&ui,
Request::AdoptDevice {
uid: Some(uid.clone()),
},
"Identity adopted",
"Couldn't adopt that computer",
);
}
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from the Devices page and reload the page on success.
pub(crate) fn run_devices_mutation(
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
load_devices(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
+284
View File
@@ -0,0 +1,284 @@
use crate::*;
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
pub(crate) struct LoginState {
// Login page.
pub(crate) email: adw::EntryRow,
pub(crate) password: adw::PasswordEntryRow,
pub(crate) login_button: gtk4::Button,
pub(crate) login_status: gtk4::Label,
}
/// The login page: an email row, password row, a primary "Sign in" button and a
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
#[allow(clippy::type_complexity)]
pub(crate) fn build_login_page() -> (
gtk4::Widget,
(
adw::EntryRow,
adw::PasswordEntryRow,
gtk4::Button,
gtk4::Label,
),
) {
let group = adw::PreferencesGroup::builder()
.title("Sign in to Proton")
.description("Use your Proton account to connect Drive.")
.build();
let email = adw::EntryRow::builder().title("Email or username").build();
let password = adw::PasswordEntryRow::builder().title("Password").build();
group.add(&email);
group.add(&password);
let login_button = gtk4::Button::builder()
.label("Sign in")
.halign(gtk4::Align::Center)
.build();
login_button.add_css_class("suggested-action");
login_button.add_css_class("pill");
let login_status = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.build();
login_status.add_css_class("dim-label");
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
logo.set_pixel_size(64);
logo.add_css_class("brand-icon");
let title = gtk4::Label::new(Some("Proton Drive"));
title.add_css_class("brand-title");
header.append(&logo);
header.append(&title);
header.set_margin_bottom(12);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16);
inner.set_margin_top(32);
inner.set_margin_bottom(32);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&group);
inner.append(&login_button);
inner.append(&login_status);
let clamp = adw::Clamp::builder()
.maximum_size(420)
.child(&inner)
.build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
(
scroll.upcast(),
(email, password, login_button, login_status),
)
}
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
/// thread, and report the outcome back on the main loop.
pub(crate) fn wire_login(ui: &Rc<Ui>) {
// Pressing Enter in either field submits the form, so signing in never needs
// a reach for the mouse. Both fields route to the same button.
let btn = ui.login.login_button.clone();
ui.login
.email
.connect_entry_activated(move |_| btn.emit_clicked());
let btn = ui.login.login_button.clone();
ui.login
.password
.connect_entry_activated(move |_| btn.emit_clicked());
let ui = ui.clone();
let button = ui.login.login_button.clone();
button.connect_clicked(move |_| {
let username = ui.login.email.text().to_string();
let password = ui.login.password.text().to_string();
if username.is_empty() || password.is_empty() {
ui.login
.login_status
.set_text("Enter your email and password.");
return;
}
ui.login.login_button.set_sensitive(false);
ui.login.login_status.set_text("Signing in…");
let (rx, totp_req_rx, hv_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
// back a code (or is cancelled, dropping the sender).
//
// Loops rather than awaiting once: a login gated behind a CAPTCHA is
// restarted after verification, so a 2FA account is asked for a second,
// unexpired code on the retry.
let ui_2fa = ui.clone();
glib::spawn_future_local(async move {
while let Ok(code_tx) = totp_req_rx.recv().await {
prompt_2fa(&ui_2fa, code_tx);
}
});
// 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.
let ui_hv = ui.clone();
glib::spawn_future_local(async move {
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
ui_hv
.login
.login_status
.set_text("Complete the verification to continue…");
prompt_human_verification(&ui_hv, &url, token_tx);
}
});
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx
.recv()
.await
.unwrap_or_else(|_| Err("login cancelled".into()));
ui.login.login_button.set_sensitive(true);
match result {
Ok(()) => {
ui.login.login_status.set_text("");
ui.login.password.set_text("");
// Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session.
service::enable_start();
refresh(&ui);
}
Err(e) => ui
.login
.login_status
.set_text(&format!("Sign-in failed: {e}")),
}
});
});
}
/// 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>)>,
) {
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);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send_blocking(Err(e.to_string()));
return;
}
};
let result = rt.block_on(async move {
auth::login_interactive(
&username,
&password,
|| {
// 2FA required: hand a one-shot sender to the UI and block
// until the dialog supplies the code. A dropped sender
// (cancelled dialog) surfaces as a cancelled login.
let (code_tx, code_rx) = std::sync::mpsc::channel::<String>();
totp_req_tx
.send_blocking(code_tx)
.map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?;
code_rx
.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
.recv()
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
Ok(HumanVerificationCredential::captcha(token))
},
)
.await
.map_err(|e| e.to_string())
});
let _ = tx.send_blocking(result);
});
(rx, totp_req_rx, hv_req_rx)
}
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which
/// the worker reads as a cancelled login.
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
let dialog = adw::AlertDialog::builder()
.heading("Two-factor authentication")
.body("Enter the code from your authenticator app.")
.build();
let group = adw::PreferencesGroup::new();
let entry = adw::EntryRow::builder()
.title("Authentication code")
.activates_default(true)
.build();
group.add(&entry);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("confirm", "Confirm");
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("confirm"));
dialog.set_close_response("cancel");
let code_tx = RefCell::new(Some(code_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) = code_tx.borrow_mut().take()
&& resp == "confirm"
{
let _ = tx.send(entry.text().trim().to_string());
}
});
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
dialog.present(parent.as_ref());
}
/// Connect the sign-out button: 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.
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
let ui = ui.clone();
button.connect_clicked(move |_| {
service::disable_stop();
if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}");
}
*ui.session.borrow_mut() = None;
refresh(&ui);
});
}
+11
View File
@@ -0,0 +1,11 @@
pub(crate) mod activity;
pub(crate) mod browser;
pub(crate) mod devices;
pub(crate) mod login;
pub(crate) mod photo_viewer;
pub(crate) mod photos;
pub(crate) mod shared;
pub(crate) mod shared_by_me;
pub(crate) mod status;
pub(crate) mod trash;
pub(crate) mod verify;
@@ -0,0 +1,698 @@
use crate::*;
/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and
/// the button/key handlers so each one takes a single handle instead of a dozen
/// widget arguments.
pub(crate) struct Viewer {
pub(crate) picture: gtk4::Picture,
pub(crate) spinner: gtk4::Spinner,
pub(crate) status: gtk4::Label,
pub(crate) title: gtk4::Label,
pub(crate) counter: gtk4::Label,
pub(crate) prev: gtk4::Button,
pub(crate) next: gtk4::Button,
/// Details drawer: the toggle that reveals it, the rows it fills in, and the
/// "Show on map" button (hidden when the photo carries no GPS tags).
pub(crate) info_toggle: gtk4::ToggleButton,
pub(crate) info_revealer: gtk4::Revealer,
pub(crate) info_rows: gtk4::Box,
pub(crate) info_map: gtk4::Button,
/// Coordinates behind `info_map`, once a photo with GPS tags is shown.
pub(crate) coords: RefCell<Option<(f64, f64)>>,
/// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded.
pub(crate) path: RefCell<Option<String>>,
/// True while the full-size photo is still downloading and what's on screen is
/// the upscaled thumbnail — a click-outside-to-close hit test has to size the
/// image from the *thumbnail's* ratio in that window, but more importantly a
/// late reply for a photo the user has already navigated away from must not
/// overwrite the new one. Guarded by comparing against `uid`.
pub(crate) loading: Cell<bool>,
}
/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as
/// label/value pairs for the details drawer.
pub(crate) struct ExifInfo {
/// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no
/// EXIF at all, which is normal for screenshots and re-encoded images.
pub(crate) fields: Vec<(&'static str, String)>,
/// Decimal degrees, if the photo is geotagged.
pub(crate) coords: Option<(f64, f64)>,
}
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands.
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true);
viewer.spinner.start();
viewer.status.set_visible(false);
viewer.loading.set(true);
*viewer.path.borrow_mut() = None;
clear_info(viewer);
// The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second.
match ui.gallery.photo_tex.borrow().get(&uid) {
Some(texture) => viewer.picture.set_paintable(Some(texture)),
None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE),
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: uid.clone() },
);
let viewer = viewer.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
// The user may have moved on while this was in flight; that photo's own
// request owns the viewer now.
if *viewer.uid.borrow() != uid {
return;
}
viewer.spinner.stop();
viewer.spinner.set_visible(false);
viewer.loading.set(false);
let fail = |message: &str| {
viewer.status.set_label(message);
viewer.status.set_visible(true);
};
match result {
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
Ok(texture) => {
viewer.picture.set_paintable(Some(&texture));
*viewer.path.borrow_mut() = Some(path.clone());
show_info(&viewer, &path, read_exif(&path));
}
Err(e) => {
tracing::error!("Failed to load texture for {path}: {e}");
fail("Couldn't render this photo.");
}
},
Ok(Ok(Response::Error { message, .. })) => fail(&message),
Ok(Ok(_)) => fail("Unexpected reply from the mount service."),
Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."),
}
});
}
/// Warm the cache with the photo `delta` steps away, so stepping there is
/// instant. Fire-and-forget: the daemon serves control connections concurrently,
/// so this rides alongside whatever the user does next, and a failure here is
/// simply a photo that downloads on arrival like it used to.
pub(crate) fn prefetch_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
let model = &ui.gallery.model;
let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32;
let index = current + delta;
if index < 0 || index >= model.n_items() as i32 {
return;
}
let Some(photo) = model
.item(index as u32)
.and_downcast::<BoxedAnyObject>()
.map(|boxed| boxed.borrow::<PhotoItem>().clone())
else {
return;
};
// Never prefetch a video: the lightbox skips over it, and warming it would
// mean downloading a whole clip the user won't watch from here.
if photo.kind == PhotoKind::Video {
return;
}
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenPhoto { uid: photo.uid },
);
glib::spawn_future_local(async move {
let _ = rx.recv().await;
});
}
/// Reset the details drawer while the next photo is in flight, so it never shows
/// the previous photo's camera against the new image.
pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
*viewer.coords.borrow_mut() = None;
viewer.info_map.set_visible(false);
while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row);
}
let label = gtk4::Label::builder()
.label("Reading photo details…")
.halign(gtk4::Align::Start)
.build();
label.add_css_class("dim-label");
viewer.info_rows.append(&label);
}
/// Fill the details drawer for the photo now on screen: its own file facts
/// (size, dimensions) plus whatever EXIF it carries.
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
while let Some(row) = viewer.info_rows.first_child() {
viewer.info_rows.remove(&row);
}
let mut fields: Vec<(&str, String)> = Vec::new();
if let Ok(meta) = std::fs::metadata(path) {
fields.push(("Size", human_bytes(meta.len())));
}
fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone())));
if fields.is_empty() {
let label = gtk4::Label::builder()
.label("This photo carries no metadata.")
.halign(gtk4::Align::Start)
.wrap(true)
.build();
label.add_css_class("dim-label");
viewer.info_rows.append(&label);
} else {
let group = gtk4::ListBox::new();
group.set_selection_mode(gtk4::SelectionMode::None);
group.add_css_class("boxed-list");
for (label, value) in fields {
let row = adw::ActionRow::builder()
.title(label)
.subtitle(value)
.subtitle_selectable(true)
.build();
row.add_css_class("property");
group.append(&row);
}
viewer.info_rows.append(&group);
}
viewer.info_map.set_visible(info.coords.is_some());
*viewer.coords.borrow_mut() = info.coords;
}
/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on
/// disk. Anything missing is simply left out — phone screenshots and re-encoded
/// images legitimately carry no EXIF at all.
pub(crate) fn read_exif(path: &str) -> ExifInfo {
let mut fields: Vec<(&'static str, String)> = Vec::new();
let mut coords = None;
let reader = match std::fs::File::open(path) {
Ok(file) => {
match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) {
Ok(reader) => reader,
Err(e) => {
tracing::debug!("no exif in {path}: {e}");
return ExifInfo { fields, coords };
}
}
}
Err(e) => {
tracing::warn!("cannot open {path} for exif: {e}");
return ExifInfo { fields, coords };
}
};
let field = |tag: exif::Tag| {
reader
.get_field(tag, exif::In::PRIMARY)
.map(|f| f.display_value().with_unit(&reader).to_string())
};
if let Some(size) = field(exif::Tag::PixelXDimension)
.zip(field(exif::Tag::PixelYDimension))
.map(|(w, h)| format!("{w} × {h}"))
{
fields.push(("Dimensions", size));
}
if let Some(taken) = field(exif::Tag::DateTimeOriginal) {
fields.push(("Taken", taken));
}
let camera = [exif::Tag::Make, exif::Tag::Model]
.iter()
.filter_map(|tag| field(*tag))
.collect::<Vec<_>>()
.join(" ");
if !camera.is_empty() {
fields.push(("Camera", camera));
}
if let Some(lens) = field(exif::Tag::LensModel) {
fields.push(("Lens", lens));
}
let exposure: Vec<String> = [
exif::Tag::FNumber,
exif::Tag::ExposureTime,
exif::Tag::PhotographicSensitivity,
exif::Tag::FocalLength,
]
.iter()
.filter_map(|tag| field(*tag))
.collect();
if !exposure.is_empty() {
fields.push(("Exposure", exposure.join(" · ")));
}
if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef)
&& let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef)
{
fields.push(("Location", format!("{lat:.5}, {lon:.5}")));
coords = Some((lat, lon));
}
ExifInfo { fields, coords }
}
/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to
/// decimal degrees, negating for the S/W hemispheres.
pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option<f64> {
let field = reader.get_field(tag, exif::In::PRIMARY)?;
let exif::Value::Rational(dms) = &field.value else {
return None;
};
let [deg, min, sec] = dms.get(..3)? else {
return None;
};
let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0;
let hemisphere = reader
.get_field(ref_tag, exif::In::PRIMARY)
.map(|f| f.display_value().to_string())
.unwrap_or_default();
let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W');
Some(if negative { -degrees } else { degrees })
}
pub(crate) fn save_photo_to_disk(window: &gtk4::Window, source_path: &str, original_name: &str) {
let dialog = gtk4::FileDialog::builder()
.title("Save Photo")
.initial_name(original_name)
.build();
let source_path_str = source_path.to_string();
dialog.save(Some(window), gio::Cancellable::NONE, move |res| {
if let Ok(file) = res
&& let Some(dest_path) = file.path()
{
if let Err(e) = std::fs::copy(&source_path_str, &dest_path) {
tracing::error!("Failed to copy file to {:?}: {}", dest_path, e);
} else {
tracing::info!("Saved photo to {:?}", dest_path);
}
}
});
}
/// Step `delta` photos through the flat timeline model and load what lands there.
pub(crate) fn navigate_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, delta: i32) {
let model = &ui.gallery.model;
let n = model.n_items();
if n == 0 {
return;
}
let uid_val = viewer.uid.borrow().clone();
let current_idx = find_photo_index(model, &uid_val).unwrap_or(0);
// Where the raw step lands (prev/next are ±1; Home/End are huge deltas that
// clamp to the ends), and which way it was travelling.
let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1);
let dir = if delta < 0 { -1 } else { 1 };
// The lightbox renders stills only — a video plays in an external player, so
// it has no frame here. Land on the nearest renderable item, preferring the
// travel direction and falling back to scanning inward from a boundary (so
// Home/End still reach the first/last still even when videos sit at the end).
let renderable = |idx: u32| -> Option<PhotoItem> {
model
.item(idx)
.and_then(|obj| {
obj.downcast_ref::<BoxedAnyObject>()
.map(|b| b.borrow::<PhotoItem>().clone())
})
.filter(|photo| photo.kind != PhotoKind::Video)
};
let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> {
let mut probe = from;
while (0..n as i32).contains(&probe) {
if let Some(photo) = renderable(probe as u32) {
return Some((probe as u32, photo));
}
probe += step;
}
None
};
// Scan the travel direction first. Only fall back to scanning inward when the
// step landed on a boundary — that is the Home/End case (jump to first/last,
// then step inward past any videos there); a mid-list prev/next with only
// videos ahead should simply stay put rather than reverse.
let at_boundary = start == 0 || start == n as i32 - 1;
let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten());
let Some((next_idx, photo)) = landing else {
return;
};
*viewer.uid.borrow_mut() = photo.uid.clone();
show_photo_position(viewer, next_idx, n, photo.capture_time);
load_photo(ui, viewer, photo.uid.clone());
// Keep walking in the same direction: the next one is likely where they're
// headed, so have it in the cache before they ask.
prefetch_photo(ui, viewer, delta.signum());
}
/// Set the lightbox's title (the photo's date) and its "12 of 340" counter.
pub(crate) fn show_photo_position(viewer: &Rc<Viewer>, index: u32, total: u32, capture_time: i64) {
viewer.prev.set_sensitive(index > 0);
viewer.next.set_sensitive(index + 1 < total);
viewer.title.set_label(&format_capture_time(capture_time));
viewer
.counter
.set_label(&format!("{} of {total}", index + 1));
}
/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a
/// floating top bar, prev/next affordances and a details drawer.
///
/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close
/// button, or a click on the backdrop beside the photo all dismiss it.
pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
let parent = ui.stack.root().and_downcast::<gtk4::Window>().unwrap();
let window = gtk4::Window::builder()
.title("Photo")
.modal(true)
.transient_for(&parent)
.default_width(1100)
.default_height(760)
.build();
window.add_css_class("photo-viewer-window");
let overlay = gtk4::Overlay::new();
let picture = gtk4::Picture::builder()
.content_fit(gtk4::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.build();
overlay.set_child(Some(&picture));
let prev_btn = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text("Previous (←)")
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Center)
.build();
prev_btn.add_css_class("circular");
prev_btn.add_css_class("flat");
prev_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&prev_btn);
let next_btn = gtk4::Button::builder()
.icon_name("go-next-symbolic")
.tooltip_text("Next (→)")
.halign(gtk4::Align::End)
.valign(gtk4::Align::Center)
.build();
next_btn.add_css_class("circular");
next_btn.add_css_class("flat");
next_btn.add_css_class("viewer-nav-btn");
overlay.add_overlay(&next_btn);
// Top bar: the photo's date and position on the left, actions on the right,
// over a gradient so white controls stay legible on a bright photo.
let title_label = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
title_label.add_css_class("viewer-title");
let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
counter_label.add_css_class("viewer-counter");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
titles.set_hexpand(true);
titles.set_valign(gtk4::Align::Center);
titles.append(&title_label);
titles.append(&counter_label);
let action = |icon: &str, tooltip: &str| {
let button = gtk4::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.valign(gtk4::Align::Center)
.build();
button.add_css_class("flat");
button.add_css_class("viewer-action-btn");
button
};
let info_toggle = gtk4::ToggleButton::builder()
.icon_name("info-outline-symbolic")
.tooltip_text("Details (i)")
.valign(gtk4::Align::Center)
.build();
info_toggle.add_css_class("flat");
info_toggle.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
close_btn.add_css_class("viewer-close-btn");
let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
top_bar.add_css_class("viewer-top-bar");
top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true);
top_bar.append(&titles);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
// Details drawer: slides in from the right as a real surface (not a wash of
// black over the photo), with its own header and its own way out.
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
let info_map = gtk4::Button::builder()
.label("Show on map")
.icon_name("map-symbolic")
.halign(gtk4::Align::Start)
.build();
info_map.add_css_class("pill");
info_map.set_visible(false);
let info_title = gtk4::Label::builder()
.label("Details")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
info_title.add_css_class("heading");
let info_close = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Hide details")
.build();
info_close.add_css_class("flat");
info_close.add_css_class("circular");
let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
info_header.append(&info_title);
info_header.append(&info_close);
let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
info_body.set_margin_top(16);
info_body.set_margin_bottom(16);
info_body.set_margin_start(16);
info_body.set_margin_end(16);
info_body.append(&info_header);
info_body.append(&info_rows);
info_body.append(&info_map);
// Scrolled, because a photo with a full EXIF block plus a location can
// outgrow a short window.
let info_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.propagate_natural_height(true)
.child(&info_body)
.build();
let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
info_panel.add_css_class("viewer-info-panel");
info_panel.set_width_request(320);
info_panel.append(&info_scroll);
let info_revealer = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideLeft)
.halign(gtk4::Align::End)
.valign(gtk4::Align::Fill)
.child(&info_panel)
.build();
overlay.add_overlay(&info_revealer);
let spinner = gtk4::Spinner::builder()
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.width_request(48)
.height_request(48)
.build();
spinner.add_css_class("viewer-spinner");
overlay.add_overlay(&spinner);
let status_label = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.halign(gtk4::Align::Center)
.valign(gtk4::Align::Center)
.build();
status_label.add_css_class("viewer-status");
overlay.add_overlay(&status_label);
window.set_child(Some(&overlay));
let viewer = Rc::new(Viewer {
picture: picture.clone(),
spinner,
status: status_label,
title: title_label,
counter: counter_label,
prev: prev_btn.clone(),
next: next_btn.clone(),
info_toggle: info_toggle.clone(),
info_revealer: info_revealer.clone(),
info_rows,
info_map: info_map.clone(),
coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None),
loading: Cell::new(false),
});
let n = ui.gallery.model.n_items();
let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0);
let capture_time = ui
.gallery
.model
.item(initial_idx)
.and_downcast::<BoxedAnyObject>()
.map_or(0, |boxed| boxed.borrow::<PhotoItem>().capture_time);
show_photo_position(&viewer, initial_idx, n, capture_time);
load_photo(ui, &viewer, initial_uid);
prefetch_photo(ui, &viewer, 1);
let w_close = window.clone();
close_btn.connect_clicked(move |_| {
w_close.close();
});
let viewer_info = viewer.clone();
info_toggle.connect_toggled(move |toggle| {
viewer_info
.info_revealer
.set_reveal_child(toggle.is_active());
});
let toggle_off = info_toggle.clone();
info_close.connect_clicked(move |_| toggle_off.set_active(false));
let viewer_map = viewer.clone();
info_map.connect_clicked(move |_| {
if let Some((lat, lon)) = *viewer_map.coords.borrow() {
open_path(&format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}"
));
}
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
if let Some(path) = viewer_download.path.borrow().as_deref() {
let name = format!("{}.jpg", viewer_download.uid.borrow());
save_photo_to_disk(&w_download, path, &name);
}
});
let viewer_ext = viewer.clone();
open_ext_btn.connect_clicked(move |_| {
if let Some(path) = viewer_ext.path.borrow().as_deref() {
open_path(path);
}
});
let ui_prev = ui.clone();
let viewer_prev = viewer.clone();
prev_btn.connect_clicked(move |_| {
navigate_photo(&ui_prev, &viewer_prev, -1);
});
let ui_next = ui.clone();
let viewer_next = viewer.clone();
next_btn.connect_clicked(move |_| {
navigate_photo(&ui_next, &viewer_next, 1);
});
// Click the backdrop — the dark area beside the photo — to dismiss, the way
// every other lightbox behaves. Clicks on the photo itself are left alone, so
// reaching for the image doesn't fling the window shut.
let backdrop = gtk4::GestureClick::new();
let viewer_click = viewer.clone();
let w_click = window.clone();
backdrop.connect_released(move |_, _, x, y| {
if !over_photo(&viewer_click.picture, x, y) {
w_click.close();
}
});
picture.add_controller(backdrop);
let key_controller = gtk4::EventControllerKey::new();
let ui_key = ui.clone();
let viewer_key = viewer.clone();
let w_key = window.clone();
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
match key.name().as_deref() {
Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1),
Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1),
Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2),
Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2),
Some("i") => viewer_key
.info_toggle
.set_active(!viewer_key.info_toggle.is_active()),
Some("f" | "F11") => {
if w_key.is_fullscreen() {
w_key.unfullscreen();
} else {
w_key.fullscreen();
}
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(key_controller);
window.present();
}
/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself
/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the
/// image and letterboxes the rest, so the drawn rectangle is the widget scaled
/// down by whichever axis binds.
pub(crate) fn over_photo(picture: &gtk4::Picture, x: f64, y: f64) -> bool {
let (width, height) = (f64::from(picture.width()), f64::from(picture.height()));
let Some(paintable) = picture.paintable() else {
return false;
};
let (iw, ih) = (
f64::from(paintable.intrinsic_width()),
f64::from(paintable.intrinsic_height()),
);
if iw <= 0.0 || ih <= 0.0 {
return false;
}
let scale = (width / iw).min(height / ih);
let (drawn_w, drawn_h) = (iw * scale, ih * scale);
let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0);
x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h
}
File diff suppressed because it is too large. Load diff
+665
View File
@@ -0,0 +1,665 @@
use crate::*;
pub(crate) struct SharedState {
// Shared page. Three sections rebuilt wholesale on each load: shared-with-me,
// incoming invitations, and bookmarks. Rows are tracked so a reload can remove
// the old ones before appending the new (adw groups have no clear-all).
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) with_me_group: adw::PreferencesGroup,
pub(crate) invitations_group: adw::PreferencesGroup,
pub(crate) bookmarks_group: adw::PreferencesGroup,
/// Where in a shared folder the page currently is, as `(uid, name)` from the
/// top level down. Empty = the top level. Shared subtrees have no path in the
/// mount, so descending is uid-addressed and the stack *is* the breadcrumb.
pub(crate) nav: RefCell<Vec<(String, String)>>,
pub(crate) rows: RefCell<Vec<(adw::PreferencesGroup, gtk4::Widget)>>,
/// Guards the Shared page's load so overlapping navigations don't stack.
pub(crate) inflight: Cell<bool>,
/// When the Shared page last painted good data. `None` = never / invalidated,
/// forcing a fetch on next visit. See [`PAGE_TTL`].
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Shared page's load/repaint touch.
pub(crate) struct SharedWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) shared_with_me: adw::PreferencesGroup,
pub(crate) invitations: adw::PreferencesGroup,
pub(crate) bookmarks: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) add_bookmark: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Shared page: three stacked sections — items shared *with* me (each with a
/// Leave action), invitations addressed to me pending accept/reject, and public
/// links I've bookmarked (open / remove). All three live outside the mount tree,
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
let title = gtk4::Label::builder()
.label("Shared with me")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
let add_bookmark = gtk4::Button::builder()
.label("Add Bookmark")
.valign(gtk4::Align::Center)
.build();
add_bookmark.add_css_class("flat");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&add_bookmark);
let shared_with_me = adw::PreferencesGroup::builder()
.title("Shared with me")
.build();
let invitations = adw::PreferencesGroup::builder()
.title("Invitations")
.build();
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
groups.append(&shared_with_me);
groups.append(&invitations);
groups.append(&bookmarks);
let clamp = adw::Clamp::builder().child(&groups).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
SharedWidgets {
content,
status,
shared_with_me,
invitations,
bookmarks,
retry,
add_bookmark,
refresh,
},
)
}
/// Install the Shared page's retry and Add-Bookmark buttons.
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: &gtk4::Button, add_bookmark: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared(&ui_retry);
});
let ui_add = ui.clone();
add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add));
}
/// Show a status page in place of the Shared sections.
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.shared.status.set_icon_name(Some(icon));
ui.shared.status.set_title(title);
ui.shared.status.set_description(Some(description));
ui.shared.retry.set_visible(retry);
ui.shared.content.set_visible_child_name("status");
}
/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in
/// parallel and repaint the page once all three land.
///
/// Inside a shared folder ([`SharedState::nav`] non-empty) only that folder's
/// children are fetched: invitations and bookmarks belong to the top level, and
/// carrying them down a subtree would read as if they lived there.
pub(crate) fn load_shared(ui: &Rc<Ui>) {
if ui.shared.inflight.get() {
return;
}
let current = ui.shared.nav.borrow().last().cloned();
if let Some((uid, _)) = current {
load_shared_folder(ui, uid);
return;
}
ui.shared.inflight.set(true);
shared_status(
ui,
"emblem-shared-symbolic",
"Loading…",
"Reading your shared items.",
false,
);
ui.busy_begin();
let socket = ui.dirs.control_socket();
let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe);
let invites_rx = spawn_request(socket.clone(), Request::ListInvitations);
let bookmarks_rx = spawn_request(socket, Request::ListBookmarks);
let ui = ui.clone();
glib::spawn_future_local(async move {
let shared = shared_rx.recv().await;
let invites = invites_rx.recv().await;
let bookmarks = bookmarks_rx.recv().await;
ui.busy_end();
ui.shared.inflight.set(false);
// A transport failure on any of the three means the daemon isn't up.
if matches!(shared, Ok(Err(_)) | Err(_))
|| matches!(invites, Ok(Err(_)) | Err(_))
|| matches!(bookmarks, Ok(Err(_)) | Err(_))
{
ui.shared.loaded_at.set(None);
shared_unreachable(&ui);
return;
}
let shared_items = match shared {
Ok(Ok(Response::Entries { entries })) => entries,
_ => Vec::new(),
};
let invitations = match invites {
Ok(Ok(Response::Invitations { items })) => items,
_ => Vec::new(),
};
let bookmark_items = match bookmarks {
Ok(Ok(Response::Bookmarks { items })) => items,
_ => Vec::new(),
};
repaint_shared(&ui, &shared_items, &invitations, &bookmark_items);
ui.shared.loaded_at.set(Some(Instant::now()));
});
}
/// List one shared folder's children and repaint the page as that folder's view.
/// The uid comes from the row that was activated (or from the nav stack on a
/// reload) — a shared subtree is reachable no other way.
fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
ui.shared.inflight.set(true);
shared_status(
ui,
"folder-symbolic",
"Loading…",
"Reading this shared folder.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedFolder { uid });
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.shared.inflight.set(false);
match result {
Ok(Ok(Response::Entries { entries })) => {
repaint_shared_folder(&ui, &entries);
ui.shared.loaded_at.set(Some(Instant::now()));
}
Ok(Ok(Response::Error { message, kind })) => {
// The folder is gone or access was revoked: fall back to the top
// level rather than stranding the page on a dead uid.
ui.shared.nav.borrow_mut().pop();
toast_failure(&ui, "Couldn't open shared folder", &message, kind);
load_shared(&ui);
}
_ => {
ui.shared.loaded_at.set(None);
shared_unreachable(&ui);
}
}
});
}
/// Paint the contents of the shared folder at the top of the nav stack: a row to
/// go back up, then the children. Invitations and bookmarks are hidden here —
/// they are top-level things, not contents of this folder.
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
group.remove(&row);
}
ui.shared.content.set_visible_child_name("list");
ui.shared.invitations_group.set_visible(false);
ui.shared.bookmarks_group.set_visible(false);
let nav = ui.shared.nav.borrow().clone();
let title = nav
.iter()
.map(|(_, name)| name.as_str())
.collect::<Vec<_>>()
.join(" / ");
ui.shared.with_me_group.set_title(&title);
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
let up = adw::ActionRow::builder()
.title("Back")
.activatable(true)
.build();
up.add_prefix(&gtk4::Image::from_icon_name("go-up-symbolic"));
let ui_up = ui.clone();
up.connect_activated(move |_| {
ui_up.shared.nav.borrow_mut().pop();
load_shared(&ui_up);
});
ui.shared.with_me_group.add(&up);
rows.push((ui.shared.with_me_group.clone(), up.upcast()));
if entries.is_empty() {
let row = dim_row("This folder is empty.");
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else {
for entry in entries {
let row = shared_entry_row(ui, entry);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
}
*ui.shared.rows.borrow_mut() = rows;
}
/// A row for one node shared with me: folders descend into, files download and
/// open with the user's default application.
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(&entry.name)
.activatable(true)
.build();
if !entry.is_dir {
row.set_subtitle(&human_bytes(entry.size));
}
row.add_prefix(&gtk4::Image::from_icon_name(if entry.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
let ui_act = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
let is_dir = entry.is_dir;
row.connect_activated(move |_| {
if is_dir {
ui_act
.shared
.nav
.borrow_mut()
.push((uid.clone(), name.clone()));
load_shared(&ui_act);
} else {
open_shared_file(&ui_act, &uid, &name);
}
});
row
}
/// Download a file shared with me into the daemon's cache and hand it to the
/// user's default application — the shared-item twin of the browser's
/// download-and-open, addressed by uid because the file lives outside the mount.
fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
// Ignore a repeat activation of a file already downloading, so an impatient
// double-click doesn't kick off a second round-trip.
if !ui.opening.borrow_mut().insert(uid.to_string()) {
return;
}
ui.busy_begin();
toast(ui, &format!("Downloading “{name}”…"));
let rx = spawn_request(
ui.dirs.control_socket(),
Request::OpenSharedFile {
uid: uid.to_string(),
},
);
let ui = ui.clone();
let uid = uid.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.opening.borrow_mut().remove(&uid);
match result {
Ok(Ok(Response::FilePath { path })) => open_path(&path),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, "Couldn't open file", &message, kind)
}
_ => toast_error(
&ui,
"Couldn't open file",
"The mount service didn't respond.",
),
}
});
}
/// The daemon didn't answer the Shared page. Same still-starting vs. down split
/// as the other pages.
pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
shared_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
shared_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("shared") {
load_shared(&ui);
}
});
}
/// Rebuild the three Shared sections. Rows added last time are removed first —
/// adw groups have no clear-all — then each section is repopulated, showing a
/// dim placeholder row when empty.
pub(crate) fn repaint_shared(
ui: &Rc<Ui>,
shared: &[DirEntry],
invitations: &[InvitationInfo],
bookmarks: &[BookmarkInfo],
) {
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
group.remove(&row);
}
ui.shared.content.set_visible_child_name("list");
ui.shared.invitations_group.set_visible(true);
ui.shared.bookmarks_group.set_visible(true);
ui.shared.with_me_group.set_title("Shared with me");
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
// Shared with me: name + Leave.
if shared.is_empty() {
let row = dim_row("Nothing is shared with you.");
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
} else {
for entry in shared {
let row = shared_entry_row(ui, entry);
let leave = gtk4::Button::builder()
.label("Leave")
.valign(gtk4::Align::Center)
.build();
leave.add_css_class("flat");
let ui_leave = ui.clone();
let uid = entry.uid.clone();
let name = entry.name.clone();
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
row.add_suffix(&leave);
ui.shared.with_me_group.add(&row);
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
}
}
// Invitations: inviter + item, Accept / Reject.
if invitations.is_empty() {
let row = dim_row("No pending invitations.");
ui.shared.invitations_group.add(&row);
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
} else {
for inv in invitations {
let item = inv
.name
.clone()
.unwrap_or_else(|| "a shared item".to_string());
let row = adw::ActionRow::builder()
.title(&item)
.subtitle(format!("from {}", inv.inviter_email))
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if inv.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
let reject = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Reject")
.valign(gtk4::Align::Center)
.build();
reject.add_css_class("flat");
let accept = gtk4::Button::builder()
.label("Accept")
.valign(gtk4::Align::Center)
.build();
accept.add_css_class("suggested-action");
let ui_acc = ui.clone();
let id_acc = inv.id.clone();
accept.connect_clicked(move |_| {
respond_invitation(&ui_acc, &id_acc, true);
});
let ui_rej = ui.clone();
let id_rej = inv.id.clone();
reject.connect_clicked(move |_| {
respond_invitation(&ui_rej, &id_rej, false);
});
row.add_suffix(&accept);
row.add_suffix(&reject);
ui.shared.invitations_group.add(&row);
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
}
}
// Bookmarks: name/URL, Open / Remove.
if bookmarks.is_empty() {
let row = dim_row("No saved bookmarks.");
ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
} else {
for bm in bookmarks {
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
let row = adw::ActionRow::builder()
.title(&title)
.subtitle(&bm.url)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("emblem-symbolic-link"));
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove bookmark")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open in browser")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = bm.url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
let ui_rm = ui.clone();
let token = bm.token.clone();
let name_rm = title.clone();
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
row.add_suffix(&open);
row.add_suffix(&remove);
ui.shared.bookmarks_group.add(&row);
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
}
}
*ui.shared.rows.borrow_mut() = rows;
}
/// Accept or reject an invitation, then reload the Shared page.
pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
let req = if accept {
Request::AcceptInvitation { id: id.to_string() }
} else {
Request::RejectInvitation { id: id.to_string() }
};
let (done, failed) = if accept {
("Invitation accepted", "Couldn't accept the invitation")
} else {
("Invitation rejected", "Couldn't reject the invitation")
};
run_shared_mutation(ui, req, done, failed);
}
/// Confirm, then leave a node shared with me.
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Leave shared item")
.body(format!(
"Leave “{name}”? You'll lose access until you're invited again."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("leave", "Leave");
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "leave" {
run_shared_mutation(
&ui,
Request::LeaveShared { uid: uid.clone() },
"Left shared item",
"Couldn't leave the shared item",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then remove a saved bookmark.
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Remove bookmark")
.body(format!("Remove the bookmark for “{name}”?"))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("remove", "Remove");
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let token = token.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_shared_mutation(
&ui,
Request::DeleteBookmark {
token: token.clone(),
},
"Bookmark removed",
"Couldn't remove the bookmark",
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a public-link URL (and optional password) and save it as a bookmark.
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading("Add bookmark")
.body("Paste a Proton Drive public link to save it here.")
.build();
let group = adw::PreferencesGroup::new();
let url_row = adw::EntryRow::builder()
.title("Public link URL")
.activates_default(true)
.build();
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (if the link has one)")
.activates_default(true)
.build();
group.add(&url_row);
group.add(&pw_row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", "Cancel");
dialog.add_response("add", "Add");
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("add"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp != "add" {
return;
}
let url = url_row.text().trim().to_string();
if url.is_empty() {
toast_error(&ui, "Couldn't add bookmark", "A URL is required.");
return;
}
let pw = pw_row.text().to_string();
let password = if pw.is_empty() { None } else { Some(pw) };
run_shared_mutation(
&ui,
Request::CreateBookmark { url, password },
"Bookmark saved",
"Couldn't add the bookmark",
);
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from the Shared page and reload the page on success.
pub(crate) fn run_shared_mutation(
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
load_shared(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
@@ -0,0 +1,299 @@
use crate::*;
pub(crate) struct SharedByMeState {
// Shared (by me) page: one section listing the items I've shared, each with a
// copy-link / manage affordance. Rebuilt wholesale on each load.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) inflight: Cell<bool>,
pub(crate) loaded_at: Cell<Option<Instant>>,
}
/// Widgets the Shared (by-me) page's load/repaint touch.
pub(crate) struct SharedByMeWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) group: adw::PreferencesGroup,
pub(crate) retry: gtk4::Button,
pub(crate) refresh: gtk4::Button,
}
/// The Shared page: one section listing the items I have shared with others —
/// each row summarizing who has access and carrying its public link (copy/open)
/// and a Manage shortcut into the per-node Share dialog.
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
let title = gtk4::Label::builder()
.label("Shared")
.halign(gtk4::Align::Start)
.hexpand(true)
.build();
title.add_css_class("title-2");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.set_hexpand(true);
header.append(&title);
header.append(&refresh);
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("emblem-shared-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
SharedByMeWidgets {
content,
status,
group,
retry,
refresh,
},
)
}
/// Install the Shared (by-me) page's retry button.
pub(crate) fn wire_shared_by_me(ui: &Rc<Ui>, retry: &gtk4::Button) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| {
service::restart();
load_shared_by_me(&ui_retry);
});
}
/// Show a status page in place of the Shared (by-me) list.
pub(crate) fn shared_by_me_status(
ui: &Rc<Ui>,
icon: &str,
title: &str,
description: &str,
retry: bool,
) {
ui.shared_by_me.status.set_icon_name(Some(icon));
ui.shared_by_me.status.set_title(title);
ui.shared_by_me.status.set_description(Some(description));
ui.shared_by_me.retry.set_visible(retry);
ui.shared_by_me.content.set_visible_child_name("status");
}
/// Fetch the shared-by-me listing and repaint the page.
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
if ui.shared_by_me.inflight.get() {
return;
}
ui.shared_by_me.inflight.set(true);
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Loading…",
"Reading what you've shared.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
ui.shared_by_me.inflight.set(false);
match result {
Ok(Ok(Response::SharedByMe { items })) => {
repaint_shared_by_me(&ui, &items);
ui.shared_by_me.loaded_at.set(Some(Instant::now()));
}
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
&message,
false,
),
Ok(Ok(_)) => shared_by_me_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read your shares",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => {
ui.shared_by_me.loaded_at.set(None);
shared_by_me_unreachable(&ui);
}
}
});
}
/// The daemon didn't answer the Shared (by-me) page.
pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
shared_by_me_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
shared_by_me_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") {
load_shared_by_me(&ui);
}
});
}
/// Rebuild the Shared (by-me) section from a fresh listing.
pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
for row in ui.shared_by_me.rows.borrow_mut().drain(..) {
ui.shared_by_me.group.remove(&row);
}
if items.is_empty() {
shared_by_me_status(
ui,
"emblem-shared-symbolic",
"Nothing shared yet",
"Items you share with people or by link show up here.",
false,
);
return;
}
ui.shared_by_me.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = Vec::new();
for item in items {
let row = adw::ActionRow::builder()
.title(&item.name)
.subtitle(shared_item_summary(item))
.build();
row.add_prefix(&gtk4::Image::from_icon_name(if item.is_dir {
"folder-symbolic"
} else {
"text-x-generic-symbolic"
}));
// Copy / open the public link, when there is one with a recovered URL.
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
let ui_copy = ui.clone();
let url_copy = url.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
toast(&ui_copy, "Link copied");
});
let open = gtk4::Button::builder()
.icon_name("external-link-symbolic")
.tooltip_text("Open link")
.valign(gtk4::Align::Center)
.build();
open.add_css_class("flat");
let url_open = url.clone();
open.connect_clicked(move |_| open_uri(&url_open));
row.add_suffix(&copy);
row.add_suffix(&open);
}
// Manage opens the per-node Share dialog — only when the node's path is
// known (it has been browsed to this session); the dialog is path-keyed.
if !item.path.is_empty() {
let manage = gtk4::Button::builder()
.label("Manage")
.valign(gtk4::Align::Center)
.build();
manage.add_css_class("flat");
let ui_manage = ui.clone();
let entry = shared_item_as_entry(item);
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
row.add_suffix(&manage);
}
ui.shared_by_me.group.add(&row);
rows.push(row.upcast());
}
*ui.shared_by_me.rows.borrow_mut() = rows;
}
/// A one-line summary of who can reach a shared item, for its row subtitle.
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
let mut parts = Vec::new();
if item.member_count > 0 {
parts.push(format!(
"{} {}",
item.member_count,
if item.member_count == 1 {
"person"
} else {
"people"
}
));
}
if item.invite_count > 0 {
parts.push(format!("{} pending", item.invite_count));
}
if item.link.is_some() {
parts.push("Public link".to_string());
}
if parts.is_empty() {
"Shared".to_string()
} else {
parts.join(" · ")
}
}
/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can
/// open on it. Only used when the item's path is known.
pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
DirEntry {
name: item.name.clone(),
is_dir: item.is_dir,
size: 0,
modified: 0,
pinned: false,
cached: false,
uid: item.uid.clone(),
path: item.path.clone(),
}
}
+894
View File
@@ -0,0 +1,894 @@
use crate::*;
pub(crate) struct StatusState {
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
pub(crate) status_inflight: Cell<bool>,
/// Guards the [`Request::GetQueueStatus`] poll the same way: at most one
/// in-flight at a time so a wedged daemon can't stack worker threads.
pub(crate) transfers_inflight: Cell<bool>,
/// Activity group + its current rows, hidden when no transfer is in flight.
pub(crate) transfers_group: adw::PreferencesGroup,
pub(crate) transfer_rows: RefCell<Vec<TransferRow>>,
// Main page.
pub(crate) account_row: adw::ActionRow,
/// Read-only mount status line. The mount is driven by the systemd user
/// service (enabled on login), not by the user — so this only reports.
pub(crate) mount_row: adw::ActionRow,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
/// Account-quota group + its bar/label. Hidden until the first reading.
pub(crate) quota_group: adw::PreferencesGroup,
pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label,
/// One `AccountQuota` in flight at a time, and when it last succeeded — quota
/// barely moves, so [`refresh_quota`] refetches on a long TTL, not every tick.
pub(crate) quota_inflight: Cell<bool>,
pub(crate) quota_checked_at: Cell<Option<Instant>>,
/// "Start on login" toggle. [`Self::settings_suppress`] guards programmatic
/// sets so reflecting the systemd state doesn't fire the toggle handler.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
pub(crate) budget_row: adw::SpinRow,
/// Shows the active mountpoint in its subtitle; updated when the user picks a
/// new folder.
pub(crate) mountpoint_row: adw::ActionRow,
/// Set while a settings widget is being populated programmatically, so its
/// change handler skips the IPC/systemd side effect.
pub(crate) settings_suppress: Cell<bool>,
pub(crate) pins_group: adw::PreferencesGroup,
/// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// diff against them and only rebuild when the pin set actually changes.
pub(crate) pin_rows: RefCell<Vec<PinRow>>,
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
pub(crate) pins_state: RefCell<Option<Vec<String>>>,
/// The mount state the *last* desktop notification reported, so a flap only
/// notifies on the edge. `None` until the first status reply, so a cold start
/// doesn't announce "disconnected" before the service has had a chance to come
/// up.
pub(crate) notified_mounted: Cell<Option<bool>>,
/// How many transfers were in flight on the previous poll. A drop to zero is
/// what "sync complete" means; there's no completion event on the wire.
pub(crate) active_transfers: Cell<usize>,
}
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
pub(crate) struct PinRow {
pub(crate) row: adw::ActionRow,
/// The unpin button, absent on the placeholder row.
pub(crate) unpin: Option<gtk4::Button>,
}
/// One rendered row in the Activity group: a description over a progress bar.
/// Retained so [`repaint_transfers`] can update the bar and label in place each
/// tick when the active set is unchanged, instead of rebuilding.
pub(crate) struct TransferRow {
pub(crate) row: adw::PreferencesRow,
pub(crate) label: gtk4::Label,
pub(crate) bar: gtk4::ProgressBar,
}
/// What one Activity row should say this tick, and how far along it is —
/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and
/// transfers both render to this, so the group is one list in the order the
/// daemon reports: the jobs that frame the work, then the files moving under it.
pub(crate) struct ActivityLine {
pub(crate) text: String,
pub(crate) fraction: Option<f64>,
}
/// Widgets the settings page hands back for the refresh loop and action wiring.
pub(crate) struct MainWidgets {
pub(crate) account_row: adw::ActionRow,
pub(crate) mount_row: adw::ActionRow,
/// Live upload/download progress, populated by the refresh loop.
pub(crate) transfers_group: adw::PreferencesGroup,
/// Account-quota group; hidden until `refresh_quota` gets a reading.
pub(crate) quota_group: adw::PreferencesGroup,
pub(crate) quota_bar: gtk4::ProgressBar,
pub(crate) quota_label: gtk4::Label,
pub(crate) cache_bar: gtk4::ProgressBar,
pub(crate) cache_label: gtk4::Label,
pub(crate) pins_group: adw::PreferencesGroup,
pub(crate) logout_button: gtk4::Button,
/// "Start on login" toggle, reflecting the systemd unit's enabled state.
pub(crate) autostart_row: adw::SwitchRow,
/// Cache soft-cap editor, in GiB; `0` = unlimited.
pub(crate) budget_row: adw::SpinRow,
/// Purges all unpinned cached content.
pub(crate) purge_button: gtk4::Button,
/// Shows the active mountpoint; its suffix button opens a folder chooser.
pub(crate) mountpoint_row: adw::ActionRow,
pub(crate) mountpoint_button: gtk4::Button,
}
/// The main (logged-in) page: a libadwaita settings surface — account header,
/// mount status, storage controls (cache budget + purge), system integration
/// (start-on-login, mountpoint), the pin list, and developer overrides. Returns
/// the widgets the refresh loop updates plus the controls to wire.
pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
// Account group: identity + sign-out.
let account_group = adw::PreferencesGroup::new();
let account_row = adw::ActionRow::builder().title("Not signed in").build();
let avatar = adw::Avatar::new(40, None, true);
account_row.add_prefix(&avatar);
let logout_button = gtk4::Button::builder()
.label("Sign out")
.valign(gtk4::Align::Center)
.build();
logout_button.add_css_class("flat");
account_row.add_suffix(&logout_button);
account_group.add(&account_row);
// Account storage: the Proton account quota (used of total), distinct from
// the local content cache below. A progress bar + "X of Y used" line, painted
// by `refresh_quota`. Hidden until the first successful read so a cold start
// (or an account the API can't report) shows nothing rather than an empty bar.
let quota_group = adw::PreferencesGroup::builder()
.title("Account storage")
.description("Your Proton storage across all products.")
.visible(false)
.build();
let quota_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
quota_box.set_margin_top(6);
quota_box.set_margin_bottom(6);
let quota_bar = gtk4::ProgressBar::new();
let quota_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
quota_label.add_css_class("dim-label");
quota_box.append(&quota_bar);
quota_box.append(&quota_label);
let quota_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&quota_box)
.build();
quota_group.add(&quota_row);
// Mount group: a read-only status line. The mount is managed automatically
// by the systemd user service; there is no toggle to fiddle with.
let mount_group = adw::PreferencesGroup::builder().title("Drive").build();
let mount_row = adw::ActionRow::builder()
.title("Proton Drive")
.subtitle("Not mounted")
.build();
mount_group.add(&mount_row);
// Activity group: live upload/download progress. Hidden until the refresh
// loop sees an in-flight transfer from `Request::GetQueueStatus`.
let transfers_group = adw::PreferencesGroup::builder()
.title("Activity")
.description("Files moving to and from Proton Drive.")
.visible(false)
.build();
// Storage group: a progress bar + "X of Y used" label, plus the cache-budget
// editor and a purge button. `budget_row`/`purge_button` are wired in
// `wire_settings`; the bar + label are repainted by the refresh loop.
let storage_group = adw::PreferencesGroup::builder()
.title("Storage")
.description("Local cache for pinned and recently opened files.")
.build();
let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
storage_box.set_margin_top(6);
storage_box.set_margin_bottom(6);
let cache_bar = gtk4::ProgressBar::new();
let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
cache_label.add_css_class("dim-label");
storage_box.append(&cache_bar);
storage_box.append(&cache_label);
let usage_row = adw::PreferencesRow::builder()
.activatable(false)
.child(&storage_box)
.build();
storage_group.add(&usage_row);
// Cache budget, expressed in GiB. 0 = unlimited; the daemon applies a 0 cap
// as "no eviction". Step in 0.5 GiB; the upper bound is generous.
let budget_adj = gtk4::Adjustment::new(0.0, 0.0, 1024.0, 0.5, 1.0, 0.0);
let budget_row = adw::SpinRow::builder()
.title("Cache budget (GiB)")
.subtitle("Soft cap for cached content; 0 = unlimited.")
.adjustment(&budget_adj)
.digits(1)
.build();
storage_group.add(&budget_row);
let purge_row = adw::ActionRow::builder()
.title("Purge cache")
.subtitle("Delete cached content. Pinned files are kept.")
.build();
let purge_button = gtk4::Button::builder()
.label("Purge")
.valign(gtk4::Align::Center)
.build();
purge_button.add_css_class("destructive-action");
purge_row.add_suffix(&purge_button);
storage_group.add(&purge_row);
// System integration: start-on-login + mountpoint chooser.
let system_group = adw::PreferencesGroup::builder()
.title("System integration")
.build();
let autostart_row = adw::SwitchRow::builder()
.title("Start on login")
.subtitle("Mount Proton Drive automatically when you log in.")
.build();
system_group.add(&autostart_row);
let mountpoint_row = adw::ActionRow::builder()
.title("Mountpoint")
.subtitle("—")
.build();
let mountpoint_button = gtk4::Button::builder()
.label("Change")
.valign(gtk4::Align::Center)
.build();
mountpoint_button.add_css_class("flat");
mountpoint_row.add_suffix(&mountpoint_button);
system_group.add(&mountpoint_row);
// Pins group: filled in by refresh.
let pins_group = adw::PreferencesGroup::builder()
.title("Pinned files")
.description("Kept available offline on this device.")
.build();
// Developer overrides: read-only client identity, for support/debugging.
let dev_group = adw::PreferencesGroup::builder().title("Developer").build();
let version_row = adw::ActionRow::builder()
.title("App version")
.subtitle(pdfs_core::config::APP_VERSION)
.build();
version_row.add_css_class("property");
let agent_row = adw::ActionRow::builder()
.title("User agent")
.subtitle(pdfs_core::config::USER_AGENT)
.build();
agent_row.add_css_class("property");
dev_group.add(&version_row);
dev_group.add(&agent_row);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
inner.set_margin_top(18);
inner.set_margin_bottom(18);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&account_group);
inner.append(&quota_group);
inner.append(&mount_group);
inner.append(&transfers_group);
inner.append(&storage_group);
inner.append(&system_group);
inner.append(&pins_group);
inner.append(&dev_group);
let clamp = adw::Clamp::builder()
.maximum_size(560)
.child(&inner)
.build();
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
(
scroll.upcast(),
MainWidgets {
account_row,
mount_row,
transfers_group,
quota_group,
quota_bar,
quota_label,
cache_bar,
cache_label,
pins_group,
logout_button,
autostart_row,
budget_row,
purge_button,
mountpoint_row,
mountpoint_button,
},
)
}
/// Bytes per GiB, for the cache-budget editor's unit conversion.
pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
/// Wire the Settings-page controls: the cache-budget editor, the purge button,
/// the start-on-login switch and the mountpoint chooser. Initial widget state is
/// read once from config / systemd here (the refresh loop owns only the live
/// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the
/// programmatic populate so the change handlers don't fire on it.
pub(crate) fn wire_settings(
ui: &Rc<Ui>,
purge_button: &gtk4::Button,
mountpoint_button: &gtk4::Button,
) {
let config = ui.dirs.load_config();
// Populate from persisted config + the systemd unit state, suppressed.
ui.status.settings_suppress.set(true);
ui.status
.budget_row
.set_value(config.resolved_cache_budget() as f64 / GIB);
ui.status
.mountpoint_row
.set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string());
ui.status.autostart_row.set_active(service::is_enabled());
ui.status.settings_suppress.set(false);
// Cache budget: a user edit applies the new soft cap on the daemon (which
// also persists it to config). 0 GiB = unlimited.
let ui_budget = ui.clone();
ui.status.budget_row.connect_value_notify(move |row| {
if ui_budget.status.settings_suppress.get() {
return;
}
let bytes = (row.value() * GIB).round() as u64;
settings_request(
&ui_budget,
Request::SetCacheBudget { bytes },
"Cache budget updated",
"Couldn't set cache budget",
);
});
// Purge: confirm, then drop all unpinned cached content via the daemon.
let ui_purge = ui.clone();
purge_button.connect_clicked(move |_| {
let ui = ui_purge.clone();
let dialog = adw::AlertDialog::builder()
.heading("Purge cache")
.body("Delete all cached content that isn't pinned? Pinned files stay offline.")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("purge", "Purge");
dialog.set_response_appearance("purge", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
dialog.connect_response(None, move |_, resp| {
if resp == "purge" {
settings_request(
&ui,
Request::PurgeCache,
"Cache purged",
"Couldn't purge cache",
);
}
});
dialog.present(ui_window(&ui_purge).as_ref());
});
// Start on login: enable/disable the systemd unit without stopping a live
// mount (the user can disconnect separately).
let ui_auto = ui.clone();
ui.status.autostart_row.connect_active_notify(move |row| {
if ui_auto.status.settings_suppress.get() {
return;
}
if row.is_active() {
service::enable();
} else {
service::disable();
}
});
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
// the change takes effect (the daemon reads the path on mount).
let ui_mp = ui.clone();
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
}
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
/// confirming with `done` or reporting the daemon's error under `failed`. Unlike
/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the
/// cache read-out.
pub(crate) fn settings_request(
ui: &Rc<Ui>,
req: Request,
done: &'static str,
failed: &'static str,
) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => toast(&ui, done),
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
_ => toast_error(&ui, failed, "The mount service didn't respond."),
}
});
}
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
/// the mount service so the daemon picks it up.
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title("Choose mountpoint folder")
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(path) = folder.path() else { return };
let path_str = path.display().to_string();
// Persist the choice to config so the next mount uses it.
let mut config = ui.dirs.load_config();
config.mountpoint = Some(path_str.clone());
if let Err(e) = ui.dirs.save_config(&config) {
toast_error(&ui, "Couldn't save mountpoint", &e.to_string());
return;
}
ui.status.mountpoint_row.set_subtitle(&path_str);
// The daemon only reads the mountpoint at mount time, so offer a restart.
let confirm = adw::AlertDialog::builder()
.heading("Restart to apply")
.body(format!(
"The mountpoint is now “{path_str}”. Restart the Drive mount to use it?"
))
.build();
confirm.add_response("later", "Later");
confirm.add_response("restart", "Restart now");
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
confirm.set_default_response(Some("restart"));
confirm.set_close_response("later");
confirm.connect_response(None, |_, resp| {
if resp == "restart" {
service::restart();
}
});
confirm.present(ui_window(&ui).as_ref());
});
}
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
/// reload the page.
pub(crate) fn wire_retry(ui: &Rc<Ui>) {
let ui_browser = ui.clone();
ui.browser.retry.clone().connect_clicked(move |_| {
service::restart();
load_browser(&ui_browser);
});
let ui_gallery = ui.clone();
ui.gallery.retry.clone().connect_clicked(move |_| {
service::restart();
load_gallery(&ui_gallery, false);
});
let ui_trash = ui.clone();
ui.trash.retry.clone().connect_clicked(move |_| {
service::restart();
load_trash(&ui_trash);
});
}
/// Repaint the window from the cached login identity, then kick an async mount-
/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
/// and the status round-trip is offloaded to a worker so the main loop never
/// blocks on a slow or wedged daemon.
pub(crate) fn refresh(ui: &Rc<Ui>) {
// Login identity decides which page is shown. Read the cached session — set
// at startup and on login/logout — never the keyring.
{
let session = ui.session.borrow();
match session.as_ref() {
Some(s) => {
// Only pull the user onto a destination when they're sitting on the
// login page; otherwise leave whichever page they navigated to.
if ui.stack.visible_child_name().as_deref() == Some("login") {
ui.stack.set_visible_child_name("browser");
}
ui.nav.set_collapsed(false);
ui.status.account_row.set_title(&s.username);
ui.status.account_row.set_subtitle("Proton account");
}
None => {
ui.stack.set_visible_child_name("login");
// Collapsed + showing content = the login page owns the window and
// no destination is reachable without a session.
ui.nav.set_collapsed(true);
ui.nav.set_show_content(true);
return;
}
}
}
refresh_status(ui);
refresh_transfers(ui);
// Both of these pages show work as it happens, so they follow the tick while
// they are on screen. Every other page loads on navigation only.
match ui.stack.visible_child_name().as_deref() {
Some("main") => refresh_quota(ui),
Some("devices") => refresh_sync_folders(ui),
Some("activity") => refresh_activity(ui),
_ => {}
}
}
/// How long a quota reading stays fresh. Account storage barely moves, so the
/// Settings tick refetches it only this often rather than every 2s.
const QUOTA_TTL: Duration = Duration::from_secs(60);
/// Fetch the account quota (if the last reading is stale) and paint the Account
/// storage group. Runs only while Settings is on screen. The group stays hidden
/// until a reading lands, so an account the API can't report — or a still-starting
/// daemon — shows nothing rather than an empty bar. A failed fetch leaves the last
/// good reading in place.
pub(crate) fn refresh_quota(ui: &Rc<Ui>) {
if ui.status.quota_inflight.get() {
return;
}
if let Some(at) = ui.status.quota_checked_at.get()
&& at.elapsed() < QUOTA_TTL
{
return;
}
ui.status.quota_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::AccountQuota);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.quota_inflight.set(false);
if let Ok(Ok(Response::AccountQuota {
max_space,
used_space,
})) = result
{
ui.status.quota_checked_at.set(Some(Instant::now()));
let used = used_space.max(0) as u64;
if max_space > 0 {
let total = max_space as u64;
let fraction = (used as f64 / total as f64).min(1.0);
ui.status.quota_bar.set_fraction(fraction);
let pct = (fraction * 100.0).round() as u64;
ui.status.quota_label.set_text(&format!(
"{} of {} used ({pct}%)",
human_bytes(used),
human_bytes(total)
));
} else {
ui.status.quota_bar.set_fraction(0.0);
ui.status
.quota_label
.set_text(&format!("{} used", human_bytes(used)));
}
ui.status.quota_group.set_visible(true);
}
});
}
/// Record the mount state seen by the last status poll: gate every control that
/// needs a live daemon, and notify the desktop when the state actually flips.
///
/// The gating is the point — without it, New Folder / Upload / the details pane's
/// actions stay clickable while the mount is down and each click buys a round-trip
/// that can only fail. A greyed control says so up front.
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
*ui.mounted.borrow_mut() = mounted;
ui.browser.new_folder.set_sensitive(mounted);
ui.browser.upload.set_sensitive(mounted);
ui.browser.upload_folder.set_sensitive(mounted);
ui.gallery.upload.set_sensitive(mounted);
ui.details.details.pin_row.set_sensitive(mounted);
ui.details.details.rename_button.set_sensitive(mounted);
ui.details.details.trash_button.set_sensitive(mounted);
ui.details.details.open_button.set_sensitive(mounted);
// Only notify on a real edge, and never for the first reading: at startup the
// service is usually still coming up, and "disconnected" would be a lie.
if ui.status.notified_mounted.get() == Some(mounted) {
return;
}
let first = ui.status.notified_mounted.replace(Some(mounted)).is_none();
if first {
return;
}
if mounted {
notify(
"mount-state",
"Proton Drive connected",
"Your Drive is mounted and available.",
);
} else {
notify(
"mount-state",
"Proton Drive disconnected",
"The mount service stopped. Files aren't available until it restarts.",
);
}
}
/// Poll the daemon's in-flight transfers on a worker thread and repaint the
/// Activity group. Independently inflight-guarded from [`refresh_status`] so the
/// two cheap polls on the 2s tick don't gate each other. The group hides itself
/// when nothing is moving, so an idle account shows no Activity section.
pub(crate) fn refresh_transfers(ui: &Rc<Ui>) {
if ui.status.transfers_inflight.get() {
return;
}
ui.status.transfers_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.transfers_inflight.set(false);
match result {
Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs),
// Daemon unreachable or odd reply: clear the section rather than
// leave stale progress bars frozen on screen.
_ => repaint_transfers(&ui, &[], &[]),
}
});
}
/// Render the Activity group from a work snapshot: the daemon's jobs (scans,
/// batch counts, the local index) above the files moving under them. Rebuilds the
/// rows only when the set changes (count differs); on the common steady tick it
/// updates each bar's fraction and the label in place, so progress animates
/// without flicker. Hides the whole group when the daemon is idle.
pub(crate) fn repaint_transfers(ui: &Rc<Ui>, items: &[TransferItem], jobs: &[JobItem]) {
let lines: Vec<ActivityLine> = jobs
.iter()
.map(job_line)
.chain(items.iter().map(transfer_line))
.collect();
// The wire carries in-flight transfers only, with no completion event: the
// count falling to zero is what "the sync finished" looks like from here.
// Jobs are deliberately not counted — a bulk upload retires its scan job and
// starts its upload job mid-flight, which is not a thing finishing.
let previous = ui.status.active_transfers.replace(items.len());
if items.is_empty() && previous > 0 {
let files = if previous == 1 {
"1 file".to_string()
} else {
format!("{previous} files")
};
notify(
"sync-complete",
"Sync complete",
&format!("{files} finished transferring."),
);
// A just-finished batch may have added files (bulk upload) the current
// listing doesn't show yet; refresh whichever listing is on screen.
reload_listing(ui);
}
if lines.is_empty() {
if !ui.status.transfer_rows.borrow().is_empty() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
}
ui.status.transfers_group.set_visible(false);
return;
}
ui.status.transfers_group.set_visible(true);
// Rebuild rows only when the count changes; otherwise reuse them in place.
if ui.status.transfer_rows.borrow().len() != lines.len() {
for tr in ui.status.transfer_rows.borrow_mut().drain(..) {
ui.status.transfers_group.remove(&tr.row);
}
for _ in &lines {
let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
row_box.set_margin_top(8);
row_box.set_margin_bottom(8);
let label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
label.add_css_class("dim-label");
let bar = gtk4::ProgressBar::new();
row_box.append(&label);
row_box.append(&bar);
let row = adw::PreferencesRow::builder()
.activatable(false)
.child(&row_box)
.build();
ui.status.transfers_group.add(&row);
ui.status
.transfer_rows
.borrow_mut()
.push(TransferRow { row, label, bar });
}
}
for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) {
tr.label.set_text(&line.text);
match line.fraction {
Some(f) => tr.bar.set_fraction(f),
// No total to divide by: pulse so the bar still reads as "working".
None => tr.bar.pulse(),
}
}
}
/// One Activity row for a daemon job: its title, plus whatever it can say about
/// where it is — a count when it has one, else what it is currently chewing on.
pub(crate) fn job_line(j: &JobItem) -> ActivityLine {
let text = match (j.total > 0, j.detail.is_empty()) {
(true, true) => format!("{} — {} of {}", j.title, j.done, j.total),
(true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total),
(false, true) => format!("{}…", j.title),
(false, false) => format!("{} — {}…", j.title, j.detail),
};
ActivityLine {
text,
fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)),
}
}
/// One Activity row for a file in flight: which way it's going, how far, how fast.
pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
ActivityLine {
text: format!(
"{arrow} {} — {} ({}/s)",
t.name,
human_bytes(t.bytes_completed),
human_bytes(t.speed_bytes_sec),
),
fraction: None,
}
} else {
ActivityLine {
text: format!(
"{arrow} {} — {} of {} ({}/s)",
t.name,
human_bytes(t.bytes_completed),
human_bytes(t.bytes_total),
human_bytes(t.speed_bytes_sec),
),
fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)),
}
}
}
/// Fetch mount status + cache stats from the daemon on a worker thread and repaint
/// the mount line, cache bar and pin list on the reply. The daemon owns the cache
/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
/// the tick can't stack threads on a stalled daemon.
pub(crate) fn refresh_status(ui: &Rc<Ui>) {
if ui.status.status_inflight.get() {
return;
}
ui.status.status_inflight.set(true);
let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.status.status_inflight.set(false);
match result {
Ok(Ok(Response::Status {
mountpoint,
used,
budget,
pins,
online,
pending_uploads,
pending_changes,
..
})) => {
set_mounted(&ui, true);
// The queue is the more useful thing to say when it has anything
// 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 = pending_summary(pending_uploads, pending_changes);
ui.status.mount_row.set_subtitle(&match (online, queued) {
(true, None) => format!("Mounted at {mountpoint}"),
(true, Some(q)) => format!("Mounted at {mountpoint} — {q}"),
(false, None) => {
format!("Mounted at {mountpoint} — offline, cached files only")
}
(false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"),
});
let fraction = if budget == 0 {
0.0
} else {
(used as f64 / budget as f64).min(1.0)
};
ui.status.cache_bar.set_fraction(fraction);
ui.status.cache_label.set_text(&format!(
"{} of {} used",
human_bytes(used),
human_bytes(budget)
));
repaint_pins(&ui, &pins, true);
}
// Daemon unreachable (still starting, or down): report not-mounted and
// grey out the unpin buttons in place, but leave the last-known pin
// rows and cache read-out so the page doesn't flicker on a blip.
_ => {
set_mounted(&ui, false);
ui.status.mount_row.set_subtitle("Not mounted");
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(false);
}
}
}
}
});
}
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
/// mount daemon is running (`mounted`). Diffs against the last batch by path: when
/// the set is unchanged (the common case on the 2s tick) it only flips the unpin
/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
/// and drop scroll/selection every tick.
pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
if ui.status.pins_state.borrow().as_ref() == Some(&desired) {
for r in ui.status.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(mounted);
}
}
return;
}
for pr in ui.status.pin_rows.borrow_mut().drain(..) {
ui.status.pins_group.remove(&pr.row);
}
*ui.status.pins_state.borrow_mut() = Some(desired);
if pins.is_empty() {
let row = adw::ActionRow::builder()
.title("No pinned files")
.subtitle("Right-click a file in the mount to keep it offline.")
.build();
ui.status.pins_group.add(&row);
ui.status
.pin_rows
.borrow_mut()
.push(PinRow { row, unpin: None });
return;
}
for pin in pins {
let name = Path::new(&pin.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&pin.path)
.to_string();
let row = adw::ActionRow::builder()
.title(&name)
.subtitle(&pin.path)
.build();
let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic");
row.add_prefix(&icon);
let unpin = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.valign(gtk4::Align::Center)
.tooltip_text("Unpin (remove offline copy)")
.sensitive(mounted)
.build();
unpin.add_css_class("flat");
let ui_btn = ui.clone();
let path = pin.path.clone();
unpin.connect_clicked(move |_| {
let socket = ui_btn.dirs.control_socket();
match send(&socket, &Request::Unpin { path: path.clone() }) {
Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"),
Ok(_) => refresh(&ui_btn),
Err(e) => tracing::error!("unpin request failed: {e}"),
}
});
row.add_suffix(&unpin);
ui.status.pins_group.add(&row);
ui.status.pin_rows.borrow_mut().push(PinRow {
row,
unpin: Some(unpin),
});
}
}
+398
View File
@@ -0,0 +1,398 @@
use crate::*;
pub(crate) struct TrashState {
// Trash page. Trashed nodes are addressed by uid, not by path, so this page
// keeps no current-directory state — it re-lists from the daemon on show.
pub(crate) model: gio::ListStore,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
/// Empties the trash; insensitive while it is empty (or unread).
pub(crate) empty: gtk4::Button,
/// "12 items" under the page title.
pub(crate) subtitle: gtk4::Label,
}
/// The widgets of the Trash page that a load repaints.
pub(crate) struct TrashWidgets {
pub(crate) model: gio::ListStore,
pub(crate) list: gtk4::ListView,
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) empty: gtk4::Button,
pub(crate) refresh: gtk4::Button,
pub(crate) subtitle: gtk4::Label,
}
/// The Trash page: a flat list of everything Drive is holding in the trash, each
/// row offering Restore and Delete Forever, with Empty Trash in the header.
///
/// A trashed node has no path inside the mount — the daemon forgets it when it is
/// trashed — so unlike the Files page this one addresses entries by uid and always
/// re-lists from the server rather than from a cached listing. Row rendering needs
/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`].
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
let model = gio::ListStore::new::<BoxedAnyObject>();
let title = gtk4::Label::builder()
.label("Trash")
.halign(gtk4::Align::Start)
.build();
title.add_css_class("title-2");
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
subtitle.add_css_class("dim-label");
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
titles.set_hexpand(true);
titles.append(&title);
titles.append(&subtitle);
let empty = gtk4::Button::builder()
.label("Empty Trash")
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
empty.add_css_class("destructive-action");
let refresh = refresh_button();
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header.append(&titles);
header.append(&refresh);
header.append(&empty);
let retry = gtk4::Button::builder()
.label("Retry")
.halign(gtk4::Align::Center)
.build();
retry.add_css_class("pill");
retry.add_css_class("suggested-action");
retry.set_visible(false);
let status = adw::StatusPage::builder()
.icon_name("user-trash-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
// No selection model: every action lives on the row it acts on, so a
// mis-aimed Delete Forever isn't one click away from a stale selection.
let list = gtk4::ListView::builder()
.model(&gtk4::NoSelection::new(Some(model.clone())))
.build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&list)
.build();
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&header);
inner.append(&content);
(
inner.upcast(),
TrashWidgets {
model,
list,
content,
status,
retry,
empty,
refresh,
subtitle,
},
)
}
/// Install the row factory and the Empty Trash button. The row's two buttons read
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
/// captured copy, so a recycled row always acts on the item it currently shows.
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: &gtk4::ListView, empty: &gtk4::Button) {
let factory = gtk4::SignalListItemFactory::new();
let ui_setup = ui.clone();
factory.connect_setup(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let icon = gtk4::Image::builder().pixel_size(32).build();
let name = gtk4::Label::builder()
.halign(gtk4::Align::Start)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build();
meta.add_css_class("dim-label");
meta.add_css_class("caption");
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
text.set_hexpand(true);
text.set_valign(gtk4::Align::Center);
text.append(&name);
text.append(&meta);
let restore = gtk4::Button::builder()
.icon_name("edit-undo-symbolic")
.tooltip_text("Restore to its original folder")
.valign(gtk4::Align::Center)
.build();
restore.add_css_class("flat");
let purge = gtk4::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Delete permanently")
.valign(gtk4::Align::Center)
.build();
purge.add_css_class("flat");
let ui_restore = ui_setup.clone();
let item_restore = item.clone();
restore.connect_clicked(move |_| {
if let Some(entry) = bound_entry(&item_restore) {
restore_entry(&ui_restore, &entry);
}
});
let ui_purge = ui_setup.clone();
let item_purge = item.clone();
purge.connect_clicked(move |_| {
if let Some(entry) = bound_entry(&item_purge) {
prompt_delete_forever(&ui_purge, &entry);
}
});
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
row.set_margin_top(6);
row.set_margin_bottom(6);
row.set_margin_start(6);
row.set_margin_end(6);
row.append(&icon);
row.append(&text);
row.append(&restore);
row.append(&purge);
item.set_child(Some(&row));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let Some(entry) = bound_entry(item) else {
return;
};
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
return;
};
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
return;
};
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
name.set_label(&entry.name);
}
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
let kind = if entry.is_dir {
"Folder".to_string()
} else {
human_bytes(entry.size)
};
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
}
});
list.set_factory(Some(&factory));
let ui_empty = ui.clone();
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
}
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
/// (recycled) row.
pub(crate) fn bound_entry(item: &gtk4::ListItem) -> Option<DirEntry> {
let obj = item.item().and_downcast::<BoxedAnyObject>()?;
let entry = obj.borrow::<DirEntry>().clone();
Some(entry)
}
/// Fetch the trash listing and repaint the page.
pub(crate) fn load_trash(ui: &Rc<Ui>) {
// Drop the old rows first: a stale row here would offer Restore on something
// that may already be gone.
ui.trash.model.remove_all();
ui.trash.empty.set_sensitive(false);
trash_status(
ui,
"user-trash-symbolic",
"Loading…",
"Reading the trash.",
false,
);
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
Ok(Ok(Response::Error { message, .. })) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
&message,
false,
),
Ok(Ok(_)) => trash_status(
&ui,
"dialog-warning-symbolic",
"Couldn't read the trash",
"Unexpected reply from the mount service.",
false,
),
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
}
});
}
/// The daemon didn't answer. Same split as the Files page: still starting (poll
/// again, no button) versus actually down (Retry, which restarts the service).
pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
if service::is_failed() || !service::is_active() {
trash_status(
ui,
"network-offline-symbolic",
"Not connected",
"The Proton Drive mount service isn't running.",
true,
);
return;
}
trash_status(
ui,
"folder-remote-symbolic",
"Connecting…",
"Waiting for the Proton Drive mount service to come up.",
false,
);
let ui = ui.clone();
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
if ui.stack.visible_child_name().as_deref() == Some("trash") {
load_trash(&ui);
}
});
}
/// Show a status page in place of the trash list.
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.trash.status.set_icon_name(Some(icon));
ui.trash.status.set_title(title);
ui.trash.status.set_description(Some(description));
ui.trash.retry.set_visible(retry);
ui.trash.content.set_visible_child_name("status");
ui.trash.subtitle.set_label("");
}
/// Repopulate the trash list, most recently modified first — the order in which a
/// user looks for what they just deleted.
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
ui.trash.model.remove_all();
ui.trash.empty.set_sensitive(!entries.is_empty());
if entries.is_empty() {
trash_status(
ui,
"user-trash-symbolic",
"Trash is empty",
"Items you delete from Proton Drive show up here.",
false,
);
return;
}
ui.trash.content.set_visible_child_name("list");
ui.trash.subtitle.set_label(&match entries.len() {
1 => "1 item".to_string(),
n => format!("{n} items"),
});
let mut sorted = entries.to_vec();
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
for entry in sorted {
ui.trash.model.append(&BoxedAnyObject::new(entry));
}
}
/// Restore one trashed entry to the folder it was trashed from.
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let name = entry.name.clone();
run_mutation(
ui,
Request::Restore {
uids: vec![entry.uid.clone()],
},
format!("Restored “{name}”"),
"Couldn't restore",
);
}
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
let win = ui_window(ui);
let uid = entry.uid.clone();
let name = entry.name.clone();
let dialog = adw::AlertDialog::builder()
.heading("Delete permanently")
.body(format!(
"Permanently delete “{name}”? This cannot be undone."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("delete", "Delete Permanently");
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "delete" {
run_mutation(
&ui,
Request::DeleteForever {
uids: vec![uid.clone()],
},
format!("Deleted “{name}” permanently"),
"Couldn't delete",
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then permanently delete everything in the trash.
pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
let win = ui_window(ui);
let count = ui.trash.model.n_items();
let dialog = adw::AlertDialog::builder()
.heading("Empty Trash")
.body(format!(
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
))
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("empty", "Empty Trash");
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "empty" {
run_mutation(
&ui,
Request::EmptyTrash,
"Trash emptied".to_string(),
"Couldn't empty the trash",
);
}
});
dialog.present(win.as_ref());
}
+166
View File
@@ -0,0 +1,166 @@
//! The human-verification (CAPTCHA) dialog.
//!
//! 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
//! expects the client to present Proton's hosted verification page, collect the
//! token the user earns by solving it, and retry the login carrying that token.
//!
//! 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.
use crate::*;
use webkit6::prelude::*;
/// 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
/// the page itself — so nothing arrives unless the message is forwarded
/// explicitly. Injecting at document start guarantees the listener is installed
/// before the page can post anything.
const BRIDGE: &str = r#"
window.addEventListener('message', function (event) {
try {
window.webkit.messageHandlers.hv.postMessage(JSON.stringify(event.data));
} catch (e) {}
});
"#;
/// 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>,
) {
let content = webkit6::UserContentManager::new();
content.add_script(&webkit6::UserScript::new(
BRIDGE,
webkit6::UserContentInjectedFrames::AllFrames,
webkit6::UserScriptInjectionTime::Start,
&[],
&[],
));
// Registration is what makes `messageHandlers.hv` exist in the page; without
// it the bridge above throws on every message.
content.register_script_message_handler("hv", None);
let webview = webkit6::WebView::builder()
.user_content_manager(&content)
.vexpand(true)
.hexpand(true)
.build();
let dialog = adw::Dialog::builder()
.title("Verification")
.content_width(420)
.content_height(560)
.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();
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
// verification app's own chatter. Anything that is not a completion
// is simply not ours.
return;
};
if let Some(tx) = tx.borrow_mut().take() {
let _ = tx.send(token);
}
if let Some(dlg) = dlg_weak.upgrade() {
dlg.close();
}
});
// 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());
}
/// Pull the verification token out of a message posted by the verification page.
///
/// Returns `None` for anything that is not a completion message. The page posts
/// a good deal besides — resize requests, readiness pings — and treating an
/// unrecognised shape as success would hand the API an empty token and fail the
/// login with a confusing error.
fn extract_token(raw: &str) -> Option<String> {
let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
// If the message was stringified twice (e.g. raw is a double-quoted JSON string),
// parse the inner string.
if let Some(inner) = value.as_str()
&& let Ok(parsed) = serde_json::from_str(inner)
{
value = parsed;
}
// The payload is nested under `payload` and the message names itself in
// `type`; both spellings of the success type have shipped.
let kind = value.get("type")?.as_str()?;
if !matches!(
kind,
"HUMAN_VERIFICATION_SUCCESS" | "human_verification_success"
) {
return None;
}
let payload = value.get("payload")?;
let token = payload.get("token")?.as_str()?;
if token.is_empty() {
return None;
}
Some(token.to_owned())
}
#[cfg(test)]
mod tests {
use super::extract_token;
#[test]
fn a_completion_message_yields_its_token() {
let raw =
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":"tok-1","type":"captcha"}}"#;
assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
}
#[test]
fn a_double_serialized_completion_message_yields_its_token() {
let raw = r#""{\"type\":\"HUMAN_VERIFICATION_SUCCESS\",\"payload\":{\"token\":\"tok-1\",\"type\":\"captcha\"}}""#;
assert_eq!(extract_token(raw).as_deref(), Some("tok-1"));
}
/// The page posts plenty that is not a completion; none of it may be
/// mistaken for one, or the login retries with a garbage token.
#[test]
fn unrelated_messages_are_ignored() {
for raw in [
r#"{"type":"resize","payload":{"height":400}}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS"}"#,
r#"{"payload":{"token":"tok"}}"#,
r#"{"type":"HUMAN_VERIFICATION_SUCCESS","payload":{"token":""}}"#,
"not json at all",
"",
] {
assert!(extract_token(raw).is_none(), "accepted: {raw}");
}
}
}
+277
View File
@@ -0,0 +1,277 @@
use crate::*;
pub(crate) struct DetailsState {
/// Widgets in the details pane, repainted from the selected entry.
pub(crate) details: DetailsWidgets,
/// The entry the details pane is currently showing, so its buttons act on the
/// same one the user is looking at even after the model is repopulated.
pub(crate) details_entry: RefCell<Option<DirEntry>>,
/// Set while the details pane is being populated, so setting the offline
/// switch programmatically doesn't fire a pin/unpin round-trip.
pub(crate) details_suppress: Cell<bool>,
/// The grid's and list's selection models. Both wrap the one browser model, so
/// a selection in either drives the details pane.
pub(crate) grid_selection: gtk4::SingleSelection,
pub(crate) list_selection: gtk4::SingleSelection,
}
/// The widgets in the browser's details pane that a selection repaints.
pub(crate) struct DetailsWidgets {
pub(crate) icon: gtk4::Image,
pub(crate) name: gtk4::Label,
pub(crate) kind: gtk4::Label,
pub(crate) size_row: adw::ActionRow,
pub(crate) modified_row: adw::ActionRow,
pub(crate) path_row: adw::ActionRow,
pub(crate) pin_row: adw::SwitchRow,
pub(crate) open_button: gtk4::Button,
pub(crate) rename_button: gtk4::Button,
pub(crate) trash_button: gtk4::Button,
pub(crate) close_button: gtk4::Button,
}
/// The details pane shown beside the file views: a big type icon over the entry's
/// name, its properties, an offline (pin) toggle and the primary actions. Built
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
/// [`wire_details`] connects the buttons.
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
let close_button = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Close details")
.halign(gtk4::Align::End)
.build();
close_button.add_css_class("flat");
close_button.add_css_class("circular");
let icon = gtk4::Image::builder()
.icon_name("text-x-generic-symbolic")
.pixel_size(64)
.build();
let name = gtk4::Label::builder()
.wrap(true)
.wrap_mode(gtk4::pango::WrapMode::WordChar)
.justify(gtk4::Justification::Center)
.build();
name.add_css_class("title-4");
let kind = gtk4::Label::new(None);
kind.add_css_class("dim-label");
kind.add_css_class("caption");
let head = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
head.set_margin_bottom(6);
head.append(&icon);
head.append(&name);
head.append(&kind);
let props = adw::PreferencesGroup::new();
let size_row = adw::ActionRow::builder()
.title("Size")
.subtitle("—")
.build();
size_row.add_css_class("property");
let modified_row = adw::ActionRow::builder()
.title("Modified")
.subtitle("—")
.build();
modified_row.add_css_class("property");
let path_row = adw::ActionRow::builder()
.title("Location")
.subtitle("—")
.subtitle_lines(3)
.build();
path_row.add_css_class("property");
props.add(&size_row);
props.add(&modified_row);
props.add(&path_row);
let offline = adw::PreferencesGroup::new();
let pin_row = adw::SwitchRow::builder()
.title("Available offline")
.subtitle("Keep a local copy on this device.")
.build();
offline.add(&pin_row);
let open_button = gtk4::Button::builder().label("Open").build();
open_button.add_css_class("suggested-action");
open_button.add_css_class("pill");
let rename_button = gtk4::Button::builder().label("Rename").build();
rename_button.add_css_class("pill");
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
trash_button.add_css_class("destructive-action");
trash_button.add_css_class("pill");
let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
actions.set_margin_top(6);
actions.append(&open_button);
actions.append(&rename_button);
actions.append(&trash_button);
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(12);
inner.set_margin_bottom(12);
inner.set_margin_start(12);
inner.set_margin_end(12);
inner.append(&close_button);
inner.append(&head);
inner.append(&props);
inner.append(&offline);
inner.append(&actions);
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&inner)
.build();
let pane = adw::ToolbarView::new();
pane.set_content(Some(&scroll));
pane.add_css_class("background");
(
pane.upcast(),
DetailsWidgets {
icon,
name,
kind,
size_row,
modified_row,
path_row,
pin_row,
open_button,
rename_button,
trash_button,
close_button,
},
)
}
/// Connect the details pane: mirror the grid's and list's selection into it, and
/// wire its buttons back onto the entry it's showing.
pub(crate) fn wire_details(ui: &Rc<Ui>) {
// Both views share one model but have their own selection, so watch both.
for selection in [
ui.details.grid_selection.clone(),
ui.details.list_selection.clone(),
] {
let ui_sel = ui.clone();
selection.connect_selection_changed(move |sel, _, _| {
match entry_at(sel.model().as_ref(), sel.selected()) {
Some(entry) => show_details(&ui_sel, &entry),
None => hide_details(&ui_sel),
}
});
}
let ui_close = ui.clone();
ui.details.details.close_button.connect_clicked(move |_| {
ui_close
.details
.grid_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
ui_close
.details
.list_selection
.set_selected(gtk4::INVALID_LIST_POSITION);
hide_details(&ui_close);
});
let ui_open = ui.clone();
ui.details.details.open_button.connect_clicked(move |_| {
// Bind the clone to a local so the `Ref` from `borrow()` is dropped before
// `activate_entry` runs; it navigates and repaints the details pane, which
// takes `details_entry.borrow_mut()` and would panic against a live borrow.
let entry = ui_open.details.details_entry.borrow().clone();
if let Some(entry) = entry {
activate_entry(&ui_open, &entry);
}
});
let ui_rename = ui.clone();
ui.details.details.rename_button.connect_clicked(move |_| {
let entry = ui_rename.details.details_entry.borrow().clone();
if let Some(entry) = entry {
prompt_rename(&ui_rename, &entry);
}
});
let ui_trash = ui.clone();
ui.details.details.trash_button.connect_clicked(move |_| {
let entry = ui_trash.details.details_entry.borrow().clone();
if let Some(entry) = entry {
prompt_delete(&ui_trash, &entry);
}
});
let ui_pin = ui.clone();
ui.details
.details
.pin_row
.connect_active_notify(move |row| {
if ui_pin.details.details_suppress.get() {
return;
}
let Some(entry) = ui_pin.details.details_entry.borrow().clone() else {
return;
};
// The switch reads the *desired* state; `toggle_pin` derives the request
// from the entry's current one, so only act when they actually differ.
if row.is_active() != entry.pinned {
toggle_pin(&ui_pin, &entry);
}
});
}
/// Reveal the details pane and paint it from `entry`.
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
ui.details.details_suppress.set(true);
let d = &ui.details.details;
d.icon.set_icon_name(Some(icon_base_for(entry)));
d.name.set_label(&entry.name);
d.kind
.set_label(if entry.is_dir { "Folder" } else { "File" });
d.size_row.set_subtitle(&if entry.is_dir {
"—".to_string()
} else {
human_bytes(entry.size)
});
d.size_row.set_visible(!entry.is_dir);
d.modified_row
.set_subtitle(&format_modified(entry.modified));
let rel = entry_rel(ui, entry);
let parent = match rel.rfind('/') {
Some(i) => &rel[..i],
None => "",
};
d.path_row.set_subtitle(if parent.is_empty() {
"Proton Drive"
} else {
parent
});
d.pin_row.set_active(entry.pinned);
d.pin_row.set_sensitive(*ui.mounted.borrow());
d.open_button
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
ui.details.details_suppress.set(false);
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
// which fires on the *first* press of a double-click; mutating the widget
// tree here cancels GtkGridView's multi-press tracking, so the second press
// restarts the count and `activate` never fires — the folder needs two
// double-clicks. Deferring lets the click gesture finish first. Guard on the
// entry still being present so a navigation that clears it wins the race.
let ui = ui.clone();
glib::idle_add_local_once(move || {
if ui.details.details_entry.borrow().is_some() {
ui.browser.split.set_show_sidebar(true);
}
});
}
/// Hide the details pane and forget the entry it was showing, so a stale entry
/// can't be acted on after the listing moves on.
pub(crate) fn hide_details(ui: &Rc<Ui>) {
ui.browser.split.set_show_sidebar(false);
*ui.details.details_entry.borrow_mut() = None;
}
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
/// F2 / Delete shortcuts.
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
ui.details.details_entry.borrow().clone()
}
+2
View File
@@ -0,0 +1,2 @@
pub(crate) mod details;
pub(crate) mod share_dialog;
@@ -0,0 +1,465 @@
use crate::*;
/// The roles offered in the Share dialog's dropdowns, in index order.
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
/// Map a role dropdown index to the wire role string.
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
match idx {
1 => "editor",
2 => "admin",
_ => "viewer",
}
}
/// Map a wire role string to its dropdown index.
pub(crate) fn role_wire_to_index(role: &str) -> u32 {
match role {
"editor" => 1,
"admin" => 2,
_ => 0,
}
}
/// The mutable state behind an open Share dialog, so the invite/role/link
/// handlers can rebuild the people and link sections after each change without
/// tearing the whole dialog down.
pub(crate) struct ShareDialog {
pub(crate) ui: Rc<Ui>,
/// The node's mountpoint-relative path — how every request addresses it.
pub(crate) rel: String,
pub(crate) people: adw::PreferencesGroup,
pub(crate) link_group: adw::PreferencesGroup,
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
pub(crate) link_rows: RefCell<Vec<gtk4::Widget>>,
}
/// Open the per-node Share dialog: invite Proton/external users, manage who has
/// access and their roles, and create/copy/remove a public link.
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
if !*ui.mounted.borrow() {
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
return;
}
let rel = entry_rel(ui, entry);
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name)));
toolbar.add_top_bar(&header);
// Invite section: emails + role + optional message.
let invite_group = adw::PreferencesGroup::builder()
.title("Invite people")
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
.build();
let email_row = adw::EntryRow::builder().title("Email addresses").build();
let role_model = gtk4::StringList::new(&SHARE_ROLES);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title("Role").build();
role_wrap.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder().title("Message (optional)").build();
let invite_btn = gtk4::Button::builder()
.label("Send Invitations")
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
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);
let people = adw::PreferencesGroup::builder()
.title("People with access")
.build();
let link_group = adw::PreferencesGroup::builder()
.title("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));
let dialog = adw::Dialog::builder()
.title("Share")
.content_width(480)
.content_height(560)
.child(&toolbar)
.build();
let state = Rc::new(ShareDialog {
ui: ui.clone(),
rel,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
link_rows: RefCell::new(Vec::new()),
});
// Enter in either free-text field sends the invitations, no mouse needed.
let btn = invite_btn.clone();
email_row.connect_entry_activated(move |_| btn.emit_clicked());
let btn = invite_btn.clone();
message_row.connect_entry_activated(move |_| btn.emit_clicked());
// Invite button.
let state_inv = state.clone();
invite_btn.connect_clicked(move |_| {
let raw = email_row.text();
let emails: Vec<String> = raw
.split([',', ' ', ';'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if emails.is_empty() {
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
return;
}
let role = role_index_to_wire(role_drop.selected()).to_string();
let msg = message_row.text().trim().to_string();
let message = if msg.is_empty() { None } else { Some(msg) };
let email_clear = email_row.clone();
let msg_clear = message_row.clone();
share_dialog_op(
&state_inv,
Request::ShareNode {
path: state_inv.rel.clone(),
emails,
role,
message,
},
"Invitations sent",
"Couldn't send invitations",
Some(Box::new(move || {
email_clear.set_text("");
msg_clear.set_text("");
})),
);
});
share_dialog_reload(&state);
dialog.present(ui_window(ui).as_ref());
}
/// Re-fetch the node's share and rebuild the people + public-link sections.
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::ListShare {
path: state.rel.clone(),
},
);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Share { entries, link })) => {
repaint_share_people(&state, &entries);
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't load sharing", &message)
}
_ => toast_error(
&state.ui,
"Couldn't load sharing",
"The mount service didn't respond.",
),
}
});
}
/// 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(..) {
state.people.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
if entries.is_empty() {
let row = dim_row("No one else has access yet.");
state.people.add(&row);
rows.push(row.upcast());
*state.people_rows.borrow_mut() = rows;
return;
}
for entry in entries {
let subtitle = match entry.kind {
ShareEntryKind::Member => "Member".to_string(),
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.build();
// External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown.
if matches!(
entry.kind,
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
) {
let model = gtk4::StringList::new(&SHARE_ROLES);
let drop = gtk4::DropDown::builder()
.model(&model)
.selected(role_wire_to_index(&entry.role))
.valign(gtk4::Align::Center)
.build();
let state_role = state.clone();
let id = entry.id.clone();
let kind = entry.kind;
drop.connect_selected_notify(move |d| {
share_dialog_op(
&state_role,
Request::UpdateShareRole {
path: state_role.rel.clone(),
id: id.clone(),
kind,
role: role_index_to_wire(d.selected()).to_string(),
},
"Role updated",
"Couldn't update the role",
None,
);
});
row.add_suffix(&drop);
} else {
let label = gtk4::Label::builder()
.label(capitalize(&entry.role))
.valign(gtk4::Align::Center)
.build();
label.add_css_class("dim-label");
row.add_suffix(&label);
}
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove access")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let state_rm = state.clone();
let id = entry.id.clone();
let kind = entry.kind;
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemoveShareEntry {
path: state_rm.rel.clone(),
id: id.clone(),
kind,
},
"Access removed",
"Couldn't remove access",
None,
);
});
row.add_suffix(&remove);
state.people.add(&row);
rows.push(row.upcast());
}
*state.people_rows.borrow_mut() = rows;
}
/// Rebuild the public-link section: an existing link (copy / remove) or a
/// create control.
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
match link {
Some(link) => {
let url = link.url.clone().unwrap_or_default();
let subtitle = if link.has_password {
format!("Anyone with the link ({}) · password-protected", link.role)
} else {
format!("Anyone with the link ({})", link.role)
};
let row = adw::ActionRow::builder()
.title(if url.is_empty() { "Public link" } else { &url })
.subtitle(&subtitle)
.build();
row.add_css_class("property");
if !url.is_empty() {
let copy = gtk4::Button::builder()
.icon_name("edit-copy-symbolic")
.tooltip_text("Copy link")
.valign(gtk4::Align::Center)
.build();
copy.add_css_class("flat");
let state_copy = state.clone();
let url_copy = url.clone();
copy.connect_clicked(move |btn| {
btn.clipboard().set_text(&url_copy);
toast(&state_copy.ui, "Link copied");
});
row.add_suffix(&copy);
}
let remove = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Remove link")
.valign(gtk4::Align::Center)
.build();
remove.add_css_class("flat");
let state_rm = state.clone();
let id = link.id.clone();
remove.connect_clicked(move |_| {
share_dialog_op(
&state_rm,
Request::RemovePublicLink {
path: state_rm.rel.clone(),
id: id.clone(),
},
"Public link removed",
"Couldn't remove the link",
None,
);
});
row.add_suffix(&remove);
state.link_group.add(&row);
rows.push(row.upcast());
}
None => {
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]);
let role_drop = gtk4::DropDown::builder()
.model(&role_model)
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_row = adw::ActionRow::builder().title("Link role").build();
role_row.add_suffix(&role_drop);
let pw_row = adw::PasswordEntryRow::builder()
.title("Password (optional)")
.build();
let create = gtk4::Button::builder()
.label("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();
// Enter in the password field creates the link.
let btn = create.clone();
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
let state_c = state.clone();
let pw_for = pw_row.clone();
create.connect_clicked(move |_| {
let role = if role_drop.selected() == 1 {
"editor"
} else {
"viewer"
}
.to_string();
let pw = pw_for.text().to_string();
let password = if pw.is_empty() { None } else { Some(pw) };
share_dialog_create_link(&state_c, role, password);
});
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&create_wrap);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(create_wrap.upcast());
}
}
*state.link_rows.borrow_mut() = rows;
}
/// Create a public link, then reload the dialog so the copy/remove controls
/// replace the create form (and the freshly minted URL is shown).
pub(crate) fn share_dialog_create_link(
state: &Rc<ShareDialog>,
role: String,
password: Option<String>,
) {
let rx = spawn_request(
state.ui.dirs.control_socket(),
Request::CreatePublicLink {
path: state.rel.clone(),
role,
password,
expires: None,
},
);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::PublicLink { .. })) => {
toast(&state.ui, "Public link created");
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => {
toast_error(&state.ui, "Couldn't create the link", &message)
}
_ => toast_error(
&state.ui,
"Couldn't create the link",
"The mount service didn't respond.",
),
}
});
}
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
pub(crate) fn share_dialog_op(
state: &Rc<ShareDialog>,
req: Request,
done: &'static str,
failed: &'static str,
on_success: Option<Box<dyn Fn()>>,
) {
let rx = spawn_request(state.ui.dirs.control_socket(), req);
let state = state.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
if let Some(cb) = on_success {
cb();
}
toast(&state.ui, done);
share_dialog_reload(&state);
}
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message),
_ => {
// A role dropdown that failed is now out of sync with the server;
// reload to snap it back.
toast_error(&state.ui, failed, "The mount service didn't respond.");
share_dialog_reload(&state);
}
}
});
}
+32 -8
View File
@@ -18,7 +18,9 @@ use ksni::menu::StandardItem;
use ksni::{MenuItem, Tray, TrayService};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{Request, Response, TransferDirection, TransferItem, send};
use pdfs_core::control::{
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
};
use pdfs_core::service;
/// How often the tray re-polls the daemon to refresh its menu.
@@ -38,12 +40,18 @@ struct DriveState {
sync: String,
}
/// Summarise a transfer snapshot into one menu line, or empty when idle. A single
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth.
fn sync_line(items: &[TransferItem]) -> String {
/// 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".
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => String::new(),
[] => match jobs.first() {
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
Some(j) => format!("{}…", j.title),
None => String::new(),
},
[t] => {
let arrow = match t.direction {
TransferDirection::Download => "↓",
@@ -80,14 +88,24 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(Response::Status {
mountpoint, pinned, ..
mountpoint,
pinned,
online,
pending_uploads,
pending_changes,
..
}) => DriveState {
line: format!("Mounted at {mountpoint} ({pinned} pinned)"),
line: match (online, pending_summary(pending_uploads, pending_changes)) {
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
(false, Some(q)) => format!("Offline — {q}"),
},
mounted: true,
mountpoint: PathBuf::from(mountpoint),
// Same daemon is up, so a cheap follow-up poll gives the sync line.
sync: match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items }) => sync_line(&items),
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
},
},
@@ -252,6 +270,12 @@ fn main() -> Result<()> {
let _ = std::fs::remove_file(&tray_sock);
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
.context("failed to bind tray single-instance socket")?;
// Owner-only like the control socket (B6). This one only guards single
// instancing, so a failure is logged rather than fatal — but there is no
// reason to leave it reachable by other local users either.
if let Err(e) = pdfs_core::config::restrict_socket(&tray_sock) {
tracing::warn!(error = %e, "could not restrict tray socket permissions");
}
let socket = dirs.control_socket();
let default_mountpoint = dirs.default_mountpoint();
+376 -85
View File
@@ -1,11 +1,11 @@
//! `pdfs-prompt` — the launcher: one search field over both Proton Drive and the
//! files on this machine, styled after Google Drive's search overlay.
//!
//! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. It is
//! a short-lived process: it must draw *now*, so nothing here blocks the main
//! loop. Every daemon round-trip runs on a worker thread and lands back through
//! an async channel; the two searches (Drive and local) are fired in parallel and
//! each section renders as its own reply arrives.
//! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. The
//! application is single-instance and keeps its window alive between summons,
//! so repeat activations only reset and present the existing widget tree. Every
//! daemon round-trip runs on a worker thread and lands back through an async
//! channel. Drive and local lookup share one request so a result set paints once.
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
@@ -17,7 +17,12 @@ use adw::prelude::*;
use gtk4::{gio, glib};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{LocalHit, Request, Response, SearchHit, send};
use pdfs_core::control::{
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
mod activation;
use activation::{DriveActivation, drive_activation, mounted_path};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
@@ -60,6 +65,16 @@ impl Filter {
Filter::Media => !is_dir && is_media(name),
}
}
fn search_kind(self) -> SearchKind {
match self {
Self::All => SearchKind::All,
Self::Folders => SearchKind::Folders,
Self::Documents => SearchKind::Documents,
Self::Images => SearchKind::Images,
Self::Media => SearchKind::Media,
}
}
}
fn extension(name: &str) -> String {
@@ -173,6 +188,31 @@ impl Hit {
fn pinned(&self) -> bool {
matches!(self, Hit::Drive(h) if h.pinned)
}
fn score(&self) -> i64 {
match self {
Self::Drive(hit) => hit.score,
Self::Local(hit) => hit.score,
}
}
/// Stable identity used to preserve the keyboard cursor across re-renders.
/// The bool keeps a Drive and a local hit at the same path distinct.
fn key(&self) -> (bool, String) {
match self {
Hit::Drive(h) => (false, h.path.clone()),
Hit::Local(h) => (true, h.path.clone()),
}
}
}
fn rank_hits(hits: &mut [Hit]) {
hits.sort_by(|a, b| {
b.score()
.cmp(&a.score())
.then_with(|| a.name().cmp(b.name()))
.then_with(|| a.key().cmp(&b.key()))
});
}
/// Everything the window needs to render and act, in one place so the many
@@ -186,6 +226,7 @@ struct Ui {
stack: gtk4::Stack,
scroller: gtk4::ScrolledWindow,
results: gtk4::Box,
search_section: Section,
drive_section: Section,
local_section: Section,
placeholder: adw::StatusPage,
@@ -199,18 +240,32 @@ struct Ui {
/// then local ones, matching the visual order.
visible: RefCell<Vec<Hit>>,
cursor: Cell<Option<usize>>,
/// The trimmed query the currently-rendered rows belong to. Enter compares
/// against it so a keystroke that lands just before a fresher render can't
/// open a file from a result set the user has already typed past.
rendered_query: RefCell<String>,
filter: Cell<Filter>,
/// Monotonic query id. A reply whose id is stale (the user typed again while
/// it was in flight) is dropped instead of overwriting fresher results.
query_id: Cell<u64>,
/// In-flight searches for the current query id, so the spinner stops only
/// once both sections have answered.
/// In-flight requests for the current query id.
pending: Cell<u8>,
/// True once a Drive open is running: the window is about to close, so
/// further input is ignored.
opening: Cell<bool>,
indexing: Cell<bool>,
/// Set when Enter arrives before the in-flight query has rendered (typing
/// then hitting Enter faster than the search debounce). The open is honoured
/// against the selected row once the fresh results settle, rather than being
/// dropped — so a plain "type, Enter" always opens something.
open_pending: Cell<bool>,
/// Prevent lifecycle-driven entry resets from also scheduling the normal
/// debounced empty-query request; activation performs one explicit daemon
/// bootstrap instead.
suppress_entry_change: Cell<bool>,
/// The window, so a deferred open (above) can reach it from `render`.
window: RefCell<Option<adw::ApplicationWindow>>,
}
/// A titled group of rows: header (hidden when empty) plus its list.
@@ -281,17 +336,33 @@ fn main() -> glib::ExitCode {
.init();
let app = adw::Application::builder().application_id(APP_ID).build();
// GtkApplication normally remains active while it owns a window, including
// when that window is hidden. Hold it explicitly as well: residency is a
// product requirement here, not an incidental window-lifetime side effect.
let _resident = app.hold();
app.connect_startup(|_| load_css());
app.connect_activate(build_window);
let prompt: Rc<RefCell<Option<Rc<Ui>>>> = Rc::new(RefCell::new(None));
app.connect_activate(move |app| {
let ui = if let Some(ui) = prompt.borrow().clone() {
ui
} else {
let Some(ui) = build_window(app) else {
return;
};
*prompt.borrow_mut() = Some(ui.clone());
ui
};
ui.activate();
});
app.run()
}
fn build_window(app: &adw::Application) {
fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let dirs = match AppDirs::new() {
Ok(d) => d,
Err(e) => {
tracing::error!("cannot resolve app dirs: {e}");
return;
return None;
}
};
@@ -342,8 +413,10 @@ fn build_window(app: &adw::Application) {
let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
results.add_css_class("results");
let search_section = Section::new("Best matches", "system-search-symbolic");
let drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
let local_section = Section::new("This computer", "drive-harddisk-symbolic");
search_section.attach(&results);
drive_section.attach(&results);
local_section.attach(&results);
@@ -410,6 +483,7 @@ fn build_window(app: &adw::Application) {
stack,
scroller,
results,
search_section,
drive_section,
local_section,
placeholder,
@@ -419,12 +493,17 @@ fn build_window(app: &adw::Application) {
local_hits: RefCell::new(Vec::new()),
visible: RefCell::new(Vec::new()),
cursor: Cell::new(None),
rendered_query: RefCell::new(String::new()),
filter: Cell::new(Filter::All),
query_id: Cell::new(0),
pending: Cell::new(0),
opening: Cell::new(false),
indexing: Cell::new(false),
open_pending: Cell::new(false),
suppress_entry_change: Cell::new(false),
window: RefCell::new(None),
});
*ui.window.borrow_mut() = Some(window.clone());
for (filter, label) in FILTERS {
let chip = gtk4::ToggleButton::builder()
@@ -453,15 +532,17 @@ fn build_window(app: &adw::Application) {
(&ui.local_section.list, true),
] {
let ui_row = ui.clone();
let window_row = window.clone();
list.connect_row_activated(move |_, row| {
let offset = if is_local { ui_row.drive_count() } else { 0 };
ui_row.open(offset + row.index() as usize, &window_row);
ui_row.open(offset + row.index() as usize);
});
}
let ui_row = ui.clone();
ui.search_section
.list
.connect_row_activated(move |_, row| ui_row.open(row.index() as usize));
let ui_key = ui.clone();
let window_key = window.clone();
let keys = gtk4::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, state| {
if ui_key.opening.get() {
@@ -469,7 +550,13 @@ fn build_window(app: &adw::Application) {
}
match key {
gtk4::gdk::Key::Escape => {
window_key.close();
// Spotlight-style: first Escape clears a non-empty query, a
// second (or an already-empty box) dismisses the launcher.
if ui_key.entry.text().is_empty() {
ui_key.dismiss();
} else {
ui_key.entry.set_text("");
}
glib::Propagation::Stop
}
gtk4::gdk::Key::Down => {
@@ -485,12 +572,10 @@ fn build_window(app: &adw::Application) {
ui_key.cycle_filter(back);
glib::Propagation::Stop
}
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => {
if let Some(index) = ui_key.cursor.get() {
ui_key.open(index, &window_key);
}
glib::Propagation::Stop
}
// Return is deliberately absent: `GtkText` binds it to `activate`
// and consumes it, so a bubble-phase controller on the window never
// sees it while the entry has focus (which is always, here). It is
// handled on the entry's `activate` signal instead — see below.
_ => glib::Propagation::Proceed,
}
});
@@ -498,9 +583,50 @@ fn build_window(app: &adw::Application) {
// pointer has moved focus into it.
window.add_controller(keys);
// Enter opens the row under the cursor. This lives on the entry rather than
// with the other keys on the window because `GtkText` claims Return for its
// own `activate` binding, so a bubble-phase controller upstream of the
// focused entry is never reached. Enter with focus in the list is covered by
// `row_activated` above.
let ui_activate = ui.clone();
entry.connect_activate(move |_| {
if ui_activate.opening.get() {
return;
}
// Fall back to the top row when nothing is explicitly selected (e.g.
// results haven't rendered yet); `open` guards the rest.
ui_activate.open(ui_activate.cursor.get().unwrap_or(0));
});
// Some GTK/input-method combinations consume the physical Return binding in
// GtkText without emitting Entry::activate. Capture only Return ahead of the
// text widget and leave every other key to normal GTK/IME handling. Stopping
// propagation also prevents the activate signal above from opening twice;
// that signal remains the programmatic/accessibility activation path.
let ui_return = ui.clone();
let return_key = gtk4::EventControllerKey::new();
return_key.set_propagation_phase(gtk4::PropagationPhase::Capture);
return_key.connect_key_pressed(move |_, key, _, _| {
if matches!(key, gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter) {
if !ui_return.opening.get() {
ui_return.open(ui_return.cursor.get().unwrap_or(0));
}
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
entry.add_controller(return_key);
let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
let ui_changed = ui.clone();
entry.connect_changed(move |entry| {
if ui_changed.suppress_entry_change.get() {
return;
}
// A fresh keystroke supersedes any Enter that was waiting on results:
// the user is still refining the query, not asking to open yet.
ui_changed.open_pending.set(false);
if let Some(source) = debounce.borrow_mut().take() {
source.remove();
}
@@ -517,22 +643,59 @@ fn build_window(app: &adw::Application) {
let ui_retry = ui.clone();
retry.connect_clicked(move |_| ui_retry.connect());
// A launcher is modal by convention: losing focus dismisses it.
window.connect_is_active_notify(|w| {
if !w.is_active() {
w.close();
}
// Keep the widget tree alive when the compositor asks the window to close.
// This also avoids running GTK's destruction path while async replies still
// hold references to prompt widgets.
let ui_close = ui.clone();
window.connect_close_request(move |_| {
ui_close.dismiss();
glib::Propagation::Stop
});
let entry_map = entry.clone();
window.connect_map(move |_| {
entry_map.grab_focus();
});
ui.connect();
window.present();
Some(ui)
}
impl Ui {
/// Reset and present the resident prompt in response to GApplication
/// activation. A second invocation while a file is materialising merely
/// raises the progress window; it must not make the in-flight open mutable.
fn activate(self: &Rc<Self>) {
let Some(window) = self.window.borrow().clone() else {
return;
};
if !self.opening.get() {
// Supersede replies from the previous showing before clearing the
// query. The daemon work may finish, but it can no longer repaint
// the newly activated prompt with stale rows.
self.query_id.set(self.query_id.get() + 1);
self.pending.set(0);
self.open_pending.set(false);
self.spinner.stop();
self.spinner.set_visible(false);
self.suppress_entry_change.set(true);
self.entry.set_text("");
self.suppress_entry_change.set(false);
self.set_filter(Filter::All);
self.connect();
}
window.present();
self.entry.grab_focus();
}
/// Dismiss without destroying the application window. GTK continues to
/// associate the hidden window with the single application instance, so a
/// later desktop shortcut invocation is a cheap `activate` round-trip.
fn dismiss(&self) {
self.open_pending.set(false);
if let Some(window) = self.window.borrow().as_ref() {
window.set_visible(false);
}
}
/// Ask the daemon for status and, if it answers, load the suggested (pinned)
/// files. Runs off-thread: a dead daemon must not freeze the first frame.
fn connect(self: &Rc<Self>) {
@@ -556,6 +719,17 @@ impl Ui {
});
}
/// The daemon stopped answering after the window was already open. Flip to
/// the offline page and lock the entry; the retry button re-runs `connect`.
fn go_offline(self: &Rc<Self>) {
self.pending.set(0);
self.spinner.stop();
self.spinner.set_visible(false);
self.entry.set_sensitive(false);
self.hint.set_label("Daemon offline");
self.stack.set_visible_child_name("offline");
}
/// The empty-query view: pinned files, which are the ones the user chose to
/// keep on this machine — the closest thing the daemon has to "recent".
fn load_suggestions(self: &Rc<Self>) {
@@ -567,6 +741,10 @@ impl Ui {
if !ui.finish_query(id) {
return;
}
if matches!(reply, Ok(Err(_)) | Err(_)) {
ui.go_offline();
return;
}
let hits = match reply {
Ok(Ok(Response::Pins { pins })) => pins
.into_iter()
@@ -578,6 +756,8 @@ impl Ui {
modified: 0,
pinned: true,
uid: pin.uid,
mounted_path: None,
score: 0,
})
.collect(),
_ => Vec::new(),
@@ -588,57 +768,52 @@ impl Ui {
});
}
/// Fire both searches for `query` in parallel. An empty query falls back to
/// the suggestions view.
/// Search Drive and local metadata in one daemon round-trip. An empty query
/// falls back to the suggestions view.
fn search(self: &Rc<Self>, query: &str) {
if query.is_empty() {
self.load_suggestions();
return;
}
let id = self.begin_query(2);
let drive = spawn_request(
let id = self.begin_query(1);
let search = spawn_request(
self.socket.clone(),
Request::Search {
Request::SearchV2 {
query: query.to_string(),
limit: SEARCH_LIMIT,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: self.filter.get().search_kind(),
},
},
);
let ui = self.clone();
glib::spawn_future_local(async move {
let reply = drive.recv().await;
let reply = search.recv().await;
if !ui.finish_query(id) {
return;
}
match reply {
Ok(Ok(Response::SearchResults { hits })) => *ui.drive_hits.borrow_mut() = hits,
_ => ui.drive_hits.borrow_mut().clear(),
}
ui.render();
});
let local = spawn_request(
self.socket.clone(),
Request::SearchLocal {
query: query.to_string(),
limit: SEARCH_LIMIT,
},
);
let ui = self.clone();
glib::spawn_future_local(async move {
let reply = local.recv().await;
if !ui.finish_query(id) {
return;
}
match reply {
Ok(Ok(Response::LocalResults { hits, indexing })) => {
ui.indexing.set(indexing);
*ui.local_hits.borrow_mut() = hits;
Ok(Ok(Response::SearchResultsV2 {
drive_hits,
local_hits,
local_indexing,
})) => {
*ui.drive_hits.borrow_mut() = drive_hits;
*ui.local_hits.borrow_mut() = local_hits;
ui.indexing.set(local_indexing);
ui.render();
}
// Transport failure: the daemon is gone. Don't paint "No
// results" over a crash — say so.
Ok(Err(_)) | Err(_) => ui.go_offline(),
_ => {
ui.drive_hits.borrow_mut().clear();
ui.local_hits.borrow_mut().clear();
ui.render();
}
_ => ui.local_hits.borrow_mut().clear(),
}
ui.render();
});
}
@@ -677,7 +852,12 @@ impl Ui {
for (chip_filter, chip) in self.chips.borrow().iter() {
chip.set_active(*chip_filter == filter);
}
self.render();
let query = self.entry.text().trim().to_string();
if query.is_empty() {
self.render();
} else {
self.search(&query);
}
}
fn cycle_filter(self: &Rc<Self>, backwards: bool) {
@@ -702,12 +882,18 @@ impl Ui {
.count()
}
/// Rebuild both sections from the last replies under the active chip, then
/// restore the cursor to the top. Every state change funnels through here, so
/// the list, the placeholder, and the footer can never disagree.
/// Rebuild suggestions or the globally-ranked search section, preserving the
/// selected identity when a result survives the update.
fn render(self: &Rc<Self>) {
let filter = self.filter.get();
// Identity of the row under the cursor before we rebuild the list, so a
// late-arriving second search can't yank the selection back to the top.
let selected_key = self
.cursor
.get()
.and_then(|i| self.visible.borrow().get(i).map(Hit::key));
let drive: Vec<Hit> = self
.drive_hits
.borrow()
@@ -725,19 +911,32 @@ impl Ui {
.map(Hit::Local)
.collect();
let searching = !self.entry.text().trim().is_empty();
let drive_title = if searching {
"Proton Drive"
} else {
"Pinned in Proton Drive"
};
self.drive_section.set_rows(&drive, Some(drive_title));
self.local_section.set_rows(&local, None);
let query = self.entry.text().trim().to_string();
let searching = !query.is_empty();
let total = drive.len() + local.len();
let mut visible = drive;
visible.extend(local);
if searching {
rank_hits(&mut visible);
self.search_section.set_rows(&visible, Some("Best matches"));
self.drive_section.set_rows(&[], None);
self.local_section.set_rows(&[], None);
} else {
self.search_section.set_rows(&[], None);
let drive_count = visible
.iter()
.take_while(|hit| matches!(hit, Hit::Drive(_)))
.count();
self.drive_section
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive"));
self.local_section.set_rows(&visible[drive_count..], None);
}
// Find where the previously-selected row landed in the rebuilt list.
let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key));
*self.visible.borrow_mut() = visible;
*self.rendered_query.borrow_mut() = query;
if total == 0 {
self.placeholder.set_title(if searching {
@@ -757,10 +956,20 @@ impl Ui {
self.cursor.set(None);
} else {
self.stack.set_visible_child_name("results");
self.select(0);
self.select(restored.unwrap_or(0));
}
self.hint.set_label(&self.status_text(total, searching));
// Honour an Enter that arrived before this query rendered, now that the
// results (and the cursor) are settled. Only once both searches are in,
// so it lands on the final list, not a half-populated one.
if self.open_pending.get() && self.pending.get() == 0 {
self.open_pending.set(false);
if let Some(index) = self.cursor.get() {
self.open(index);
}
}
}
fn status_text(&self, total: usize, searching: bool) -> String {
@@ -795,6 +1004,19 @@ impl Ui {
/// section owns it, clear the other section's selection, and scroll it into
/// view. Focus stays in the entry so typing never breaks.
fn select(self: &Rc<Self>, index: usize) {
if !self.rendered_query.borrow().is_empty() {
self.drive_section.list.unselect_all();
self.local_section.list.unselect_all();
let Some(row) = self.search_section.list.row_at_index(index as i32) else {
return;
};
self.search_section.list.select_row(Some(&row));
self.cursor.set(Some(index));
self.scroll_into_view(&row);
return;
}
self.search_section.list.unselect_all();
let drive_count = self.drive_count();
let (list, other, local_index) = if index < drive_count {
(
@@ -841,10 +1063,18 @@ impl Ui {
/// straight to the desktop; a Drive hit must first be hydrated into the cache
/// by the daemon (`OpenFile`), which can take a moment — the window stays up,
/// with the spinner running, until the path comes back.
fn open(self: &Rc<Self>, index: usize, window: &adw::ApplicationWindow) {
fn open(self: &Rc<Self>, index: usize) {
if self.opening.get() {
return;
}
// The rows on screen may belong to an older query whose reply is still
// settling. Don't act on them — Enter must never launch a file the user
// has typed past. But don't drop the intent either: remember it and open
// the right row once the fresh results land (see `render`).
if self.entry.text().trim() != *self.rendered_query.borrow() {
self.open_pending.set(true);
return;
}
let Some(hit) = self.visible.borrow().get(index).cloned() else {
return;
};
@@ -852,15 +1082,36 @@ impl Ui {
match hit {
Hit::Local(local) => {
xdg_open(Path::new(&local.path));
window.close();
}
// A Drive folder exists in the mount — open it in the file manager
// rather than downloading anything.
Hit::Drive(drive) if drive.is_dir => {
xdg_open(&self.mountpoint.borrow().join(&drive.path));
window.close();
self.dismiss();
}
Hit::Drive(drive) => {
// Empty-query rows come from ListPins, whose `recursive` flag is
// pin policy rather than reliable node kind metadata. Every pin
// already has a mounted path, so activate that path directly;
// this handles non-recursively pinned folders without mistaking
// them for files and sending an invalid OpenFile request.
if self.rendered_query.borrow().is_empty() {
let path = drive.mounted_path.as_deref().map_or_else(
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
PathBuf::from,
);
xdg_open(&path);
self.dismiss();
return;
}
match drive_activation(&drive.name, drive.is_dir) {
DriveActivation::Folder | DriveActivation::MountedMedia => {
let path = drive.mounted_path.as_deref().map_or_else(
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
PathBuf::from,
);
xdg_open(&path);
self.dismiss();
return;
}
DriveActivation::Materialize => {}
}
self.opening.set(true);
self.spinner.set_visible(true);
self.spinner.start();
@@ -873,7 +1124,6 @@ impl Ui {
},
);
let ui = self.clone();
let window = window.clone();
glib::spawn_future_local(async move {
let reply = rx.recv().await;
ui.spinner.stop();
@@ -882,9 +1132,9 @@ impl Ui {
match reply {
Ok(Ok(Response::FilePath { path })) => {
xdg_open(Path::new(&path));
window.close();
ui.dismiss();
}
Ok(Ok(Response::Error { message })) => {
Ok(Ok(Response::Error { message, .. })) => {
ui.hint.set_label(&format!("Could not open: {message}"));
}
_ => ui.hint.set_label("Could not reach the daemon"),
@@ -1034,6 +1284,13 @@ fn format_age(epoch_secs: i64) -> String {
/// launcher follows the system light/dark theme instead of pinning its own.
fn load_css() {
let css = "
/* Pin the accent to Proton Purple so the HUD matches the main app,
which its own process/theme would otherwise leave at the system
default (often blue or orange). */
@define-color accent_bg_color #6d4aff;
@define-color accent_color #6d4aff;
@define-color accent_fg_color #ffffff;
window.launcher-window {
background: transparent;
}
@@ -1176,3 +1433,37 @@ fn load_css() {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranked_hits_merge_sources_on_one_score_scale() {
let mut hits = vec![
Hit::Drive(SearchHit {
name: "alpha.pdf".into(),
path: "Drive/alpha.pdf".into(),
is_dir: false,
size: 1,
modified: 1,
pinned: false,
uid: "v~a".into(),
mounted_path: None,
score: 100,
}),
Hit::Local(LocalHit {
name: "better.pdf".into(),
path: "/home/me/better.pdf".into(),
is_dir: false,
size: 1,
modified: 1,
score: 300,
}),
];
rank_hits(&mut hits);
assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf"));
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf"));
}
}
+346
View File
@@ -0,0 +1,346 @@
# Proton Drive Linux Client: Architecture Specification
This document provides a deep, comprehensive architectural description of the Proton Drive Linux Client (`pdfs`). It specifies the design patterns, data flows, thread models, and subsystem dependencies that govern the virtual filesystem (FUSE), database persistence, cache management, and two-way sync engine.
---
## 1. Subsystem Overview & Crate Topology
The application is modularized into four workspace crates, dividing core library logic, filesystem mounting, control-socket IPC, and front-ends.
```mermaid
graph TD
%% Crates
CLI["crates/pdfs-cli (CLI & Daemon Entrypoint)"]
GUI["crates/pdfs-gui (GTK Front-end)"]
FUSE["crates/pdfs-fuse (FUSE VFS & Sync Loop)"]
CORE["crates/pdfs-core (DB, Cache & IPC Protocol)"]
SDK["proton-sdk-rs (Proton Drive API & Cryptography)"]
%% Dependencies
CLI --> FUSE
GUI --> CORE
FUSE --> CORE
CORE --> SDK
CLI -.->|Unix Socket IPC| FUSE
GUI -.->|Unix Socket IPC| FUSE
```
### Crate Division & Responsibility Matrix
| Crate | Primary Role | Key Components | State Management |
|---|---|---|---|
| [`pdfs-core`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-core) | Core Infrastructure & Services | Cache Bookkeeping, Database migrations/schemas, IPC protocol payloads. | Holds the unified SQLite DB (`Db`) connection and the on-disk cache metadata (`ContentCache`). |
| [`pdfs-fuse`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-fuse) | VFS Layer & Reconciliation | FUSE callbacks, background upload queue (`drain`), two-way sync runner. | Manages in-memory inode maps (`State`), active descriptors (`WriteHandle`), and background task threads. |
| [`pdfs-cli`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-cli) | Command Line Interface | Command routing, daemon launcher, IPC client wrapper. | Stateless; communicates with daemon over IPC control socket. |
| [`pdfs-gui`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-gui) | Graphical Interface | GTK Page timelines (My files, Shared, Shared with me, Computers, Photos, Activity, Trash, Settings). | Stateless; polls daemon for status and lists timelines via IPC socket. |
---
## 2. In-Memory VFS State & File Operations
The VFS layer implements FUSE via the `fuser` crate. Because the remote storage contains base64-encoded file keys and requires cryptographic envelope parsing, raw listings and inodes are virtualized and stored in a local state directory.
### Inode and Path Resolution
* **In-Memory Cache (`State`):** Maps FUSE `u64` inodes to Proton Drive `NodeUid`s.
* **Database Row Mapping (`StoredNode`):** Stores directories, sizes, and timestamps.
* **On-Demand Loading (`ensure_children`):** If a directory is accessed, the daemon checks its database `listed` flag. If `listed = 0`, it triggers an API call to fetch remote nodes, populates the DB and in-memory caches, and returns.
```mermaid
sequenceDiagram
autonumber
actor User as Kernel (VFS Call)
participant FUSE as pdfs-fuse VFS
participant ST as State (In-Memory)
participant DB as Db (SQLite)
participant API as Proton API Client
User->>FUSE: lookup(parent_ino, "report.pdf")
FUSE->>ST: children.get(&parent_ino)
alt Parent listing is resident in-memory
ST-->>FUSE: returns child_ino
else Listing missing in-memory
FUSE->>DB: children_if_listed(parent_uid)
alt Parent marked listed in DB
DB-->>FUSE: returns child node metadata list
FUSE->>ST: intern_from_db() and populate children cache
else Parent not listed in DB
FUSE->>API: enumerate_folder_children_node_uids()
API-->>FUSE: list of UIDs
FUSE->>API: enumerate_nodes(uids)
API-->>FUSE: list of decrypted Nodes
FUSE->>DB: upsert_nodes() & set_listed(true)
FUSE->>ST: intern_batch() and populate children cache
end
end
FUSE-->>User: returns child inode metadata (attributes, TTL)
```
---
## 3. Read Path & Block Caching Pipeline
Read requests are parallelized and served in blocks of size `BLOCK_SIZE` (4 MiB).
* **Unpinned files (Streaming):** Avoids writing full files to disk to preserve storage. Instead, blocks are kept in a fixed-size `stream_ring` (in-memory ring cache) and evicted immediately.
* **Pinned files (Persistent):** Block downloads are saved directly to `ContentCache` on disk.
* **Read-Ahead:** The reader thread spawns asynchronous tasks to pre-fetch upcoming blocks.
```mermaid
sequenceDiagram
autonumber
actor Kernel as Kernel Read (offset, size)
participant FUSE as pdfs-fuse VFS
participant Cache as ContentCache (Local Disk)
participant Ring as StreamRing (In-Memory)
participant API as Proton API Client
Kernel->>FUSE: read(ino, fh, offset, size)
FUSE->>FUSE: calculate block indices [first_block..last_block]
loop For each block index
alt Block in disk Cache (Pinned/Cache hit)
FUSE->>Cache: read_block(block_idx)
Cache-->>FUSE: block bytes
else Block in stream ring cache
FUSE->>Ring: get(block_idx)
Ring-->>FUSE: block bytes
else Block Cache Miss
FUSE->>API: download_range(offset, len)
API-->>FUSE: decrypted block bytes
alt caching_enabled
FUSE->>Cache: store_block(block_idx)
else streaming_only
FUSE->>Ring: insert(block_idx)
end
end
end
FUSE->>FUSE: stitch blocks and slice to offset/size
FUSE-->>Kernel: return data buffer
```
---
## 4. Write Path & Staging/Draining Pipeline
Because Proton Drive does not support partial byte writes, modified files must be uploaded as whole new revisions.
1. **Staging writes (`WriteHandle`):** Writes are stored locally in a `scratch` file. The daemon tracks modified regions using `Intervals` (which holds ranges of edited bytes).
2. **Close/Release (`queue_revision`):** When the application closes the file descriptor, the daemon:
- Fetches any untouched gaps from the remote base file to compile the full file.
- Moves the scratch file to `staging` under a `{uid}-{millis}-{counter}` name, so a staged blob can be tied back to its node without consulting the database.
- Queues a pending database operation (`PendingOp`). For `OP_REVISION` ops, execution is debounced by 2 seconds (`DRAIN_REVISION_DEBOUNCE = 2s`) to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission.
3. **Async Drain Thread (`run_pending_drain`):** The background drain worker picks up the database operations queue, handles revision uploads, resolves conflicts, and cleans up staging files. Upon landing a revision upload (`refresh_after_upload`), it rebaselines both still-queued ops (`rebaseline_pending`) and open write handles targeting the same node to prevent false self-conflict copies on subsequent writes.
```mermaid
sequenceDiagram
autonumber
actor Kernel as Kernel Write (fh, offset, data)
participant FUSE as pdfs-fuse VFS
participant WH as WriteHandle (Scratch File)
participant DB as Db (SQLite Queue)
participant DR as Drain Thread
participant API as Proton API Client
Kernel->>FUSE: write(ino, fh, offset, data)
FUSE->>WH: write_at(offset, data)
FUSE->>WH: update written intervals
FUSE-->>Kernel: return bytes_written
Note over Kernel, FUSE: Application closes file (close(2))
Kernel->>FUSE: release(fh)
FUSE->>FUSE: fill_gaps() (fetch untouched remote ranges)
FUSE->>FUSE: move scratch file to staging directory
FUSE->>DB: enqueue_op(OP_REVISION, staged_path, meta)
FUSE->>FUSE: record_pending_write() (update size/mtime in memory & DB)
FUSE-->>Kernel: return success (async release)
Note over DB, DR: Background Queue Processing
DR->>DB: next_due_op()
DB-->>DR: return OP_REVISION
DR->>API: upload_new_revision_from(staged_path)
API-->>DR: return new node revision metadata
DR->>DB: delete_op()
DR->>FUSE: refresh_after_upload() (sync local metadata with server time)
```
---
## 5. Sync Engine (Two-Way Reconciliation)
The sync engine handles offline-capable, bidirectional synchronization between the local disk and Proton Drive for directories marked in `mirror` mode.
### Lifecycle of a Sync Pass
1. **Walk Local:** Walks the local directory tree recursively, scanning sizes and modification times.
2. **Walk Remote:** Walks the remote database representation. If remote file modification times are updated, it calls the API to decrypt their sizes.
3. **Load Baseline:** Loads the `sync_entry` database table, which contains the snapshot of both sides during the *last successful sync*.
4. **Permutation Diffing:** The loop compares the three states (`local`, `remote`, `baseline`) to classify items:
```mermaid
graph TD
%% States
Classify{"Classify (Local, Remote, Baseline)"}
%% Logic Rules
Classify -->|Both Sides Match| Match["No-Op (In Sync)"]
Classify -->|Local Changed, Remote Untouched| Upload["Upload Revision"]
Classify -->|Remote Changed, Local Untouched| Download["Download Revision"]
Classify -->|Both Sides Changed| Conflict["Conflict Copy (Local renamed to 'sync-conflict', remote downloaded)"]
Classify -->|Local Deleted, Remote Untouched| RemoteDelete["Trash Remote Node"]
Classify -->|Remote Deleted, Local Untouched| LocalDelete["Delete Local File"]
Classify -->|New Local File, No Remote/Baseline| UploadNew["Upload New Node"]
Classify -->|New Remote File, No Local/Baseline| DownloadNew["Download New Node"]
```
5. **Depth-Ascending Batching:** Folders are processed first to ensure hierarchies exist before files are placed. Work is executed concurrently up to a set limit.
6. **Post-Sync Settle:** On success, baseline entries are upserted, timestamps updated, and any pending mode switches (e.g. going on-demand) are evaluated.
---
## 6. IPC Socket Protocol
The CLI and GUI front-ends do not access database files or make network calls directly. They communicate with the background daemon process over a Unix domain socket.
* **Transport:** IPC over Unix Stream Socket.
* **Framing:** Line-delimited JSON payloads.
* **Control Protocol:**
* Client sends a single JSON line (`Request`).
* Daemon parses, handles the request, and replies with a single JSON line (`Response`).
* Timeout durations are separated: **2 seconds** for writes (avoids hangs on defunct sockets) and **120 seconds** for reads (accommodates heavy transfers).
---
## 7. Subsystem Interaction & Thread Map
The background daemon relies on the following thread topology:
1. **Main Thread / Dispatch Loop:** Blocks on `fuser::Session` loop. Reads kernel FUSE events and hands off network-bound VFS work to the FUSE workers pool.
2. **FUSE Workers Pool (11 threads, two lanes):** Bounded thread pool handling network operations. Split into 3 threads reserved for metadata (`lookup`, `readdir`) and 8 general threads that serve transfers (block reads) and fall back to metadata when no transfer is waiting. Reserved threads never accept a transfer — that is what keeps a directory listing from queuing behind saturated downloads (audit A6).
3. **IPC listener Thread:** Listens on Unix socket connections, spawning a lightweight task per connection to serve front-end status/configuration requests.
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
---
## 8. Threat Model: What This Client Writes to Disk in Plaintext
Proton Drive is zero-knowledge: the server never holds the keys to your content. That property ends at this daemon. Serving a remote file through a POSIX filesystem means producing plaintext, and serving it *quickly* means keeping some of that plaintext around. This section states exactly what lands on disk, because the guarantee users infer from "zero-knowledge" is stronger than the one a files-on-demand client can offer locally.
**The short version: the cache and state directories hold decrypted content and decrypted metadata, and this client assumes the disk underneath them is encrypted (LUKS, or an encrypted home).** On an unencrypted disk, an attacker with the powered-off machine can read cached file content and the full name/structure of your Drive without ever touching your password.
### 8.1 Decrypted content
Everything under `$XDG_CACHE_HOME/<app>/content/` is plaintext:
| Path | Holds | Lifetime |
|---|---|---|
| `<uid>` blobs | Whole decrypted files (pinned files, opened files) | Until LRU eviction or budget purge |
| `blocks/` | Decrypted 4 MiB block ranges of partially-read files | Until LRU eviction |
| `thumbs/` | Decrypted thumbnails and previews | Until LRU eviction |
| `scratch/` | In-progress writes from open file handles | Until `release`, or rescued at next open |
| `staging/` | Released writes awaiting upload | **Until the upload lands** |
| `recovery/` | `fsync`ed writes rescued from an unclean shutdown | **Until replayed into `staging/`** |
`staging/` and `recovery/` deserve separate attention: unlike the cache directories, they are not a copy of something the server already has. They hold user-authored content that may exist **nowhere else yet**, which is why they are deliberately never cleared on startup (§4, and audit A2). They are simultaneously the most sensitive thing on disk and the thing that must not be deleted to reclaim space.
### 8.2 Decrypted metadata
`$XDG_STATE_HOME/<app>/cache.db` is a plain SQLite database containing **decrypted node names**, the folder hierarchy, sizes, timestamps, a trigram full-text index over those names, the activity log, and the photos timeline. It is not evictable and not budgeted — it is the persistence layer the in-memory tree rehydrates from (§2).
Filename and directory-structure confidentiality is an explicit part of Proton Drive's model (each folder's manifest is encrypted server-side). This database is where that property is spent locally: it is a queryable, plaintext index of your entire Drive, and it survives cache purges. A `PurgeCache` clears content, not this.
### 8.3 What is *not* written in plaintext
Credentials. The session blob — access and refresh tokens, and the key material needed to resume unattended — lives only in the OS keyring via libsecret (`auth.rs`), never on disk in cleartext. `config.json` and `pins.json` hold settings and node uids, no secrets.
Note that the *control socket* is a credential of a different kind: anything that can connect to `control.sock` can drive the daemon — list and read paths, upload, trash, create share links — without touching the keyring at all. See §8.5.
### 8.4 Memory, swap, and the page cache
Two exposures this client does **not** currently mitigate, stated plainly rather than left implied:
- **Swap.** Content keys, session keys, and decrypted buffers live in ordinary heap memory. Nothing calls `mlock(2)`, so under memory pressure they may be paged out. Raising `LimitMEMLOCK` in the systemd unit would *not* change this — there is no locking to permit. The effective mitigation is encrypted swap (dm-crypt / `systemd-cryptsetup`), which is standard on a LUKS install.
- **Kernel page cache.** Plaintext returned through FUSE is cached by the kernel like any other file data, and is likewise swappable. Defeating this would mean `direct_io` on every read, forfeiting the readahead and caching that make the mount usable. The trade is taken deliberately in favour of performance.
### 8.5 File modes: currently inherited, not enforced
**The daemon does not set restrictive permissions on anything it creates.** Cache and state directories come from `create_dir_all`, and `control.sock` from a plain `UnixListener::bind` — all subject to the process umask, which on a typical desktop yields `0755` directories and a `0755` socket.
What protects them today is the mode of the XDG parents (`~/.cache`, `~/.local/state`), which are conventionally `0700` — but that is a property of the user's system, not something this client establishes or verifies. On a machine where the home directory is group- or world-traversable, the consequences differ by artifact:
| Artifact | Exposure if the parent is traversable |
|---|---|
| `content/` | Another local user can read cached plaintext file content |
| `cache.db` | Another local user can read the full decrypted name/structure index |
| `control.sock` | **Another local user can drive the daemon**: enumerate, read, upload, trash, create public share links |
The socket is the sharpest of the three, because it is an authority boundary rather than a data one — connecting to it confers the daemon's authenticated session without any credential.
**Fixed (bugs.md B6):** `AppDirs::ensure` now sets `0700` on the state, cache, and config directories on every start — not only at creation, so a directory that already exists with a permissive mode is tightened — and `config::restrict_socket` sets `0600` on both sockets immediately after `bind`. The directories are restricted *before* the socket is bound, which closes the window between `bind` and `chmod`. A control socket whose mode cannot be set takes the daemon down rather than serving unguarded.
See [RECOVERY.md](RECOVERY.md) for what a lost machine means for the plaintext described in this section, and what to revoke.
### 8.6 Implications for deployment
- Treat the cache and state directories as being as sensitive as the Drive contents themselves.
- Multi-user machines warrant checking `~/.cache` and `~/.local/state` are `0700` until §8.5 is addressed.
- Purging the cache (`pdfs` settings, or `PurgeCache` over IPC) removes content but **not** `cache.db`, and deliberately never removes undrained `staging/` or `recovery/` blobs.
---
## 9. Human Verification (CAPTCHA) Flow
When logging in from an unfamiliar IP address or VPN, the Proton API may gate the sign-in with a human verification challenge (CAPTCHA). This client handles this asynchronously and interactively.
### 9.1 Sequence of Verification and Re-Authentication
```mermaid
sequenceDiagram
autonumber
actor User as User (GUI)
participant Core as pdfs-core (Auth)
participant UI as pdfs-gui (Login Page)
participant Web as WebKitWebView Dialog
participant API as Proton API Server
User->>UI: Enter credentials & click Sign In
UI->>Core: login_interactive()
Core->>API: auth/v4 (Initial SRP Handshake)
API-->>Core: HTTP 422 (Error 9001: CAPTCHA Challenge URL)
Core->>UI: Error::HumanVerificationRequired(hv)
UI->>UI: Block login thread, dispatch to GTK main loop
UI->>Web: Create dialog & load verification URL
Note over User, Web: User completes CAPTCHA in embedded WebView
Web->>UI: window.postMessage(HUMAN_VERIFICATION_SUCCESS)
UI->>UI: Extract token (with double-serialization safety)
UI->>UI: Close dialog & send token to blocked login thread
UI->>Core: login_verified(with verification token)
Core->>API: auth/v4 (SRP retry with x-pm-human-verification-token)
API-->>Core: Returns session tokens
Core-->>UI: Login Successful
```
### 9.2 Key Technical Design Decisions
1. **Weak Reference UI Binding:** To prevent memory leaks and strong reference cycles between the parent dialog, the child `WebKitWebView`, the script message manager, and the connection callback, the dialog is downgraded to a `WeakRef` inside the callback:
```rust
let dlg_weak = dialog.downgrade();
content.connect_script_message_received(Some("hv"), move |_, value| {
// ...
if let Some(dlg) = dlg_weak.upgrade() {
dlg.close();
}
});
```
2. **Double-Serialization Tolerance:** The JavaScript message listener forwards event data as a JSON string to the native handler. Since the underlying page may post either JS objects or pre-serialized JSON strings, the Rust side performs dual-phase parsing:
```rust
let mut value = serde_json::from_str(raw).ok()?;
if let Some(inner) = value.as_str() {
if let Ok(parsed) = serde_json::from_str(inner) {
value = parsed;
}
}
```
This ensures compatibility with all versions of Proton's client verification scripts.
3. **SRP Handshake Reset:** Because a gated login burns the SRP handshake on the API side, the client cannot simply resume the previous request. Instead, `auth::login_interactive` restarts the SRP process from scratch with the verification credentials attached, keeping the complex handshake details isolated from the front-end.
4. **CLI Fallback:** Since the CLI has no native web browser engine, hitting the CAPTCHA gate fails immediately with a user-friendly message directing the user to sign in once via the GUI (`pdfs-app`) to persist the authenticated session keys to the system keyring.
+1631
View File
File diff suppressed because it is too large. Load diff
+130
View File
@@ -0,0 +1,130 @@
# Pending Development & Verification Tasks
This document compiles all pending, deferred, and unstarted tasks from the various roadmap, audit, and bug-tracking markdown files in the repository. Completed items have been filtered out.
---
## 1. Core Feature Roadmap (Horizon Tasks)
Derived from: [features.md](file:///home/narl/dev/private/proton-drive-linux/features.md) & [features-plan.md](file:///home/narl/dev/private/proton-drive-linux/features-plan.md)
* **Local Cache & Metadata Encryption at Rest (Priority 5 / Horizon 1)**: Encrypt the SQLite database `cache.db` on disk using SQLCipher (`rusqlite` feature `bundled-sqlcipher`), and encrypt raw content cache blocks (`content/blocks/` and `content/scratch/`) using a fast symmetric scheme like AES-GCM or ChaCha20-Poly1305. The encryption key should be derived from the OS Keyring.
* **Active Bandwidth Throttling & Traffic Shaping (Horizon 1)**: Add speed governors for daemon uploads and downloads. Support configuration variables `max_upload_rate_kbps` and `max_download_rate_kbps` in `config.json` that can be dynamically adjusted over the Unix control socket.
* **Interactive & Policy-Based Conflict Resolution (Horizon 1)**: Implement configurable conflict resolution policies (`rename-local`, `rename-remote`, `prefer-local`, `prefer-remote`, `interactive`). Add a "Conflicts" tab to the GUI to notify the user, block sync, and launch external visual diff tools (like Meld or KDiff3).
* **Multi-Account / Multi-Profile Support (Horizon 3)**: Enable running multiple profiles concurrently. Support separate configuration namespaces (e.g. `profiles/<profile_name>/`), independent SQLite databases, distinct mount paths (e.g. `~/ProtonDrive/Personal` and `~/ProtonDrive/Work`), and multiple daemon processes.
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Extract and display Exif metadata (camera model, exposure, coordinates) from photos locally, support creating and editing Proton Photo Albums, build a date-based timeline scrollbar, and create an auto-upload Pictures folder pipeline.
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
* **Interactive Terminal UI (TUI) Mode (Horizon 3)**: Implement `pdfs tui` using the `ratatui` crate to show transfer speed, sync status, active transfer queue with progress bars, and scrolling daemon logs.
* **Shutdown Safety & Write Queue Visibility (Horizon 1 / P4)**: Register systemd inhibitor locks in `pdfs-fuse` when there are outstanding staging writes to prevent shutdown or sleep, pop up warnings when exiting the GUI, and build a blocking `pdfs sync flush` command.
* **Block-Level Deduplication (Horizon 3)**: Transition to a Content-Addressable block cache where cached blocks are stored on disk by their SHA-256 hash. Map logical ranges in the database: `(node_uid, block_idx) -> block_hash` to avoid storing or downloading identical blocks.
* **Pre-emptive Sync Debouncing & File System Events (Horizon 3)**: Group filesystem write events on paths and delay sync queue insertion until a quiet period has elapsed (e.g. 5 seconds of inactivity) to prevent thrashing the Proton API.
* **File Version History (Horizon 2)**: Expose past file revisions and version history inside the GUI browser tabs.
---
## 2. Performance, Correctness & Robustness Items
Derived from: [plan.md](file:///home/narl/dev/private/proton-drive-linux/plan.md), [improvements.md](file:///home/narl/dev/private/proton-drive-linux/improvements.md), & [photos-video-streaming.md](file:///home/narl/dev/private/proton-drive-linux/photos-video-streaming.md)
* **B6. Debounce the LRU access touch**: Keep last-touch times in memory and flush them in batches (debounce window of 30-60 seconds) to avoid performing a SQLite `UPDATE` to the `cache_entries` table on every cache hit.
* **E12. Client in-flight cap shape**: Address the lock discrepancy between `MAX_CONCURRENT_BLOCK_DOWNLOADS = 10` (per-file) and `DEFAULT_MAX_INFLIGHT_BLOCKS = 12` (global client-wide). Either raise the global cap, lower the per-file window, or dynamically calculate the window to prevent single files from starving concurrent transfers.
* **Pipeline `enumerate_nodes_detail` (P2.8 / 8)**: Introduce pipelined node enumeration to speed up directory listings.
* **Local ancestor-chain walk (P3.4 / 4)**: Optimize recursive path lookups where full batching is impossible.
* **Name Search for Photos/Videos**: Add search by photo/video name (deferred from Phase 3 gallery implementation).
* **Sequential-Read Prefetch for Media Streaming**: Implement sequential read prefetch for media files to avoid stalls when buffering.
* **Minor sweep items (Phase F)**:
* **F1. `StreamRing.tags` Leak**: Clean up tag entries in `StreamRing.tags` when blocks are evicted. Currently, it only shrinks on `drop_node` (revision mismatch), leading to tag accumulation.
* **F2. Non-saturating bytes subtraction**: Use `saturating_sub` in `lib.rs:382` (`self.bytes -= dropped.len()`) to prevent release mode panics/wrapping if accounting drifts.
* **F3. Short block yields a short read / EOF**: Address the kernel EOF interpretation on short block reads. Either pad the block or fail loudly rather than silently serving short data.
* **F4. `stream_readahead` check-then-spawn race**: Prevent duplicate concurrent readahead tasks for the same block. Check-then-spawn is currently racey.
* **F5. `refresh_blocks` permit lock (SDK)**: Ensure URL refreshing (`refresh_blocks`) does not hold an in-flight block permit, preventing expired URLs from pinning client permits.
* **F6. Uncapped Unix control socket thread spawns**: Replace the unbounded `std::thread::spawn` per accepted socket connection in `run_control_socket` with a bounded pool or handler limits to prevent thread exhaustion.
* **F7. Document temporary cache file safety**: Add explanatory comment to `cache.rs:362` explaining why `with_extension("tmp")` is safe there but not in `store_thumbnail`.
---
## 3. Open & Unverified Bugs
Derived from: [docs/bugs.md](file:///home/narl/dev/private/proton-drive-linux/docs/bugs.md)
* **B2. Unattributed Trash Origin**: Investigate why deleted/moved files are occasionally sent to trash instead of vanishing on older rename operations.
* **B10. GLib Critical Warning on HUD Close**: Assertion failure `g_list_store_remove: assertion '!g_sequence_iter_is_end (it)' failed` when prompt window closes during active launch (`xdg-open`). Needs investigation under `G_DEBUG=fatal-criticals`.
* **B15. Empty-but-listed folders and duplicate UIDs**: Investigate folder-identity issues (e.g. duplicate remote folders like `Music` with different UIDs) and how folders obtain `listed = 1` in the database without matching child rows.
* **B8 (CAPTCHA sign-in bridge)**: Verify a real gated sign-in using Webkit CAPTCHA completion.
* **Draft Revision Upload loop**: Verify SDK-side deletion and retry logic when a conflicting draft revision exists under the same client UID.
* **Optimistic Size Loss on Restart**: Verify that pending uploads retain their optimistic sizes across a daemon restart.
---
## 4. Testing, Verification & Disaster Recovery Drills
Derived from: [testing.md](file:///home/narl/dev/private/proton-drive-linux/testing.md) & [docs/RECOVERY.md](file:///home/narl/dev/private/proton-drive-linux/docs/RECOVERY.md)
* **IPC Memory and Performance Stress Tests (Phase 3)**: Verify memory allocations do not balloon when sending large `UploadPhoto` JSON payloads. Test control socket timeout behaviors under long-running block operations.
* **Fault Injection and Resiliency Tests (Phase 4)**:
* **Network degradation**: Verify that interrupted uploads leave readable staging buffers and result in clean backoff retries.
* **Disk full (ENOSPC)**: Verify that write staging and gap-filling report correct errors to FUSE and preserve staged work.
* **Concurrent reader starvation**: Test that parallel sequential reads do not freeze directory lookups or metadata handlers.
* **Disaster Recovery Drill (P5 / Phase 5)**: Execute a complete manual disaster recovery test:
1. Register a device.
2. Sync a folder and upload data.
3. Wipe the local state directory and SQLite cache.
4. Restart the daemon.
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
---
## 5. Fixed & Resolved Bugs
Derived from: [docs/bugs.md](file:///home/narl/dev/private/proton-drive-linux/docs/bugs.md)
### Completely Fixed & Verified
* **B1 — FUSE rename loses the file (data loss)**: In `rename`, the node's database row was deleted while only updating the in-memory state, causing the file to vanish on next listing sync. Fixed by correctly calling `st.invalidate_listing(newparent)` to refresh SQLite cache states.
* **B4 — `invalidate_listing` silently skipped non-resident folders**: Folders not loaded in memory were being skipped during listing invalidation, leaving stale cache flags in the DB. Fixed by removing the residency check guard.
* **B5 — `ls -l` costs a network round trip per file (thumbnail xattr probes)**: File listings triggered blocking network checks for thumbnails on unsupported files. Fixed by caching negative thumbnail results and restricting xattr advertisement to supported image/media files.
* **B6 — Daemon sets no file modes: `control.sock` is an unguarded authority**: The Unix control socket was created without restricting file permissions, exposing daemon controls to other local users. Fixed by applying proper creation permission masks.
* **B7 — renamed directory reads as missing until the entry TTL expires**: Kernel cache maps remained stale after a FUSE directory re-anchor. Fixed by proactively sending entry invalidation notifications to the kernel.
* **B9 — Enter did not open the selected result in the launcher**: Hooked up keyboard execution (Enter key) in `pdfs-prompt` search HUD to launch the selected item using `xdg-open`.
* **B11 — a file moved while its create is still queued reads as empty**: Reconciling a local rename on a file that had not finished uploading yet uploaded empty blocks. Fixed by carrying over and staging the correct logical file size.
* **B12 — cold enumeration is slow per entry, and goes superlinear past ~500**: S2K decryption and thumbnail resolution loops were serial. Fixed by parallelizing key derivations and caching metadata.
* **B13 — `rename` over an existing destination fails instead of replacing it**: Fixed POSIX rename target overwrite behavior to replace existing destinations.
* **B14 — provisional (ciphertext) sizes make rsync read short and abort the file**: Adjusted size reporting to align logical and ciphertext sizing, preventing premature EOF.
### Code Fixed / Implemented (Pending Verification)
* **B8 — no way to complete a CAPTCHA, so a gated sign-in is unrecoverable**: Implemented a Webkit-based bridge to prompt the user to resolve interactive CAPTCHAs during gated logins. (Needs verification on a real gate request).
### Closed / Not a Bug
* **B3 — Activity log timestamps written in seconds, read as milliseconds**: Closed as investigator error (timestamps are consistent).
---
## 6. Recently Implemented Features
### Open-for-Write Deferral for Mirror Sync (Implemented)
**File**: [`sync.rs`](file:///home/narl/dev/private/proton-drive-linux/crates/pdfs-fuse/src/sync.rs)
The mirror folder sync engine now defers uploading any file that is currently held open for writing by another process, matching the guarantee the FUSE mount path already provides (where uploads are deferred until `close(fd)`).
**Problem**: Previously, the mirror sync path relied solely on a 2-second trailing-edge debounce (with a 30-second ceiling) to avoid uploading files mid-write. This was insufficient for slow continuous writers (e.g., database dumps, large exports, or editors that keep files open for extended periods), which could have their incomplete state uploaded as a real revision.
**Solution**: Before each reconcile pass, the sync engine scans `/proc/*/fd` once to build a set of canonical paths within the sync root that any process holds open for writing (`O_WRONLY` or `O_RDWR`). Files in this set are:
- **Kept in the local walk** — so they are not misclassified as deletions
- **Treated as unchanged** — so no upload is queued for them
- **Counted as `deferred`** — so the activity summary reports them (e.g., "3 uploaded, 1 deferred (open for write)")
- **Picked up on the next pass** — after the writer has closed the file
**How it works**:
1. `open_for_write_set(root)` reads `/proc/*/fd` → `readlink` for each fd → prefix-checks against the sync root → reads `/proc/<pid>/fdinfo/<n>` to check the `flags:` line's low two bits (O_ACCMODE)
2. `walk_local` sets `LocalItem.open_for_write = true` for matching files
3. Both `do_reconcile` and `push_pass` skip upload classification for flagged files
4. `Outcome.deferred` tracks the count for the activity summary
**Performance**: The `/proc` scan completes in low single-digit milliseconds on a typical desktop. Only fds whose `readlink` target falls under the sync root incur the `fdinfo` read.
**Tests added**: 8 unit tests covering `is_write_mode` parsing of various fdinfo flag combinations, and `Outcome` summary formatting with deferred counts.
| Sync Path | Open-for-write protection | Mechanism |
|---|---|---|
| FUSE mount | ✅ Perfect | Upload deferred until `close(fd)` |
| Mirror folders | ✅ Perfect | `/proc/*/fd` scan per reconcile pass |
+262
View File
@@ -0,0 +1,262 @@
# Recovery: Getting a Replacement Machine Back onto Your Device
You lose the laptop. You install `pdfs` on a new one. What does it take to get the
synced folders back?
This document is two things: **§2 is the runbook that works with the client as it
stands today**, gotchas included, and **§3 is the plan to reduce that runbook to
one command.** §1 is the part neither can fix — what actually died with the
machine.
---
## 1. What survives, and what does not
### Survives (it is on Drive, or derivable)
| Thing | Where it lives |
|---|---|
| Every file that finished uploading | Proton Drive |
| The Device registration itself | Proton Drive (`enumerate_devices`) |
| The device's folder tree — one folder per synced folder | Proton Drive, under the device root |
| Your account, keys, sharing | Proton |
### Does not survive
| Thing | Why | Recoverable? |
|---|---|---|
| Undrained `staging/` + `recovery/` blobs | Writes the kernel accepted but that never reached Drive. They existed only on that disk. | **No.** |
| Local-only files in a mirror folder that had not uploaded yet | Same. | **No.** |
| `sync_folder` rows — which local path maps to which remote folder, and in which mode | Local SQLite (`cache.db`) | Rebuildable by hand (§2), not automatically (§3) |
| `sync_entry` baseline | Local SQLite | Not needed. An empty baseline reconciles correctly — see §2.4. |
| Pins | Local SQLite | Rebuildable by hand |
| `config.json` — mountpoint, cache budget | Local config dir | Rebuildable by hand |
| `cache.db` node/FTS/photo caches | Local SQLite | Pure cache; rebuilds itself |
The first two rows are the only genuinely unrecoverable ones, and today nothing
warns you they are non-empty before you walk away from a machine. That is tracked
separately from this document as a pending-writes visibility gap.
### Before anything else: revoke
The lost machine holds a decrypted content cache, a plaintext index of every node
name in your Drive (`cache.db`), and a live session in its keyring. Whether that
matters depends on whether its disk was encrypted — but the session does not.
**Revoke the session from your Proton account settings before restoring.** Nothing
in this client can do that for you; the daemon on the lost machine holds
credentials it will happily keep refreshing.
If you have also lost your password, Proton's recovery phrase is what determines
whether the data is reachable at all. That is upstream of this client entirely: if
zero-knowledge encryption worked as advertised, nobody at Proton can help.
---
## 2. The runbook that works today
### 2.1 Install, log in
```bash
pdfs login # stores the session in the keyring
systemctl --user start proton-drive # or let autostart do it
```
### 2.2 Re-adopt the device — check this before anything else
`Core::ensure_device` recovers an existing device by **matching a Linux device
whose name equals this machine's hostname**, read from
`/proc/sys/kernel/hostname`. So:
```bash
hostnamectl hostname # what this machine will register as
pdfs devices list # what the account already has
```
- **Hostnames match** → the new machine silently re-adopts the old device and its
root folder. This is the happy path, and it is why the rest of the runbook works.
- **Hostnames differ** → `pdfs` registers a **second device**. Nothing errors.
Your old device and all its folders are still there, but this machine is not
attached to them, and every step below will quietly create empty folders under
the new device instead.
> **The single most important step in this document:** if the hostnames differ,
> set the new machine's hostname to match the old device's name *before* adding
> any sync folders.
> ```bash
> sudo hostnamectl set-hostname OLD-DEVICE-NAME
> ```
> There is no supported way to adopt a device by uid yet — see §3.1.
### 2.3 Find out which folders the device had
```bash
pdfs devices list # confirm the device and note its name
```
The device's synced folders are the folders directly under its root in Drive.
Browse them in the GUI's Computers page, or in the Proton Drive web app under
that device. **Write down their exact names** — you need them in the next step.
### 2.4 Re-attach each folder
For each folder name you noted, create the local directory and add it:
```bash
mkdir -p ~/Documents
pdfs sync add ~/Documents
```
`add_sync_folder` looks for a folder under the device root named after the local
directory's **basename**, and reuses it if found rather than creating a second
one. So `~/Documents` re-attaches to the existing `Documents`. With an empty
baseline and an empty local directory, the first reconcile classifies every remote
path as `(None, Some(remote))` with no baseline → **download**, and the folder
refills from Drive.
Three things to be careful about:
- **The basename must match exactly.** Restoring to `~/docs` when the device
folder is `Documents` does not re-attach — it creates a *new*, empty `docs`
folder in Drive and syncs nothing. The failure is silent; you just see an empty
directory. Check with `pdfs sync list` and the Computers page.
- **Add the folder empty, or accept conflict copies.** If the local directory
already has files (restored from a backup, say), there is no baseline to say
which side is newer, so both sides read as changed and the reconcile takes the
conflict arm: your local file is renamed to a `(sync-conflict <ts>)` copy and
the remote version is downloaded. Nothing is lost, but it is noisy. Restoring
into an empty directory avoids it.
- **A wholly empty local tree is now safe.** Before the audit fixes, adding a
folder whose local side was empty against a populated remote could, on a later
pass, be read as "the user deleted everything" and trash the lot. `sync.rs`'s
`guard_local_wipe` now refuses any pass where every baseline path has vanished
locally, which is exactly the restore shape.
### 2.5 Restore modes, pins, and settings by hand
```bash
pdfs sync list # note the ids
pdfs sync mode <id> ondemand # for folders that were on-demand
```
Nothing records which folders were `ondemand` before, so this is from memory. Pins
and the cache budget/mountpoint likewise: set them again from the Settings page.
### 2.6 Clean up
If a duplicate device got created before you noticed the hostname mismatch:
```bash
pdfs devices list
pdfs devices rm <uid-of-the-stray-device>
```
Check what is under it in the web app first. Deleting a device you actually
attached folders to will take its folders with it.
---
## 3. The plan: make this one command
The pieces all exist — device enumeration, folder-name reuse, an empty-baseline
reconcile that downloads correctly. What is missing is that the restore is
**implicit, name-based, and undiscoverable**. Each phase below removes one of
those properties. They are independent and land in this order by value.
### P1 — Adopt a device explicitly, by uid
**Problem:** device identity is inferred from the hostname. A new laptop with a
new name silently forks a second device, and the failure looks like "my files
didn't come back" rather than an error.
- Add `Request::AdoptDevice { uid }`: validate the uid against
`enumerate_devices`, write the `StoredDevice` row, and refuse if any
`sync_folder` rows already reference a different device.
- `pdfs devices adopt <uid>`, and an "Adopt this device" action on rows in the
GUI's Computers page that are not the current machine.
- Make the fork *visible* rather than silent: when `ensure_device` is about to
create a device and the account already has other Linux devices, log a warning
naming them, and surface it in `pdfs status`.
Keep hostname matching as the default. It is right for the common case; it just
must not be the *only* way.
### P2 — Discover and re-attach the device's folders
**Problem:** §2.3 and §2.4 are "remember what you had, then retype it exactly."
- Add `Request::ListDeviceFolders { device_uid }` — enumerate the device root's
child folders and report name, node uid, child count, and total size. This is
the "what did this machine sync?" answer that today requires the web app.
- Add `Request::AttachSyncFolder { remote_uid, local_path, mode }` — the
re-attach counterpart to `AddSyncFolder`. It binds a local path to a **specific
remote uid** rather than resolving by basename, which removes the silent
`~/docs` vs `Documents` failure entirely.
- CLI: `pdfs sync restore [--device <uid>] [--into <dir>]` — list the device's
folders, propose `<dir>/<name>` for each (default `$HOME`), and attach the ones
the user confirms.
- GUI: a "Restore folders from this device" flow on the Computers page, with a
per-folder local-path picker defaulting to `~/<name>`.
Refuse to attach into a non-empty directory without an explicit
`--allow-nonempty`, so the conflict-copy noise of §2.4 is opt-in rather than a
surprise.
### P3 — Back the profile up to Drive
**Problem:** modes, pins, mountpoint, and cache budget exist only on the dead
machine, so even a perfect P2 restores folders in the wrong mode with no pins.
Write a small JSON document into a hidden folder in the user's own Drive — say
`.proton-drive-linux/profile.json` — containing, per device:
- sync folders: remote uid, the local path last used, and mode
- pinned node uids
- mountpoint and cache budget
It rides on Drive's own encryption, so this introduces no new crypto and no new
place for plaintext to sit. Rewrite it whenever any of those change, debounced.
On a fresh install, after login: *"Found settings from `<device>`, last updated
`<date>`. Restore?"* Node uids are stable across machines, so pins restore
exactly. Local paths need remapping when the username or home differs — show the
old path, default the new one to `$HOME/<basename>`.
Store the last-used local path only as a *hint*. Restoring a profile must never
write to a path the user has not confirmed in this session.
### P4 — Make the unrecoverable visible
Nothing above helps with §1's undrained writes, because by then it is too late.
The mitigation is to make the pending queue impossible to miss *before* the
machine is lost:
- pending count and bytes in `pdfs status`, using the existing `pending` map and
`TransferRegistry`
- a tray icon state and a GUI banner while the queue is non-empty
- a warning on logout or shutdown while it is non-empty
### P5 — Verification
None of the above is trustworthy without exercising the actual shape of the
disaster. The end-to-end test is: register a device, sync a folder, upload
content, **delete the local state directory and cache entirely** (simulating the
lost machine), restart, restore, and assert the tree comes back byte-identical.
That is a two-account or at least two-state-dir integration test against a live
account, not a unit test. Until the mock-backend problem described in
`audit-plan.md` is solved, it stays manual — but it should be run, and its result
recorded here, before P1–P3 are called done.
---
## 4. Current status
| Phase | State |
|---|---|
| Manual runbook (§2) | Works, with the hostname and basename caveats |
| P1 explicit adoption | Not started |
| P2 discover + attach | Not started |
| P3 profile on Drive | Not started |
| P4 pending visibility | Not started |
| P5 restore drill | Never run |
+55
View File
@@ -0,0 +1,55 @@
# Testing
The normal Rust suite exercises the metadata database, sync planner, offline
queue, write staging, cache, and FUSE handler state without requiring a Proton
account:
```console
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
```
## Live FUSE acceptance suite
Kernel behavior and remote convergence need a real mount. The live suite runs
destructive POSIX operations only below a fresh `pdfs-acceptance-*` directory.
It refuses paths that are not FUSE mounts and removes its directory on success,
failure, or interruption.
Use a dedicated test account without irreplaceable data:
```console
scripts/fuse-acceptance.sh /mnt/on-demand-testmount
```
Passing more paths runs the same filesystem suite independently against each.
The first path must be the FUSE mount under test; later paths may be another
FUSE mount or a normal local mirror filesystem:
```console
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
scripts/fuse-acceptance.sh /mnt/on-demand-testmount /mnt/testmount
```
If two paths really are views of the **same remote folder**, enable an
additional byte-for-byte convergence check explicitly:
```console
PDFS_ACCEPTANCE_CONVERGENCE=1 \
scripts/fuse-acceptance.sh /mnt/first-view /mnt/second-view
```
Do not enable that flag merely because two sync folders belong to the same
account. Their configured remote UIDs must be identical.
The suite covers create/read/rewrite/truncate, multi-megabyte binary content,
unaligned random writes, rename replacement, cross-directory moves, important
POSIX error cases, open-then-unlink behavior, concurrent writers, directory
enumeration, and optional cross-mount byte-for-byte convergence. A pass is
strong evidence for those paths, not a blanket data-loss guarantee. Before a
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
`docs/BUGS.md` for live-verification items.
The runner accepts already-mounted paths instead of starting a daemon. This
keeps authentication and keyring setup explicit and prevents it from silently
reusing or replacing the normal application state directory.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 46 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 170 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+2 -2
View File
@@ -9,13 +9,13 @@
# Build + install: cd packaging && makepkg -fi
pkgname=proton-drive-linux
pkgver=0.3.0
pkgver=0.7.1
pkgrel=1
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
arch=('x86_64')
url="https://github.com/narl/proton-drive-linux"
license=('MIT')
depends=('fuse3' 'gtk4' 'libadwaita')
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0')
makedepends=('cargo' 'git')
provides=('pdfs')
# Files-on-demand mount + keyring need these at runtime.
+91
View File
@@ -0,0 +1,91 @@
# Local in-tree RPM (mirrors packaging/PKGBUILD).
# From the repository root:
# rpmbuild -bb packaging/proton-drive-linux.spec --define "git_dir $PWD" ...
# %build needs network so cargo can fetch crates.io (same trade-off as the Arch PKGBUILD).
%global debug_package %{nil}
# Match Arch PKGBUILD `!lto` — LTO has broken some GTK/Rust links in practice.
%global _lto_cflags %{nil}
%{!?version: %global version 0.4.0}
Name: proton-drive-linux
Version: %{version}
Release: 1%{?dist}
Summary: Proton Drive client for Linux (FUSE, CLI, GTK4 app + tray)
License: MIT
URL: https://github.com/narl/proton-drive-linux
ExclusiveArch: x86_64
BuildRequires: cargo
BuildRequires: rust
BuildRequires: pkgconf-pkg-config
BuildRequires: fuse3-devel
BuildRequires: gtk4-devel
BuildRequires: libadwaita-devel
BuildRequires: libsecret-devel
BuildRequires: dbus-devel
BuildRequires: glib2-devel
BuildRequires: webkitgtk6.0-devel
Requires: fuse3
Requires: gtk4
Requires: libadwaita
Requires: libsecret
Requires: webkitgtk6.0
Requires: xdg-utils
# DE-specific; do not Require a single desktop environment.
Recommends: gnome-keyring
Recommends: gnome-shell-extension-appindicator
Recommends: kwallet
Provides: pdfs = %{version}-%{release}
%description
Unofficial Proton Drive client featuring a FUSE files-on-demand mount,
CLI, GTK4/Libadwaita GUI, system tray, and search launcher.
%prep
# In-tree build: no Source tarball. Pass --define "git_dir /path/to/checkout".
test -n "%{?git_dir}" || (echo 'Pass --define "git_dir $PWD" from the repo root' >&2; exit 1)
test -f %{git_dir}/Cargo.toml
cp -a %{git_dir}/LICENSE .
%build
cd %{git_dir}
cargo build --release --locked \
--bin pdfs \
--bin pdfs-tray \
--bin pdfs-app \
--bin pdfs-prompt
%install
rel=%{git_dir}/target/release
install -D -m0755 "$rel/pdfs" %{buildroot}%{_bindir}/pdfs
install -D -m0755 "$rel/pdfs-tray" %{buildroot}%{_bindir}/pdfs-tray
install -D -m0755 "$rel/pdfs-app" %{buildroot}%{_bindir}/pdfs-app
install -D -m0755 "$rel/pdfs-prompt" %{buildroot}%{_bindir}/pdfs-prompt
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.desktop \
%{buildroot}%{_datadir}/applications/io.narl.proton-drive-linux.desktop
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux-tray.desktop \
%{buildroot}%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
install -D -m0644 %{git_dir}/packaging/io.narl.proton-drive-linux.svg \
%{buildroot}%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
install -D -m0644 %{git_dir}/packaging/proton-drive.service \
%{buildroot}/usr/lib/systemd/user/proton-drive.service
%files
%license LICENSE
%{_bindir}/pdfs
%{_bindir}/pdfs-tray
%{_bindir}/pdfs-app
%{_bindir}/pdfs-prompt
%{_datadir}/applications/io.narl.proton-drive-linux.desktop
%{_sysconfdir}/xdg/autostart/io.narl.proton-drive-linux-tray.desktop
%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg
/usr/lib/systemd/user/proton-drive.service
%changelog
* Sun Jul 19 2026 Local Packager - 0.4.0-1
- Initial Fedora local package (in-tree cargo build).
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Destructive only inside a uniquely named directory created by this script.
set -Eeuo pipefail
usage() {
echo "usage: $0 MOUNTPOINT [MOUNTPOINT ...]" >&2
echo "example: $0 /mnt/on-demand-testmount /mnt/testmount" >&2
exit 2
}
(( $# > 0 )) || usage
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
command -v findmnt >/dev/null || { echo "findmnt is required" >&2; exit 2; }
mounts=("$@")
run_id="pdfs-acceptance-$(date +%Y%m%dT%H%M%S)-$$"
roots=()
cleanup() {
local root
for root in "${roots[@]}"; do
[[ -n "$root" && "$root" == */pdfs-acceptance-* ]] || continue
rm -rf -- "$root" 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM
for index in "${!mounts[@]}"; do
mountpoint="${mounts[$index]}"
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
fs_type="$(findmnt -T "$mountpoint" -n -o FSTYPE | head -n 1)"
if (( index == 0 )) && [[ "$fs_type" != fuse* ]]; then
echo "refusing primary non-FUSE path $mountpoint (type: ${fs_type:-unknown})" >&2
exit 2
fi
roots+=("${mountpoint%/}/$run_id")
done
step() { printf '[test] %s\n' "$1"; }
assert_text() {
python3 - "$1" "$2" <<'PY'
import sys
p, expected = sys.argv[1], sys.argv[2].encode()
with open(p, "rb") as f: got = f.read()
if got != expected:
raise AssertionError(
f"{p}: expected {expected!r} ({len(expected)} bytes), "
f"got {got!r} ({len(got)} bytes)"
)
PY
}
run_suite() {
local primary="$1"
printf '[mount] testing %s\n' "${primary%/*}"
mkdir -- "$primary"
step "create, read, overwrite, truncate"
printf 'alpha\n' > "$primary/plain.txt"
assert_text "$primary/plain.txt" $'alpha\n'
printf 'a much longer replacement\n' > "$primary/plain.txt"
truncate -s 4 "$primary/plain.txt"
assert_text "$primary/plain.txt" "a mu"
step "binary round-trip and random-access writes"
python3 - "$primary/binary.dat" <<'PY'
import hashlib, sys
p = sys.argv[1]
data = bytes(range(256)) * 16384
with open(p, "wb") as f: f.write(data)
with open(p, "r+b", buffering=0) as f:
f.seek(4093); f.write(b"boundary-write")
with open(p, "rb") as f: got = f.read()
expected = data[:4093] + b"boundary-write" + data[4107:]
assert got == expected
print(hashlib.sha256(got).hexdigest())
PY
step "media-style header and distant seek reads"
python3 - "$primary/seek-sample.mkv" <<'PY'
import os, sys
p = sys.argv[1]
size = 12 * 1024 * 1024 + 137
header = b"\x1aE\xdf\xa3pdfs-media-header"
middle = b"pdfs-middle-cue"
trailer = b"pdfs-media-trailer"
middle_at = 7 * 1024 * 1024 + 19
trailer_at = size - len(trailer)
# A player probes the container header, jumps to cues/index data, then commonly
# seeks near EOF. Write a sparse fixture so the acceptance test stays cheap,
# and issue separate unbuffered reads so FUSE receives genuinely distant ranges
# rather than one accidental sequential read.
with open(p, "wb", buffering=0) as f:
f.truncate(size)
f.seek(0); f.write(header)
f.seek(middle_at); f.write(middle)
f.seek(trailer_at); f.write(trailer)
with open(p, "rb", buffering=0) as f:
assert f.read(len(header)) == header
f.seek(middle_at); assert f.read(len(middle)) == middle
f.seek(trailer_at); assert f.read(len(trailer)) == trailer
assert f.read(1) == b""
f.seek(size + 4096); assert f.read(16) == b""
assert os.stat(p).st_size == size
PY
step "rename, move, and replacement semantics"
mkdir -- "$primary/dir-a" "$primary/dir-b"
printf 'source' > "$primary/source"
printf 'victim' > "$primary/victim"
mv -- "$primary/source" "$primary/victim"
assert_text "$primary/victim" "source"
[[ ! -e "$primary/source" ]]
mv -- "$primary/victim" "$primary/dir-a/moved"
assert_text "$primary/dir-a/moved" "source"
step "POSIX error behavior"
if rmdir -- "$primary/dir-a/moved" 2>/dev/null; then echo "rmdir removed a file" >&2; exit 1; fi
if rm -- "$primary/dir-a" 2>/dev/null; then echo "rm removed a directory" >&2; exit 1; fi
if mv -- "$primary/dir-a" "$primary/dir-a/child" 2>/dev/null; then
echo "moving a directory below itself succeeded" >&2; exit 1
fi
step "open file remains readable after unlink"
python3 - "$primary/open-unlink" <<'PY'
import os, sys
p = sys.argv[1]; data = b"held-open\0" * 4096
with open(p, "wb") as f: f.write(data)
f = open(p, "rb", buffering=0); os.unlink(p)
assert not os.path.exists(p) and f.read() == data
f.close()
PY
step "concurrent independent writers"
for i in $(seq 1 12); do
(python3 - "$primary/concurrent-$i" "$i" <<'PY'
import sys
p, seed = sys.argv[1], int(sys.argv[2]); data = bytes([seed]) * (131072 + seed)
with open(p, "wb") as f: f.write(data)
with open(p, "rb") as f: assert f.read() == data
PY
) &
done
wait
step "directory enumeration agrees with stat"
python3 - "$primary" <<'PY'
import os, sys
for name in os.listdir(sys.argv[1]): os.lstat(os.path.join(sys.argv[1], name))
PY
}
for root in "${roots[@]}"; do
run_suite "$root"
done
if [[ "${PDFS_ACCEPTANCE_CONVERGENCE:-0}" == 1 ]] && (( ${#roots[@]} > 1 )); then
step "cross-mount convergence"
primary="${roots[0]}"
expected="$(sha256sum "$primary/binary.dat" | cut -d' ' -f1)"
for secondary in "${roots[@]:1}"; do
deadline=$((SECONDS + ${PDFS_ACCEPTANCE_SYNC_TIMEOUT:-120}))
while (( SECONDS < deadline )); do
if [[ -f "$secondary/binary.dat" ]] &&
[[ "$(sha256sum "$secondary/binary.dat" 2>/dev/null | cut -d' ' -f1)" == "$expected" ]]; then break; fi
sleep 2
done
[[ -f "$secondary/binary.dat" ]] || { echo "did not appear on $secondary" >&2; exit 1; }
[[ "$(sha256sum "$secondary/binary.dat" | cut -d' ' -f1)" == "$expected" ]] || {
echo "content mismatch on $secondary" >&2; exit 1
}
# The media fixture may drain after binary.dat. Wait independently,
# then probe only its sparse ranges on the other mount. Unlike the
# same-mount check above, this exercises remote FUSE hydration and seek
# reads instead of serving the still-local staged file.
deadline=$((SECONDS + ${PDFS_ACCEPTANCE_SYNC_TIMEOUT:-120}))
while (( SECONDS < deadline )); do
[[ -f "$secondary/seek-sample.mkv" ]] &&
[[ "$(stat -c %s "$secondary/seek-sample.mkv" 2>/dev/null)" == 12583049 ]] && break
sleep 2
done
[[ -f "$secondary/seek-sample.mkv" ]] || {
echo "media fixture did not appear on $secondary" >&2; exit 1
}
python3 - "$secondary/seek-sample.mkv" <<'PY'
import os, sys
p = sys.argv[1]
size = 12 * 1024 * 1024 + 137
checks = [
(0, b"\x1aE\xdf\xa3pdfs-media-header"),
(7 * 1024 * 1024 + 19, b"pdfs-middle-cue"),
(size - len(b"pdfs-media-trailer"), b"pdfs-media-trailer"),
]
assert os.stat(p).st_size == size
with open(p, "rb", buffering=0) as f:
for offset, expected in checks:
f.seek(offset)
assert f.read(len(expected)) == expected
PY
done
fi
step "cleanup"
cleanup
roots=()
echo "PASS: FUSE acceptance suite completed ($run_id)"