Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 | ||
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 | ||
|
|
b37ab78451 | ||
|
|
6e3d2c335e | ||
|
|
14b6df7bce | ||
|
|
4d76462e62 | ||
|
|
a5e03ad8da | ||
|
|
2336ad9e87 | ||
|
|
d988236bfa | ||
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 | ||
|
|
77198ed261 | ||
|
|
0d79c46729 | ||
|
|
a8210ce8fd | ||
|
|
551e14b644 | ||
|
|
7de667b386 | ||
|
|
295bbeadac | ||
|
|
32817870a5 | ||
|
|
b93e41efc5 | ||
|
|
082ef8522a | ||
|
|
2e95655244 | ||
|
|
942cb9f583 | ||
|
|
8470662c54 | ||
|
|
a091c2f34f | ||
|
|
bca0fe34bc | ||
|
|
6df757006a | ||
|
|
6f70eca78e | ||
|
|
0d8a474442 | ||
|
|
8be0f04b61 | ||
|
|
d5165b36a2 | ||
|
|
a76f36bdff | ||
|
|
e9009dd47c | ||
|
|
d0b49d3c8c | ||
|
|
25f578161d | ||
|
|
ea56818a80 | ||
|
|
39ab4aae48 | ||
|
|
6cba77d3cb | ||
|
|
26375885ed | ||
|
|
544d0913e8 | ||
|
|
714b3048dc | ||
|
|
bef39f75cf | ||
|
|
0a1fa95eb4 | ||
|
|
242a28bb8f | ||
|
|
bd17d506f8 | ||
|
|
1cd6a26be3 | ||
|
|
fe75998cea | ||
|
|
9d55ffb9f6 | ||
|
|
2275004f79 | ||
|
|
914ff2b5ae | ||
|
|
01677f2670 | ||
|
|
3f9d36607a | ||
|
|
f1399aa661 | ||
|
|
a3d9d05777 | ||
|
|
bf625bf8d9 | ||
|
|
e0f0c7e0b7 | ||
|
|
a6a925667a | ||
|
|
e259016a00 | ||
|
|
471f57b8e1 | ||
|
|
3ade754ea2 | ||
|
|
661ae3cf7a | ||
|
|
f803bf3cd7 | ||
|
|
084740ad95 | ||
|
|
65eed0cc72 | ||
|
|
1e0e284d5f | ||
|
|
f0fbd0775c | ||
|
|
d25a573e8f | ||
|
|
eafe1307b8 | ||
|
|
056ae0463f | ||
|
|
b2b0fe7b99 | ||
|
|
d13042b550 | ||
|
|
cb67d97b0f | ||
|
|
0323fdc44d | ||
|
|
2b2a49c679 | ||
|
|
ed129f7c71 | ||
|
|
b90b7e4b9c |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
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: Account-free filesystem acceptance
|
||||
run: scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
- 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.
|
||||
@@ -13,6 +13,7 @@ jobs:
|
||||
build-release:
|
||||
name: Build and Release
|
||||
runs-on: ubuntu-24.04
|
||||
environment: 'rpm signing'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -26,6 +27,7 @@ jobs:
|
||||
libfuse3-dev \
|
||||
libgtk-4-dev \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils \
|
||||
@@ -34,11 +36,29 @@ jobs:
|
||||
- name: Set up Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: '1.96'
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo target
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
- name: Verify tag and manifest versions
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
shell: bash
|
||||
run: |
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | head -n1)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace --locked
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
|
||||
- name: Build release binaries
|
||||
run: |
|
||||
cargo build --release --locked
|
||||
@@ -75,6 +95,8 @@ jobs:
|
||||
mkdir -p deb/usr/share/applications
|
||||
mkdir -p deb/usr/share/icons/hicolor/scalable/apps
|
||||
mkdir -p deb/usr/share/licenses/proton-drive-linux
|
||||
mkdir -p deb/usr/lib/systemd/user
|
||||
mkdir -p deb/etc/xdg/autostart
|
||||
|
||||
# Copy binaries
|
||||
cp target/release/pdfs deb/usr/bin/
|
||||
@@ -84,7 +106,9 @@ jobs:
|
||||
|
||||
# Copy desktop integration files
|
||||
cp packaging/io.narl.proton-drive-linux.desktop deb/usr/share/applications/
|
||||
cp packaging/io.narl.proton-drive-linux-tray.desktop deb/etc/xdg/autostart/
|
||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
@@ -98,7 +122,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:
|
||||
@@ -120,8 +144,10 @@ jobs:
|
||||
|
||||
# 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
|
||||
@@ -129,6 +155,26 @@ jobs:
|
||||
# Copy built RPM to root directory
|
||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||
|
||||
- name: Sign Fedora package (.rpm)
|
||||
env:
|
||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
|
||||
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||
run: |
|
||||
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
|
||||
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
|
||||
|
||||
cat <<EOF > ~/.rpmmacros
|
||||
%_signature gpg
|
||||
%_gpg_name ${GPG_KEY_NAME}
|
||||
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
|
||||
EOF
|
||||
|
||||
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
|
||||
else
|
||||
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
|
||||
fi
|
||||
|
||||
|
||||
- name: Upload Workflow Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -3,15 +3,15 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "1.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
rust-version = "1.96"
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.1.10" }
|
||||
proton-drive-rs = { version = "0.1.10" }
|
||||
proton-sdk = { version = "0.6.0" }
|
||||
proton-drive-rs = { version = "0.6.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -40,5 +40,7 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.17"
|
||||
libc = "0.2"
|
||||
@@ -6,11 +6,207 @@ 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.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
- **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.
|
||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for 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).
|
||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||
- **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.
|
||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||
|
||||
## Quick Search and Media Opening
|
||||
|
||||
`pdfs-prompt` searches Drive metadata and the local home-directory index in one request. Results are ranked together rather than split by source. Matching is case-insensitive and supports exact names, prefixes, parent paths, multiple terms, punctuation-separated words, ordered abbreviations, common typos, and adjacent transpositions.
|
||||
|
||||
The prompt stays resident after its first launch, so invoking the shortcut again reuses and resets the existing window. Drive folders open from the mount. Audio and video results also open through FUSE, allowing media players to seek and request ranges without downloading the entire file first; ordinary Drive files are materialized into the local cache before opening.
|
||||
|
||||
### Using your own launcher (fuzzel, rofi, wofi, …)
|
||||
|
||||
`pdfs-prompt --dmenu` runs the same search through an external dmenu-style
|
||||
launcher instead of the built-in window, so the HUD matches the rest of a
|
||||
tiling-WM desktop:
|
||||
|
||||
```bash
|
||||
pdfs-prompt --dmenu # fuzzel/rofi/wofi/tofi/bemenu/dmenu, whichever is installed
|
||||
pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'
|
||||
pdfs-prompt --dmenu --query invoice # skip straight to a search
|
||||
```
|
||||
|
||||
A launcher filters a fixed list — it cannot ask for a new one per keystroke — so
|
||||
searching takes two steps, and the prompt says which step you are on:
|
||||
|
||||
- `Search Drive ›` lists your pinned files. Type anything that isn't one of them
|
||||
and press Enter to search for it.
|
||||
- `Drive: invoice ›` lists the results. Enter opens one; typing something else
|
||||
and pressing Enter searches again. Escape closes.
|
||||
|
||||
`--query` skips the first step. fuzzel and rofi also get file-type icons.
|
||||
|
||||
To make an existing keybinding use it without re-binding, and to pin the
|
||||
launcher command, set them in `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": {
|
||||
"mode": "dmenu",
|
||||
"menu": ["fuzzel", "--dmenu", "--width", "60"],
|
||||
"menu_limit": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--gtk` overrides `"mode": "dmenu"` for one invocation. A `{prompt}` token
|
||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||
own prompt flag is appended.
|
||||
|
||||
## Choosing How Files Open
|
||||
|
||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||
`config.json` overrides that per file type — for instance opening text in
|
||||
Neovim inside Alacritty, while everything else still goes to the desktop:
|
||||
|
||||
```json
|
||||
{
|
||||
"open_with": {
|
||||
"terminal": ["alacritty", "-e"],
|
||||
"rules": [
|
||||
{ "match": ["@text"], "command": ["nvim"], "terminal": true },
|
||||
{ "match": ["*.png", "*.jpg"], "command": ["imv"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `match` takes file-name globs (`*.md`, `notes-*.txt`) or the classes `@dir`,
|
||||
`@text`, `@document`, `@image`, `@media`, `@any`. First matching rule wins.
|
||||
- `command` is argv, not a shell line. A `{}` token is replaced by the path;
|
||||
without one the path is appended. `$VAR` tokens expand from the environment,
|
||||
so `["$EDITOR"]` follows your editor.
|
||||
- `"terminal": true` wraps the command in `terminal`. When that is unset, the
|
||||
terminal comes from `$PDFS_TERMINAL`, then `$TERMINAL`, then the first known
|
||||
emulator on `PATH`; a bare name like `TERMINAL=alacritty` gains the right
|
||||
"run this" flag automatically.
|
||||
- `"default": [...]` replaces `xdg-open` for everything unmatched.
|
||||
|
||||
The rules apply to the prompt (both front ends) and to the GTK browser, so a
|
||||
file opens the same way wherever it was picked. Patterns are matched against the
|
||||
Drive name — a downloaded file is stored in the cache under its content hash,
|
||||
which says nothing about its type, so matching that would send everything to
|
||||
`xdg-open`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -20,6 +216,19 @@ The client includes several optimizations designed for high efficiency, a low me
|
||||
- **Disk-Backed Writes**: Large file writes are staged on disk in temporary scratch files (rather than fully buffered in RAM) and track modified byte intervals. Only the unedited remote segments are fetched at commit time, keeping memory usage minimal.
|
||||
- **Non-Blocking GTK4 Loop**: To prevent UI freezes, all synchronous D-Bus credential checks, control socket requests, and cache usage calculations are offloaded to background worker threads or fetched asynchronously.
|
||||
- **Flicker-Free UI Rendering**: The GTK4 application performs differential rendering of the pins list, only modifying list rows when the list content changes, preserving the user's scroll position and widget focus.
|
||||
- **Durable Staging**: Scratch metadata and staged writes are synced and atomically published before an upload is acknowledged locally. Rapid revisions supersede pending work transactionally.
|
||||
|
||||
## Filesystem Safety
|
||||
|
||||
Version 1.0 strengthens the boundaries around local-only data and destructive reconciliation:
|
||||
|
||||
- Truncate composes with queued revisions, preserving the authoritative prefix and correct zero-filled growth.
|
||||
- Combined move-and-rename operations are queued durably and tolerate partially completed remote state.
|
||||
- Session tombstones prevent successfully unlinked entries from reappearing through eventually consistent listings.
|
||||
- Names are valid UTF-8 and no longer than Linux's 255-byte component limit.
|
||||
- Incomplete mirror scans are non-destructive, and total-wipe protection covers every non-empty baseline.
|
||||
- Failed conflict preservation, local deletion, or staging publication does not settle a new baseline or discard the only local copy.
|
||||
- State, cache, and config directories must be real, current-user-owned `0700` directories; control sockets are `0600`.
|
||||
|
||||
|
||||
## Screenshots
|
||||
@@ -32,11 +241,16 @@ The client includes several optimizations designed for high efficiency, a low me
|
||||
<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/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
||||
<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/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" colspan="2"><img src="images/settings.png" alt="Settings" width="50%"><br><sub><b>Settings</b></sub></td>
|
||||
<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>
|
||||
|
||||
@@ -54,13 +268,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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -101,6 +332,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)
|
||||
@@ -111,15 +356,18 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
||||
1. **Triggers**:
|
||||
- Pushing a git tag matching `v*` (e.g. `git tag v0.1.0 && git push origin v0.1.0`).
|
||||
- Manual runs via the **Actions** tab in GitHub (**workflow_dispatch**).
|
||||
2. **Build Process**:
|
||||
2. **Quality Gates and Build Process**:
|
||||
- For tagged builds, verifies that the tag, workspace version, and `packaging/PKGBUILD` version agree.
|
||||
- Runs `cargo fmt`, Clippy with warnings denied, the locked workspace test suite, and the account-free FUSE acceptance contract.
|
||||
- Spawns an Ubuntu runner and installs GTK4, Libadwaita, FUSE3, and Secret Service packages.
|
||||
- Sets up the Rust compiler and caches build targets to speed up runs.
|
||||
- Compiles the workspace members in release mode.
|
||||
3. **Artifact Packaging**:
|
||||
- Generates a `.tar.gz` containing the raw binaries (`pdfs`, `pdfs-app`, `pdfs-tray`, `pdfs-prompt`).
|
||||
- Packs them into a Debian package (`.deb`).
|
||||
- Packs them into Debian (`.deb`) and Fedora (`.rpm`) packages.
|
||||
- Includes the systemd user service and tray autostart entry in the Debian package.
|
||||
4. **Publishing**:
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb` and `.tar.gz` packages as release assets.
|
||||
- Creates a GitHub Release matching the pushed tag and uploads the `.deb`, `.rpm`, and `.tar.gz` packages as release assets.
|
||||
- For manual runs, compiles and exposes the packages as workflow run artifacts for testing.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,3 +21,5 @@ sha2.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Effective access carried by mounted Drive nodes.
|
||||
|
||||
use proton_drive_rs::MemberRole;
|
||||
|
||||
/// Effective write access for a node in a mounted tree.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Owner,
|
||||
Editor,
|
||||
Viewer,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
pub fn writable(self) -> bool {
|
||||
matches!(self, Self::Owner | Self::Editor)
|
||||
}
|
||||
|
||||
pub(crate) fn as_db_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Editor => "editor",
|
||||
Self::Viewer => "viewer",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"owner" => Some(Self::Owner),
|
||||
"editor" => Some(Self::Editor),
|
||||
"viewer" => Some(Self::Viewer),
|
||||
"unknown" => Some(Self::Unknown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a share root's effective access from its exact membership role.
|
||||
///
|
||||
/// Owned trees fail open. A share whose role is absent or unrecognised fails
|
||||
/// closed. Descendants normally inherit directly at intern time; `Inherited`
|
||||
/// exists for the explicit wire role and therefore returns `parent`.
|
||||
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
|
||||
match role {
|
||||
Some(MemberRole::Viewer) => Access::Viewer,
|
||||
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
|
||||
Some(MemberRole::Inherited) => parent,
|
||||
None if under_share => Access::Viewer,
|
||||
None => Access::Owner,
|
||||
}
|
||||
}
|
||||
|
||||
/// POSIX permission bits advertised for a mounted node.
|
||||
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
|
||||
match (is_dir, access.writable()) {
|
||||
(true, true) => 0o755,
|
||||
(false, true) => 0o644,
|
||||
(true, false) => 0o555,
|
||||
(false, false) => 0o444,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_for_covers_the_fail_open_and_fail_closed_table() {
|
||||
for parent in [
|
||||
Access::Owner,
|
||||
Access::Editor,
|
||||
Access::Viewer,
|
||||
Access::Unknown,
|
||||
] {
|
||||
assert_eq!(access_for(None, parent, false), Access::Owner);
|
||||
assert_eq!(access_for(None, parent, true), Access::Viewer);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Viewer), parent, true),
|
||||
Access::Viewer
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Editor), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Admin), parent, true),
|
||||
Access::Editor
|
||||
);
|
||||
assert_eq!(
|
||||
access_for(Some(MemberRole::Inherited), parent, true),
|
||||
parent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_bits_follow_effective_access() {
|
||||
for writable in [Access::Owner, Access::Editor] {
|
||||
assert_eq!(perm_bits(true, writable), 0o755);
|
||||
assert_eq!(perm_bits(false, writable), 0o644);
|
||||
}
|
||||
for read_only in [Access::Viewer, Access::Unknown] {
|
||||
assert_eq!(perm_bits(true, read_only), 0o555);
|
||||
assert_eq!(perm_bits(false, read_only), 0o444);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
use keyring::Entry;
|
||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT};
|
||||
@@ -84,14 +87,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 +167,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,
|
||||
@@ -148,6 +222,21 @@ pub fn load() -> Result<StoredSession> {
|
||||
|
||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||
pub fn logout() -> Result<()> {
|
||||
// The persisted entity cache describes the account being logged out of. It
|
||||
// is encrypted under that account's mailbox password, but leaving it behind
|
||||
// would also mean the next account inherits a store it can only ever read as
|
||||
// misses. Best-effort: failing to remove a cache must not block the logout.
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let path = dirs.state_dir().join("sdk_cache.db");
|
||||
if let Some(cache) = crate::sdkcache::SdkCache::opened()
|
||||
&& let Err(e) = cache.clear_now()
|
||||
{
|
||||
tracing::warn!(error = %e, "clearing the SDK entity cache on logout failed");
|
||||
}
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
@@ -187,10 +276,50 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
|
||||
register_refresh_handler(&session, password.clone(), key_salts.clone());
|
||||
|
||||
let client = ProtonDriveClient::with_key_salts(&session, password.into_bytes(), key_salts);
|
||||
let client = tune(
|
||||
ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts),
|
||||
password.as_bytes(),
|
||||
);
|
||||
Ok((client, session))
|
||||
}
|
||||
|
||||
/// The client settings every front-end wants, applied wherever a
|
||||
/// [`ProtonDriveClient`] is built for real work.
|
||||
///
|
||||
/// - **Telemetry** into `tracing`, so the SDK's own spans (transfers, block
|
||||
/// storage, per-request timings) land in the daemon's log next to the
|
||||
/// daemon's, instead of being dropped by the default no-op sink.
|
||||
/// - **Small-file uploads**: a file that fits in one block is uploaded as a
|
||||
/// single atomic request instead of the multi-step draft/block/commit dance.
|
||||
/// The SDK makes this opt-in because it has no remote feature-flag provider;
|
||||
/// the drain queue writes plenty of small files, and the shorter path is one
|
||||
/// failure point per write instead of three.
|
||||
/// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not
|
||||
/// re-fetch and re-decrypt the tree the previous run already walked. It is
|
||||
/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox
|
||||
/// password, so the decrypted node names it holds are not readable from the
|
||||
/// file alone — and a password change simply reads as a cold cache, since the
|
||||
/// SDK treats an undecryptable entry as a miss and clears the store.
|
||||
///
|
||||
/// Opening it is best-effort: a store this process cannot open costs cache
|
||||
/// hits, not the session, so the client falls back to the SDK's in-memory
|
||||
/// default.
|
||||
fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient {
|
||||
let client = client
|
||||
.with_telemetry(TracingTelemetry::shared())
|
||||
.with_small_file_upload(true);
|
||||
match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) {
|
||||
Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared(
|
||||
cache,
|
||||
mailbox_password.to_vec(),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory");
|
||||
client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
///
|
||||
/// The session does not carry the mailbox password (it is only needed to rebuild
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Per-node outcomes of the SDK's batch node operations.
|
||||
//!
|
||||
//! `trash_nodes`, `restore_nodes`, `delete_nodes` and `move_nodes` report one
|
||||
//! outcome per node instead of failing the whole call: a node the server
|
||||
//! rejects leaves the rest of the batch applied. The daemon's callers fall into
|
||||
//! two shapes, and both are here so neither has to re-derive the fold:
|
||||
//!
|
||||
//! - one node in, one answer out — [`into_unit`], which collapses the single
|
||||
//! outcome back to a `Result`;
|
||||
//! - a user-supplied list — [`split`], which keeps the successes and hands back
|
||||
//! the failures to report.
|
||||
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
|
||||
/// One node's outcome, as the SDK batch operations report it.
|
||||
pub type Outcome = (NodeUid, Result<(), ProtonError>);
|
||||
|
||||
/// Collapse a batch result to the first per-node failure, for the callers that
|
||||
/// pass exactly one node and want the old all-or-nothing answer.
|
||||
///
|
||||
/// An empty outcome list is `Ok`: the server accepted the request and reported
|
||||
/// on nothing, which is not an error the caller can act on.
|
||||
pub fn into_unit(outcomes: Vec<Outcome>) -> Result<(), ProtonError> {
|
||||
for (_, outcome) in outcomes {
|
||||
outcome?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a batch result into the nodes that succeeded and the ones that failed.
|
||||
pub fn split(outcomes: Vec<Outcome>) -> (Vec<NodeUid>, Vec<(NodeUid, ProtonError)>) {
|
||||
let mut done = Vec::with_capacity(outcomes.len());
|
||||
let mut failed = Vec::new();
|
||||
for (uid, outcome) in outcomes {
|
||||
match outcome {
|
||||
Ok(()) => done.push(uid),
|
||||
Err(e) => failed.push((uid, e)),
|
||||
}
|
||||
}
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
|
||||
}
|
||||
|
||||
fn failure() -> ProtonError {
|
||||
ProtonError::invalid_operation("nope")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_is_ok_when_every_node_succeeded() {
|
||||
assert!(into_unit(vec![(uid("a"), Ok(())), (uid("b"), Ok(()))]).is_ok());
|
||||
assert!(
|
||||
into_unit(Vec::new()).is_ok(),
|
||||
"nothing reported is not a failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_unit_surfaces_a_failed_node() {
|
||||
let outcomes = vec![(uid("a"), Ok(())), (uid("b"), Err(failure()))];
|
||||
assert!(into_unit(outcomes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_successes_and_failures_apart() {
|
||||
let outcomes = vec![
|
||||
(uid("a"), Ok(())),
|
||||
(uid("b"), Err(failure())),
|
||||
(uid("c"), Ok(())),
|
||||
];
|
||||
let (done, failed) = split(outcomes);
|
||||
assert_eq!(done, vec![uid("a"), uid("c")]);
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].0, uid("b"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::menu::PromptConfig;
|
||||
use crate::opener::OpenWith;
|
||||
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.
|
||||
@@ -19,6 +22,59 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
||||
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
|
||||
/// support case can disable the sweep without editing the config file or
|
||||
/// reinstalling — it wins over the stored setting.
|
||||
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
|
||||
|
||||
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
|
||||
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
|
||||
/// redundant duplicate.
|
||||
///
|
||||
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
|
||||
/// the *previous* revision-identity check was wrong (B69), so the replacement is
|
||||
/// not trusted with deletion until it has a release cycle of field evidence
|
||||
/// behind it. See `docs/BUGS.md` B71.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SweepMode {
|
||||
/// Do not run the sweep at all — the thread is never spawned.
|
||||
Off,
|
||||
/// Run, and log what it *would* remove, but never trash anything. Divergent
|
||||
/// and orphaned copies are still surfaced to the activity feed as usual.
|
||||
#[default]
|
||||
Report,
|
||||
/// Trash copies proven identical to their live sibling.
|
||||
Enforce,
|
||||
}
|
||||
|
||||
impl SweepMode {
|
||||
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
|
||||
/// `None` for anything unrecognised, so a typo falls back to the stored
|
||||
/// setting rather than silently picking a mode the operator did not ask for.
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
|
||||
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
|
||||
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this mode is allowed to trash a duplicate.
|
||||
pub fn is_enforcing(self) -> bool {
|
||||
matches!(self, Self::Enforce)
|
||||
}
|
||||
}
|
||||
|
||||
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
|
||||
/// environment read so it can be unit-tested without mutating process state.
|
||||
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
|
||||
env.and_then(SweepMode::parse)
|
||||
.or(stored)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Configuration structure allowing the user to customize client identification headers.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct AppConfig {
|
||||
@@ -34,6 +90,35 @@ 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>,
|
||||
/// What the background conflict sweep may do. `None` means "use
|
||||
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
|
||||
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||
#[serde(default)]
|
||||
pub conflict_sweep: Option<SweepMode>,
|
||||
/// How files opened from the prompt/browser are launched. `None` means
|
||||
/// "hand everything to `xdg-open`", which is what the client did before
|
||||
/// this existed. See [`crate::opener`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub open_with: Option<OpenWith>,
|
||||
/// `pdfs-prompt` behaviour, including the external launcher used by
|
||||
/// `--dmenu`. See [`crate::menu`]. Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -43,6 +128,11 @@ impl Default for AppConfig {
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +143,46 @@ impl AppConfig {
|
||||
pub fn resolved_cache_budget(&self) -> u64 {
|
||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||
}
|
||||
|
||||
/// The effective opener policy: the user's rules, or the all-`xdg-open`
|
||||
/// default. Cloned rather than borrowed because callers hold it for the
|
||||
/// life of a window, long after the config value is gone.
|
||||
pub fn resolved_open_with(&self) -> OpenWith {
|
||||
self.open_with.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The effective prompt settings, defaulted when the section is absent.
|
||||
pub fn resolved_prompt(&self) -> PromptConfig {
|
||||
self.prompt.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||
///
|
||||
/// The environment wins so a support case can stop a destructive background
|
||||
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
|
||||
/// unparseable value is ignored rather than treated as "off", so a typo
|
||||
/// cannot silently change behaviour in either direction.
|
||||
pub fn resolved_conflict_sweep(&self) -> SweepMode {
|
||||
resolve_sweep_mode(
|
||||
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
|
||||
self.conflict_sweep,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -75,11 +205,21 @@ impl AppDirs {
|
||||
/// Load config from disk, creating default if missing.
|
||||
pub fn load_config(&self) -> AppConfig {
|
||||
let path = self.config_path();
|
||||
if path.exists()
|
||||
&& let Ok(content) = std::fs::read_to_string(&path)
|
||||
&& let Ok(config) = serde_json::from_str::<AppConfig>(&content)
|
||||
{
|
||||
return config;
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(&path)
|
||||
.map_err(Error::from)
|
||||
.and_then(|content| {
|
||||
serde_json::from_str::<AppConfig>(&content).map_err(Error::from)
|
||||
}) {
|
||||
Ok(config) => return config,
|
||||
Err(e) => {
|
||||
// Preserve the original bytes for diagnosis/recovery. In
|
||||
// particular, never replace a truncated config with defaults
|
||||
// and silently lose mount, device, or ignore settings.
|
||||
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
||||
return AppConfig::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let default_config = AppConfig::default();
|
||||
@@ -95,10 +235,26 @@ impl AppDirs {
|
||||
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
|
||||
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
||||
let path = self.config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_string_pretty(config)?)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let tmp = path.with_extension(format!("json.tmp-{}-{nonce}", std::process::id()));
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?;
|
||||
use std::io::Write as _;
|
||||
file.write_all(&serde_json::to_vec_pretty(config)?)?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -181,24 +337,41 @@ impl AppDirs {
|
||||
pub fn ensure(&self) -> Result<()> {
|
||||
for dir in [self.state_dir(), self.cache_dir()] {
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
restrict_dir(&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);
|
||||
}
|
||||
std::fs::create_dir_all(&config_dir)?;
|
||||
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 `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
||||
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
||||
fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt as _, PermissionsExt};
|
||||
let before = std::fs::symlink_metadata(dir)?;
|
||||
if before.file_type().is_symlink() || !before.is_dir() {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive path is not a real directory: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
if before.uid() != unsafe { libc::geteuid() } {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive directory is not owned by the current user: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
let after = std::fs::symlink_metadata(dir)?;
|
||||
if after.permissions().mode() & 0o777 != 0o700 {
|
||||
return Err(Error::Other(format!(
|
||||
"sensitive directory did not become owner-only: {}",
|
||||
dir.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make a just-bound Unix socket owner-only (`0600`).
|
||||
@@ -233,29 +406,57 @@ mod tests {
|
||||
/// 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 dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-cfg-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().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);
|
||||
restrict_dir(&dir).unwrap();
|
||||
|
||||
assert_eq!(mode_of(&dir), 0o700, "group and other must have no access");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restrict_dir_refuses_a_symlink() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"pdfs-cfg-link-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let target = root.join("target");
|
||||
let link = root.join("state");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
assert!(restrict_dir(&link).is_err());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 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 dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-sock-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().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();
|
||||
// chmod semantics are identical for a socket path and a regular file;
|
||||
// use a file so this regression also runs in sandboxes that prohibit
|
||||
// creating Unix sockets.
|
||||
let listener = std::fs::File::create(&sock).unwrap();
|
||||
|
||||
restrict_socket(&sock).unwrap();
|
||||
|
||||
@@ -271,6 +472,11 @@ 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()),
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -284,4 +490,86 @@ mod tests {
|
||||
assert_eq!(default_config.app_version, APP_VERSION);
|
||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_defaults_to_report_only() {
|
||||
// The sweep trashes user files, so an installed config that has never
|
||||
// heard of the setting must not get the enforcing behaviour.
|
||||
assert_eq!(SweepMode::default(), SweepMode::Report);
|
||||
assert!(!SweepMode::default().is_enforcing());
|
||||
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_parses_operator_spellings() {
|
||||
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
|
||||
}
|
||||
for s in ["report", "dry-run", "dryrun", "observe"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
|
||||
}
|
||||
for s in ["enforce", "on", "yes", "true", "1"] {
|
||||
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
|
||||
}
|
||||
// A typo must not resolve to anything, so it falls through to the config.
|
||||
assert_eq!(SweepMode::parse("offf"), None);
|
||||
assert_eq!(SweepMode::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
|
||||
let stored = Some(SweepMode::Enforce);
|
||||
// The point of the override: kill a destructive loop without editing config.
|
||||
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
|
||||
// Unrecognised value falls back to the stored choice rather than guessing.
|
||||
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
|
||||
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
|
||||
// No env, no stored value: report-only.
|
||||
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_predating_conflict_sweep_still_loads() {
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(decoded.conflict_sweep.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_mode_round_trips_through_the_config_file() {
|
||||
let config = AppConfig {
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, kind, target, detail, ok FROM activity
|
||||
ORDER BY id DESC LIMIT ?1",
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Photo albums: the album listing and each album's contents, persisted like
|
||||
//! the timeline so the Albums view opens from disk instead of re-enumerating the
|
||||
//! photos volume on every launch.
|
||||
//!
|
||||
//! An album's photos are stored per album rather than as a join against
|
||||
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
|
||||
//! never appear in our own timeline and have nowhere else to record what a
|
||||
//! thumbnail attempt learned about them.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
|
||||
use crate::Result;
|
||||
|
||||
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StoredAlbum {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub photo_count: usize,
|
||||
/// The photo shown as the album's cover, when the server named one.
|
||||
pub cover_uid: Option<String>,
|
||||
/// Epoch seconds of the last change to the album's contents.
|
||||
pub last_activity: Option<i64>,
|
||||
/// True when the album lives on someone else's photos volume.
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Replace the album listing wholesale, in the given order.
|
||||
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// Contents of an album that no longer exists (unshared, deleted) go with
|
||||
// it — otherwise they would linger forever, keyed by an album uid no
|
||||
// listing mentions again.
|
||||
{
|
||||
let kept: std::collections::HashSet<&str> =
|
||||
albums.iter().map(|a| a.uid.as_str()).collect();
|
||||
let stale: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
rows.filter_map(|r| r.ok())
|
||||
.filter(|uid| !kept.contains(uid.as_str()))
|
||||
.collect()
|
||||
};
|
||||
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
|
||||
for uid in stale {
|
||||
stmt.execute([uid])?;
|
||||
}
|
||||
}
|
||||
tx.execute("DELETE FROM albums", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO albums
|
||||
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
for (seq, album) in albums.iter().enumerate() {
|
||||
stmt.execute(params![
|
||||
album.uid,
|
||||
album.name,
|
||||
album.photo_count as i64,
|
||||
album.cover_uid,
|
||||
album.last_activity,
|
||||
album.shared as i64,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted album listing, in stored order.
|
||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||
FROM albums ORDER BY seq",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(StoredAlbum {
|
||||
uid: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
|
||||
cover_uid: r.get(3)?,
|
||||
last_activity: r.get(4)?,
|
||||
shared: r.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Number of albums in the persisted listing.
|
||||
pub fn albums_count(&self) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// Replace one album's contents, in the given (newest-capture-first) order.
|
||||
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
|
||||
/// same way [`Db::photos_replace`] keeps them for the timeline.
|
||||
pub fn album_photos_replace(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
|
||||
WHERE album_uid = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([album_uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
|
||||
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO album_photos
|
||||
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
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);
|
||||
let kind =
|
||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||
stmt.execute(params![
|
||||
album_uid,
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
ratio,
|
||||
thumb_state,
|
||||
seq as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One page of an album's photos, in stored order.
|
||||
pub fn album_photos_page(
|
||||
&self,
|
||||
album_uid: &str,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![album_uid, limit as i64, offset as i64], |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)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// How many photos of `album_uid` are persisted.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||
[album_uid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
/// The album-held photos among `uids`, one row per uid (a photo in two albums
|
||||
/// is returned once). Backs the thumbnail path for photos that are in an
|
||||
/// album but not in our own timeline — everything in a shared album, and an
|
||||
/// album cover whose photo the current timeline page hasn't reached.
|
||||
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE uid IN ({placeholders}) GROUP BY uid"
|
||||
))?;
|
||||
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)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Record a thumbnail verdict against every album row for `uid`, so what one
|
||||
/// attempt learned is not re-learned per album the photo sits in. The
|
||||
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
|
||||
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||
params![uid, state, ratio],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,30 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump `last_accessed` for many keys in one transaction.
|
||||
///
|
||||
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||
/// per 4 MiB block — and each of those was its own write transaction under
|
||||
/// the single connection mutex, contending with every `lookup` and
|
||||
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||
if touches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||
)?;
|
||||
for (key, at) in touches {
|
||||
stmt.execute(params![key, at])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
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();
|
||||
@@ -75,7 +99,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||
params![kind],
|
||||
@@ -95,7 +119,7 @@ impl Db {
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||
@@ -115,7 +139,7 @@ impl Db {
|
||||
/// (`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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
||||
|
||||
impl Db {
|
||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||
[],
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -40,32 +41,43 @@ impl Db {
|
||||
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).
|
||||
/// Write one batch of walked entries under scan generation `generation`,
|
||||
/// keeping the FTS index in step with them.
|
||||
///
|
||||
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||
/// the single shared connection, which stalls the whole mount for as long as
|
||||
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||
/// content can never change. Only insertions and deletions touch the index;
|
||||
/// a re-scan that finds the same files does no index work at all.
|
||||
///
|
||||
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||
/// distinction between "already indexed" and "new row" is exactly what
|
||||
/// decides whether the index needs a write.
|
||||
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",
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE local_files
|
||||
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||
WHERE path = ?1",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
let mut index =
|
||||
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.path,
|
||||
e.name,
|
||||
e.is_dir as i64,
|
||||
e.size,
|
||||
e.mtime,
|
||||
generation
|
||||
])?;
|
||||
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||
if update.execute(row)? == 0 {
|
||||
insert.execute(row)?;
|
||||
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -73,16 +85,25 @@ impl Db {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||
/// completion time. Returns the number of indexed entries.
|
||||
///
|
||||
/// The FTS deletes name the old values explicitly, which is how an
|
||||
/// external-content FTS5 table is told to retract a row — see
|
||||
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||
/// per-row instead of by rebuilding the index.
|
||||
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(
|
||||
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
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",
|
||||
@@ -97,7 +118,7 @@ impl Db {
|
||||
/// 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();
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
@@ -117,7 +138,7 @@ impl Db {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
@@ -147,6 +168,78 @@ impl Db {
|
||||
}
|
||||
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.read();
|
||||
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()
|
||||
{
|
||||
// Trigram rank says nothing about how well a name *starts* with the
|
||||
// query, so on a home directory full of dependency trees the pool
|
||||
// fills with vendored files before the obvious answer is reached
|
||||
// (measured: 73% of the 500 best-ranked candidates for "test" came
|
||||
// from one Go module cache). Give whole-query prefix matches their
|
||||
// own lane, newest first, and keep the single-character lane —
|
||||
// which recovers a typo that destroyed every trigram — behind it.
|
||||
let mut lanes = vec![(
|
||||
format!("{}%", like_escape(query)),
|
||||
"ORDER BY mtime DESC LIMIT ?2",
|
||||
)];
|
||||
lanes.push((
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
"ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
));
|
||||
let mut extra = Vec::new();
|
||||
for (pattern, order) in lanes {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
|
||||
))?;
|
||||
extra.extend(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
// Prefix hits go in front: `rows` is truncated to `limit` below, and
|
||||
// the lane exists precisely because trigram rank was burying them.
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for hit in extra.into_iter().chain(trigram) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
//! 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",
|
||||
"mount",
|
||||
"sync_entry",
|
||||
"pending_op",
|
||||
"activity",
|
||||
"local_files",
|
||||
"share_access",
|
||||
];
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 14;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 27;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -31,7 +31,12 @@ impl Db {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
if current >= SCHEMA_VERSION {
|
||||
if current > SCHEMA_VERSION {
|
||||
return Err(crate::Error::Other(format!(
|
||||
"database schema {current} is newer than this build supports ({SCHEMA_VERSION}); refusing to open it to avoid corrupting user data"
|
||||
)));
|
||||
}
|
||||
if current == SCHEMA_VERSION {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -78,6 +83,124 @@ impl Db {
|
||||
if current < 14 {
|
||||
tx.execute_batch(MIGRATION_V14)?;
|
||||
}
|
||||
if current < 15 {
|
||||
tx.execute_batch(MIGRATION_V15)?;
|
||||
}
|
||||
if current < 16 {
|
||||
tx.execute_batch(MIGRATION_V16)?;
|
||||
}
|
||||
if current < 17 {
|
||||
tx.execute_batch(MIGRATION_V17)?;
|
||||
}
|
||||
if current < 18 {
|
||||
tx.execute_batch(MIGRATION_V18)?;
|
||||
}
|
||||
if current < 19 {
|
||||
tx.execute_batch(MIGRATION_V19)?;
|
||||
}
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
if current < 21 {
|
||||
// A V17-and-older fixture may carry only the tables its own test
|
||||
// cares about; there is nothing to reindex without `nodes`.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
tx.execute_batch(MIGRATION_V21)?;
|
||||
}
|
||||
}
|
||||
if current < 22 {
|
||||
// Older fixtures (and a database that never grew a table because
|
||||
// its feature was never used) may be missing any of these, and an
|
||||
// index on an absent table is a hard error rather than a no-op.
|
||||
for (table, sql) in MIGRATION_V22 {
|
||||
let present: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if present {
|
||||
tx.execute_batch(sql)?;
|
||||
}
|
||||
}
|
||||
// `activity_time` indexes `time DESC` while both activity queries
|
||||
// order by `id`: it has only ever cost writes.
|
||||
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
|
||||
}
|
||||
if current < 23 {
|
||||
// One last full rebuild of the local index, because from here on the
|
||||
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||
// Whatever it costs, it costs once, at startup, before the mount
|
||||
// serves anything.
|
||||
let present: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if present {
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
}
|
||||
}
|
||||
if current < 24 {
|
||||
tx.execute_batch(MIGRATION_V24)?;
|
||||
}
|
||||
if current < 25 {
|
||||
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||
// and there is no path to materialise without it.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
tx.execute_batch(MIGRATION_V25)?;
|
||||
}
|
||||
}
|
||||
if current < 26 {
|
||||
// Same guard as V22: a fixture that never exercised the queue may
|
||||
// not have `pending_op`, and altering an absent table is an error
|
||||
// rather than a no-op.
|
||||
// A fixture built from the current schema and then rewound to an
|
||||
// older `schema_version` already has the column, and `ADD COLUMN`
|
||||
// has no `IF NOT EXISTS`.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V26)?;
|
||||
}
|
||||
}
|
||||
if current < 27 {
|
||||
// Same guards as V26, for the same two reasons: the table may not
|
||||
// exist in an old fixture, and a fixture rewound from the current
|
||||
// schema already has the column.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
|
||||
WHERE name = 'access_deferred_since'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops && !has_column {
|
||||
tx.execute_batch(MIGRATION_V27)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -340,3 +463,348 @@ ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
|
||||
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');
|
||||
";
|
||||
|
||||
/// Schema v17: effective access for roots shared with this account. The role is
|
||||
/// persisted separately from `nodes.node_json` so a cold/offline mount can
|
||||
/// enforce the last known permission even when the SDK cannot refresh metadata.
|
||||
const MIGRATION_V17: &str = "
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||
);
|
||||
";
|
||||
|
||||
/// Schema v18: unified location presentation. Device rows carry only their
|
||||
/// `sync_folder` identity; reads join all device state from that authoritative
|
||||
/// table. The trigger projects future folders, while the foreign key removes a
|
||||
/// presentation row with its sync folder.
|
||||
const MIGRATION_V18: &str = "
|
||||
CREATE TABLE mount (
|
||||
id INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL
|
||||
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||
share_root_uid TEXT,
|
||||
local_path TEXT NOT NULL DEFAULT '',
|
||||
root_uid TEXT NOT NULL DEFAULT '',
|
||||
root_share_id TEXT NOT NULL DEFAULT '',
|
||||
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||
access TEXT NOT NULL DEFAULT 'rw'
|
||||
CHECK (access IN ('rw', 'ro')),
|
||||
CHECK (
|
||||
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||
OR
|
||||
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX mount_myfiles
|
||||
ON mount(kind) WHERE kind = 'myfiles';
|
||||
CREATE UNIQUE INDEX mount_device
|
||||
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||
CREATE UNIQUE INDEX mount_shared
|
||||
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||
|
||||
INSERT INTO mount (kind, sync_folder_id)
|
||||
SELECT 'device', id FROM sync_folder;
|
||||
|
||||
CREATE TRIGGER mount_sync_folder_insert
|
||||
AFTER INSERT ON sync_folder
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||
VALUES ('device', NEW.id);
|
||||
END;
|
||||
";
|
||||
|
||||
/// Schema v19: photo albums and their contents. Both are projections of the
|
||||
/// server's listings — like `photos`, they are replaced wholesale on refresh —
|
||||
/// so the Albums view opens from disk instead of re-enumerating every launch.
|
||||
///
|
||||
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
|
||||
/// `photos`: an album shared with us lives on the sharer's volume, so its
|
||||
/// photos are not in our timeline at all and would otherwise have nowhere to
|
||||
/// remember what a thumbnail attempt learned.
|
||||
const MIGRATION_V19: &str = "
|
||||
CREATE TABLE albums (
|
||||
uid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||
cover_uid TEXT,
|
||||
last_activity INTEGER,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE album_photos (
|
||||
album_uid TEXT NOT NULL,
|
||||
uid TEXT NOT NULL,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (album_uid, uid)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favourite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
/// server's own classification, and the Photos page already derives its tabs
|
||||
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||
/// intact and each row learns its real value on the next refresh, the same way
|
||||
/// `media_type` did.
|
||||
const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
|
||||
/// V21: rebuild the node search index after fixing what counts as indexable.
|
||||
///
|
||||
/// The v16 backfill rooted its path walk at any node whose parent is null *or*
|
||||
/// not cached, but the write path's `node_is_indexable_tx` demanded a
|
||||
/// null-parent ancestor. Only the My Files root has one — a device folder's
|
||||
/// root is stored in `device`/`sync_folder`, never in `nodes` — so every node
|
||||
/// under a device folder was dropped from `nodes_fts` the first time it was
|
||||
/// upserted after the backfill, and was unfindable from the prompt (2,705 of
|
||||
/// 9,252 nodes on the account this was diagnosed against).
|
||||
///
|
||||
/// The rule now matches the backfill, so this replays it: same recursive walk
|
||||
/// as v16, over the current `nodes`.
|
||||
/// Two details the v16 backfill did not carry, both cheap here: a node under a
|
||||
/// trashed folder stays out (the write path has always excluded it, and
|
||||
/// `tombstone_subtree_tx` only marks a subtree it was told about), and the walk
|
||||
/// is depth-capped so a parent cycle in a corrupted database cannot spin the
|
||||
/// migration forever.
|
||||
const MIGRATION_V21: &str = "
|
||||
DROP TABLE IF EXISTS nodes_fts;
|
||||
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, tokenize='trigram'
|
||||
);
|
||||
INSERT INTO nodes_fts (rowid, name, path)
|
||||
WITH RECURSIVE paths(rowid, uid, path, trashed, depth) AS (
|
||||
-- Only a true root (no parent at all) contributes nothing to the path: its
|
||||
-- name is the mount. A row whose parent is merely uncached is an ordinary
|
||||
-- folder and keeps its name, which is what `path_of` does at runtime.
|
||||
SELECT n.rowid, n.uid,
|
||||
CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, n.trashed, 0
|
||||
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,
|
||||
MAX(paths.trashed, n.trashed), paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
SELECT n.rowid, n.name, paths.path
|
||||
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
||||
WHERE paths.trashed = 0;
|
||||
";
|
||||
|
||||
/// V22: indexes for the lookups that were scanning, each paired with the table
|
||||
/// it needs to exist for.
|
||||
///
|
||||
/// Each backs a query on a hot path that had no index to use:
|
||||
///
|
||||
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
|
||||
/// local entry once per node, per pass.
|
||||
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
|
||||
/// after a local index scan.
|
||||
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
|
||||
/// index keeping it to the live rows a listing actually wants.
|
||||
///
|
||||
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
|
||||
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
|
||||
/// first due row; offering the planner a `next_attempt_at` index makes it scan
|
||||
/// that index and then sort the matches by `id` instead —
|
||||
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
|
||||
const MIGRATION_V22: [(&str, &str); 3] = [
|
||||
(
|
||||
"sync_entry",
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
|
||||
),
|
||||
(
|
||||
"local_files",
|
||||
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
|
||||
),
|
||||
(
|
||||
"nodes",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
|
||||
),
|
||||
];
|
||||
|
||||
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||
/// the conflict hole B70 layer B closed.
|
||||
///
|
||||
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||
///
|
||||
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||
/// a stall→resume is plausible are pruned on write.
|
||||
const MIGRATION_V24: &str = "
|
||||
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||
uid TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL,
|
||||
sealed_at INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||
/// walked.
|
||||
///
|
||||
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||
/// per descendant of every folder upsert. The value only changes when a node's
|
||||
/// name or parent changes, which is exactly when the write path is already
|
||||
/// walking the subtree — so it belongs in a column.
|
||||
///
|
||||
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||
///
|
||||
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||
/// so the recursive walk runs once instead of once per row.
|
||||
const MIGRATION_V25: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||
|
||||
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||
|
||||
INSERT INTO node_paths (uid, path)
|
||||
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||
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.uid,
|
||||
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||
paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
SELECT uid, path FROM paths;
|
||||
|
||||
UPDATE nodes
|
||||
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||
|
||||
DROP TABLE node_paths;
|
||||
";
|
||||
|
||||
/// V26: `pending_op.claimed_at` — the drain's claim column.
|
||||
///
|
||||
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
|
||||
/// every queued rename and small write behind it for as long as it ran. Several
|
||||
/// workers can share the queue only if a claim is visible to the others: this
|
||||
/// column is what makes "already being drained by someone" a fact about the row
|
||||
/// rather than about one thread's local variable.
|
||||
///
|
||||
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
|
||||
/// claim is process-local state that outlives nothing — the single-writer lock
|
||||
/// means a non-zero value found at open belongs to a crashed run, and
|
||||
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
|
||||
///
|
||||
/// The index is partial so it costs nothing on the ordinary row: only the
|
||||
/// handful of claimed rows are in it, which is exactly the set the claim query
|
||||
/// has to subtract to keep two workers off one node.
|
||||
const MIGRATION_V26: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||
";
|
||||
|
||||
/// Schema v27: when an op was first deferred by the local access check.
|
||||
///
|
||||
/// An access deferral consumes no attempt and records no error — the op has not
|
||||
/// failed remotely, it has not been sent at all. That is right for a permission
|
||||
/// change the user is about to undo, and wrong for a condition that never
|
||||
/// clears: the row is re-deferred every
|
||||
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
|
||||
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
|
||||
/// empty, so a queue that can never drain is indistinguishable from a busy one.
|
||||
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
|
||||
/// 30 days, none of them visible anywhere.
|
||||
///
|
||||
/// `0` means "not currently access-deferred"; anything else is the ms at which
|
||||
/// the first consecutive deferral happened, which is what
|
||||
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
|
||||
/// deferral against.
|
||||
const MIGRATION_V27: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
@@ -2,9 +2,17 @@
|
||||
//! 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.
|
||||
//! the same data through the control socket. There is one write connection
|
||||
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||
//!
|
||||
//! The pool is what makes WAL worth having. The original justification for a
|
||||
//! single connection was that "FUSE callbacks already serialize behind the
|
||||
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||
//! only thing left serialising them was this process's own mutex.
|
||||
//!
|
||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
@@ -18,32 +26,41 @@
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::Result;
|
||||
use std::path::Path;
|
||||
use crate::{Error, Result};
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod activity;
|
||||
mod albums;
|
||||
mod cache;
|
||||
mod devices;
|
||||
mod local;
|
||||
mod maintenance;
|
||||
mod migrations;
|
||||
mod mounts;
|
||||
mod nodes;
|
||||
mod ops;
|
||||
mod photos;
|
||||
mod pins;
|
||||
mod share_access;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod trash;
|
||||
mod utils;
|
||||
|
||||
pub use albums::StoredAlbum;
|
||||
pub use cache::CacheEntryInput;
|
||||
pub use devices::StoredDevice;
|
||||
pub use local::LocalFileHit;
|
||||
pub use nodes::{SearchHit, StoredNode};
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
||||
PendingCounts, PendingOp, op_supersedes,
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -59,18 +76,208 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||
|
||||
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||
/// any realistic index while keeping the mapping bounded.
|
||||
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||
|
||||
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||
|
||||
/// 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>,
|
||||
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||
/// database, where a second connection would open a different, empty one —
|
||||
/// those fall back to the write connection.
|
||||
readers: Option<ReadPool>,
|
||||
/// The single-writer lock, held open for as long as this handle lives.
|
||||
///
|
||||
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||
/// in-memory databases, which nothing else can reach.
|
||||
_single_writer: Option<File>,
|
||||
}
|
||||
|
||||
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||
///
|
||||
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||
/// does not leave a connection per thread parked forever.
|
||||
struct ReadPool {
|
||||
path: PathBuf,
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
}
|
||||
|
||||
/// How many idle read connections to keep. Enough that the steady state never
|
||||
/// reopens, small enough to be unremarkable.
|
||||
const MAX_IDLE_READERS: usize = 4;
|
||||
|
||||
impl ReadPool {
|
||||
fn take(&self) -> Option<Connection> {
|
||||
if let Some(conn) = self.idle.lock().pop() {
|
||||
return Some(conn);
|
||||
}
|
||||
match open_read_only(&self.path) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(error) => {
|
||||
// Falling back to the write connection is correct, just slower,
|
||||
// so this is a warning and not a failed read.
|
||||
tracing::warn!(%error, "could not open a read-only connection");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&self, conn: Connection) {
|
||||
let mut idle = self.idle.lock();
|
||||
if idle.len() < MAX_IDLE_READERS {
|
||||
idle.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||
/// connection when there is no pool (in-memory) or opening one failed.
|
||||
///
|
||||
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||
/// for `self.read()` and nothing else.
|
||||
pub(super) struct Reader<'a> {
|
||||
pooled: Option<Connection>,
|
||||
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||
pool: Option<&'a ReadPool>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Reader<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Connection {
|
||||
self.pooled
|
||||
.as_ref()
|
||||
.or(self.writer.as_deref())
|
||||
.expect("a Reader always holds one of the two")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||
pool.put(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a second connection to the same file for reads only.
|
||||
///
|
||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||
/// writer's.
|
||||
///
|
||||
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||
/// it anyway.
|
||||
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||
use rusqlite::OpenFlags;
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
conn.pragma_update(None, "query_only", true)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||
/// the schema up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
/// Takes the single-instance lock first (see
|
||||
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let single_writer = acquire_single_writer_lock(path)?;
|
||||
let conn = match Self::open_configured(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) if is_corrupt(&e) => {
|
||||
// Every byte in here is derived from the account or from the
|
||||
// content cache directory, so the recovery is to start over —
|
||||
// loudly, and keeping the damaged file for a post-mortem rather
|
||||
// than deleting evidence.
|
||||
tracing::error!(
|
||||
db = %path.display(),
|
||||
error = %e,
|
||||
"cache database is corrupt — moving it aside and rebuilding \
|
||||
(metadata will be re-fetched; no user data is stored here)"
|
||||
);
|
||||
quarantine_corrupt_db(path)?;
|
||||
Self::open_configured(path)?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
// No pool for `:memory:`. A second connection to that name is a
|
||||
// second, empty database rather than another view of this one, so
|
||||
// every routed read would find a schema that does not exist.
|
||||
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||
path: path.to_path_buf(),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
_single_writer: single_writer,
|
||||
};
|
||||
// A drain claim names a worker thread in the process that took it, and
|
||||
// the single-writer lock above says that process is not running. Any
|
||||
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||
// hide those ops from every worker for good.
|
||||
match db.clear_op_claims() {
|
||||
Ok(0) => {}
|
||||
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||
}
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// A connection for a `SELECT`-only method.
|
||||
///
|
||||
/// Reads see the last committed state, which is the same thing they saw
|
||||
/// through the write connection: every method here commits before it
|
||||
/// returns, and nothing holds a transaction open across calls.
|
||||
pub(super) fn read(&self) -> Reader<'_> {
|
||||
if let Some(pool) = &self.readers
|
||||
&& let Some(conn) = pool.take()
|
||||
{
|
||||
return Reader {
|
||||
pooled: Some(conn),
|
||||
writer: None,
|
||||
pool: Some(pool),
|
||||
};
|
||||
}
|
||||
Reader {
|
||||
pooled: None,
|
||||
writer: Some(self.conn.lock()),
|
||||
pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||
fn open_configured(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
@@ -95,20 +302,53 @@ impl Db {
|
||||
// (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)?;
|
||||
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||
// stores a JSON blob per node — a listing of a few thousand files
|
||||
// evicts itself while it is being read.
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
// Sorts and recursive CTEs materialise their intermediate results.
|
||||
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||
// spills those to a file in `/tmp` by default; they are small and
|
||||
// short-lived, so memory is both faster and less surprising.
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
// Read the database through the page cache of the OS rather than
|
||||
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||
// does not track the database size.
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
// Checkpointing is synchronous in whichever thread happens to commit
|
||||
// the transaction that crosses the threshold — often a FUSE callback.
|
||||
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||
// rather than rare and multi-second.
|
||||
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
Ok(db.into_conn())
|
||||
}
|
||||
|
||||
/// Unwrap the connection back out of a temporary handle. Only
|
||||
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||
/// (which is written against `&self`) before the real handle exists.
|
||||
fn into_conn(self) -> Connection {
|
||||
self.conn.into_inner()
|
||||
}
|
||||
|
||||
/// Open an in-memory database. For tests.
|
||||
#[cfg(test)]
|
||||
pub fn open_in_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
// Keep relational behavior identical to `open`: V18 relies on the
|
||||
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
|
||||
// connection unless explicitly enabled.
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
@@ -122,5 +362,102 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names the in-memory database rather than a file. Both
|
||||
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||
/// corruption story.
|
||||
fn is_in_memory(path: &Path) -> bool {
|
||||
let p = path.as_os_str();
|
||||
p == ":memory:" || p.is_empty()
|
||||
}
|
||||
|
||||
/// Take the single-instance write lock for the database at `path`.
|
||||
///
|
||||
/// The daemon is the only writer by design — the front-ends go through the
|
||||
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||
/// alongside the systemd unit, at which point two processes own the same inode
|
||||
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||
/// locking makes each *statement* safe and does nothing about that.
|
||||
///
|
||||
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||
/// the process, however it exits.
|
||||
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||
if is_in_memory(path) {
|
||||
return Ok(None);
|
||||
}
|
||||
let lock_path = lock_path_for(path);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)?;
|
||||
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Err(Error::Other(format!(
|
||||
"another Proton Drive daemon is already using {} — \
|
||||
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return Err(Error::Io(e));
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
fn lock_path_for(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||
/// permission problem or a newer-than-supported schema must all still fail.
|
||||
fn is_corrupt(e: &Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty.
|
||||
///
|
||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut from = path.as_os_str().to_owned();
|
||||
from.push(suffix);
|
||||
let from = PathBuf::from(from);
|
||||
if !from.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut to = path.as_os_str().to_owned();
|
||||
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||
let to = PathBuf::from(to);
|
||||
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||
// WAL whose database header no longer matches — but failing to move the
|
||||
// database itself is not, because the retry would hit the same file.
|
||||
match std::fs::rename(&from, &to) {
|
||||
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Unified mount/location presentation rows.
|
||||
//!
|
||||
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
||||
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Db;
|
||||
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
use crate::{Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Project the configured primary mount into the presentation table.
|
||||
///
|
||||
/// `root_share_id` is optional because an offline startup may only have the
|
||||
/// cached root node. A previously learned share id is retained only when it
|
||||
/// belongs to the same root uid.
|
||||
pub fn mount_upsert_my_files(
|
||||
&self,
|
||||
local_path: &str,
|
||||
root_uid: &str,
|
||||
root_share_id: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let updated = tx.execute(
|
||||
"UPDATE mount
|
||||
SET local_path = ?1,
|
||||
root_share_id = CASE
|
||||
WHEN root_uid = ?2
|
||||
THEN COALESCE(NULLIF(?3, ''), root_share_id)
|
||||
ELSE COALESCE(?3, '')
|
||||
END,
|
||||
root_uid = ?2,
|
||||
mode = 'ondemand',
|
||||
access = 'rw'
|
||||
WHERE kind = 'myfiles'",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
let id = if updated == 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO mount
|
||||
(kind, local_path, root_uid, root_share_id, mode, access)
|
||||
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
|
||||
params![local_path, root_uid, root_share_id],
|
||||
)?;
|
||||
tx.last_insert_rowid()
|
||||
} else {
|
||||
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
|
||||
row.get(0)
|
||||
})?
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Store a share id learned asynchronously, but only if the projection still
|
||||
/// names the root it was resolved for.
|
||||
pub fn mount_repair_my_files_share_id(
|
||||
&self,
|
||||
root_uid: &str,
|
||||
root_share_id: &str,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE mount
|
||||
SET root_share_id = ?2
|
||||
WHERE kind = 'myfiles'
|
||||
AND root_uid = ?1",
|
||||
params![root_uid, root_share_id],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
/// Every local location, with device fields joined from `sync_folder`.
|
||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
m.id,
|
||||
m.kind,
|
||||
m.sync_folder_id,
|
||||
m.share_root_uid,
|
||||
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
||||
m.access,
|
||||
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
||||
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
||||
FROM mount AS m
|
||||
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||
ORDER BY CASE m.kind
|
||||
WHEN 'myfiles' THEN 0
|
||||
WHEN 'device' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
m.id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<i64>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, String>(8)?,
|
||||
row.get::<_, String>(9)?,
|
||||
row.get::<_, i64>(10)?,
|
||||
row.get::<_, Option<String>>(11)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut mounts = Vec::new();
|
||||
for row in rows {
|
||||
let (
|
||||
id,
|
||||
kind,
|
||||
sync_folder_id,
|
||||
share_root_uid,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode,
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode,
|
||||
) = row?;
|
||||
let kind = match kind.as_str() {
|
||||
"myfiles" => MountKind::MyFiles,
|
||||
"device" => MountKind::Device {
|
||||
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||
})?,
|
||||
},
|
||||
"shared" => MountKind::Shared {
|
||||
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||
})?,
|
||||
},
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown kind {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let access = match access.as_str() {
|
||||
"rw" => MountAccess::Rw,
|
||||
"ro" => MountAccess::Ro,
|
||||
other => {
|
||||
return Err(Error::Other(format!(
|
||||
"mount row {id} has unknown access {other:?}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
mounts.push(MountSpec {
|
||||
id,
|
||||
kind,
|
||||
local_path,
|
||||
root_uid,
|
||||
root_share_id,
|
||||
mode: MountMode::from(mode.as_str()),
|
||||
access,
|
||||
state,
|
||||
last_sync,
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
//! 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 std::collections::HashSet;
|
||||
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use crate::{Access, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
||||
use super::utils::{
|
||||
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
|
||||
};
|
||||
|
||||
pub struct StoredNode {
|
||||
pub node: Node,
|
||||
@@ -22,6 +26,11 @@ pub struct SearchHit {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
pub struct PublishedSharedRoot {
|
||||
pub node: Node,
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
||||
self.upsert_nodes(std::slice::from_ref(node))
|
||||
@@ -42,58 +51,304 @@ impl Db {
|
||||
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.
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
if !node.trashed {
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (uid, name) VALUES (?1, ?2)",
|
||||
params![uid, node.name],
|
||||
)?;
|
||||
}
|
||||
upsert_node_tx(&tx, node)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return visible direct children without consulting the parent's `listed`
|
||||
/// flag. Synthetic shared listings use this to serve the last completed
|
||||
/// snapshot while offline, including after a live event expired its TTL.
|
||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name, uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
||||
let mut nodes = Vec::new();
|
||||
for row in rows {
|
||||
nodes.push(serde_json::from_str(&row?)?);
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Atomically publish one completed `Shared with me` root listing.
|
||||
///
|
||||
/// Roots absent from `accepted` and every persisted descendant are
|
||||
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
|
||||
/// their visible snapshot but are downgraded to Viewer until membership is
|
||||
/// verified again. Both cases preserve queued operations and staged writes.
|
||||
pub fn publish_shared_roots(
|
||||
&self,
|
||||
parent: &NodeUid,
|
||||
accepted: &[NodeUid],
|
||||
roots: &[PublishedSharedRoot],
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
let parent = parent.to_string();
|
||||
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||
let materialized: HashSet<String> = roots
|
||||
.iter()
|
||||
.map(|published| published.node.uid.to_string())
|
||||
.collect();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
let existing = direct_child_uids_tx(&tx, &parent)?;
|
||||
let removed: Vec<String> = existing
|
||||
.into_iter()
|
||||
.filter(|uid| !present.contains(uid))
|
||||
.collect();
|
||||
|
||||
for published in roots {
|
||||
if !present.contains(&published.node.uid.to_string()) {
|
||||
continue;
|
||||
}
|
||||
upsert_node_tx(&tx, &published.node)?;
|
||||
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
|
||||
}
|
||||
for uid in present.difference(&materialized) {
|
||||
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
|
||||
}
|
||||
for root in &removed {
|
||||
tombstone_subtree_tx(&tx, root)?;
|
||||
upsert_share_access_tx(&tx, root, Access::Viewer)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||
params![parent],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
Ok(removed
|
||||
.into_iter()
|
||||
.filter_map(|uid| parse_node_uid(&uid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
|
||||
///
|
||||
/// Node rows and queued operations remain so retries and staged writes keep
|
||||
/// their references. FTS and completed-listing state are withdrawn for the
|
||||
/// whole subtree, and the deleted UID becomes a fail-closed authority.
|
||||
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tombstone_subtree_tx(&tx, &uid)?;
|
||||
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a completed foreign-folder listing from its authoritative UID
|
||||
/// list and the subset that materialized successfully. Accepted-but-omitted
|
||||
/// children retain their previous snapshot; only absent UIDs are tombstoned.
|
||||
pub fn publish_foreign_children(
|
||||
&self,
|
||||
parent: &NodeUid,
|
||||
accepted: &[NodeUid],
|
||||
materialized: &[Node],
|
||||
) -> Result<Vec<NodeUid>> {
|
||||
let parent = parent.to_string();
|
||||
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
|
||||
.into_iter()
|
||||
.filter(|uid| !present.contains(uid))
|
||||
.collect();
|
||||
for node in materialized {
|
||||
if present.contains(&node.uid.to_string()) {
|
||||
upsert_node_tx(&tx, node)?;
|
||||
}
|
||||
}
|
||||
for uid in &removed {
|
||||
tombstone_subtree_tx(&tx, uid)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||
params![parent],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(removed
|
||||
.into_iter()
|
||||
.filter_map(|uid| parse_node_uid(&uid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
|
||||
/// Viewer authority.
|
||||
///
|
||||
/// The pin is inserted last so any failure there rolls back the node and
|
||||
/// its descendant FTS updates as well as the access row. Returning `false`
|
||||
/// guarantees no write occurred, which keeps cached root lookups O(1).
|
||||
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
|
||||
let uid = node.uid.to_string();
|
||||
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let pinned_name: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
params![pinned_name_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let stored: Option<(Option<String>, String, i64, i64)> = tx
|
||||
.query_row(
|
||||
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let access: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
|
||||
parent == desired_parent
|
||||
&& name == node.name
|
||||
&& is_dir == 1
|
||||
&& trashed == node.trashed as i64
|
||||
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
|
||||
&& pinned_name.is_some();
|
||||
if unchanged {
|
||||
tx.commit()?;
|
||||
return Ok(false);
|
||||
}
|
||||
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||
upsert_node_tx(&tx, node)?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO NOTHING",
|
||||
params![pinned_name_key, node.name],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 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])?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE uid = ?1", params![uid])?;
|
||||
if let Some(rowid) = rowid {
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically retire a landed trash operation and its retained authority.
|
||||
///
|
||||
/// Queued trash keeps the node row so drain-time permission checks can
|
||||
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||
/// that row after a crash; removing the row first could strand a now
|
||||
/// unauthorizable op. One transaction closes both windows.
|
||||
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
||||
let uid = uid.to_string();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
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.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||
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.read();
|
||||
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)
|
||||
}
|
||||
|
||||
/// A node's mountpoint-relative path, or `None` when it is not cached.
|
||||
///
|
||||
/// One indexed read of the stored `path`. Callers that need to relate many
|
||||
/// nodes to a handful of roots — search decorating each hit with its
|
||||
/// mountpoint — resolve the roots once through this and do the rest as
|
||||
/// string arithmetic, instead of a `path_relative_to` walk per pair.
|
||||
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let exists: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if exists.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(path_of(&conn, uid)?))
|
||||
}
|
||||
|
||||
/// 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.read();
|
||||
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
|
||||
@@ -104,15 +359,15 @@ impl Db {
|
||||
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 conn = self.read();
|
||||
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid FROM nodes
|
||||
"SELECT node_json, uid, path 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)?)?
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
} 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.
|
||||
@@ -122,23 +377,99 @@ impl Db {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid
|
||||
FROM nodes_fts f JOIN nodes n ON n.uid = f.uid
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
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)?)?
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
||||
};
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
for (json, uid) in rows {
|
||||
for (json, uid, path) in rows {
|
||||
let node: Node = serde_json::from_str(&json)?;
|
||||
let path = path_of(&conn, &uid)?;
|
||||
let path = match path {
|
||||
Some(path) => path,
|
||||
None => walk_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.read();
|
||||
let terms = candidate_trigrams(query);
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
} else {
|
||||
let expression = terms.join(" OR ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
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_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||
};
|
||||
// Trigram rank does not know that a name *starts* with the query, and a
|
||||
// short substitution can destroy every trigram outright (`vedio` vs
|
||||
// `video`). Two indexed lanes cover both without scanning the node
|
||||
// table: whole-query prefix first, then same-initial.
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path 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 mut extra = Vec::new();
|
||||
for pattern in [
|
||||
format!("{}%", like_escape(query)),
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
] {
|
||||
extra.extend(collect_hits(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
|
||||
)?);
|
||||
}
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for row in extra.into_iter().chain(trigram) {
|
||||
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
rows.truncate(limit);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(json, uid, path)| {
|
||||
Ok(SearchHit {
|
||||
node: serde_json::from_str(&json)?,
|
||||
path: match path {
|
||||
Some(path) => path,
|
||||
None => walk_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.
|
||||
@@ -153,7 +484,7 @@ impl Db {
|
||||
|
||||
/// 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 conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
@@ -174,7 +505,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||
@@ -192,7 +523,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let listed: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||
@@ -225,6 +556,305 @@ impl Db {
|
||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||
}
|
||||
|
||||
/// What a node's row looked like before this upsert, for the fields that decide
|
||||
/// whether the subtree below it has to be touched at all.
|
||||
struct PriorRow {
|
||||
parent_uid: Option<String>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
trashed: bool,
|
||||
}
|
||||
|
||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||
|
||||
let prior: Option<PriorRow> = tx
|
||||
.query_row(
|
||||
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| {
|
||||
Ok(PriorRow {
|
||||
parent_uid: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
trashed: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
// A node's path is its parent's path plus its own name — one indexed lookup,
|
||||
// where resolving it from scratch is a recursive walk to the root. A node
|
||||
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||
let path = match &parent_uid {
|
||||
None => String::new(),
|
||||
Some(parent) => {
|
||||
let parent_path: Option<Option<String>> = tx
|
||||
.query_row(
|
||||
"SELECT path FROM nodes WHERE uid = ?1",
|
||||
params![parent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||
}
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
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,
|
||||
path = excluded.path",
|
||||
params![
|
||||
uid,
|
||||
parent_uid,
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
path,
|
||||
],
|
||||
)?;
|
||||
let rowid: i64 = tx.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
if indexable {
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||
params![rowid, node.name, path],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Everything below only matters when the subtree's paths or reachability
|
||||
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
|
||||
// full descendant walk on each of those was most of what a large tree spent
|
||||
// its time on.
|
||||
let subtree_moved = prior.is_none_or(|prior| {
|
||||
prior.parent_uid != parent_uid
|
||||
|| prior.name != node.name
|
||||
|| prior.path.as_deref() != Some(path.as_str())
|
||||
|| prior.trashed != node.trashed
|
||||
});
|
||||
if node.is_folder() && subtree_moved {
|
||||
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrite every descendant's stored path and search-index row after `folder`
|
||||
/// moved, was renamed, or changed reachability.
|
||||
///
|
||||
/// One recursive walk carries both the new path and whether anything on the way
|
||||
/// down is trashed, so each descendant costs two trivial statements instead of a
|
||||
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
|
||||
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
|
||||
/// subtree is the same for every node in it.
|
||||
///
|
||||
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
|
||||
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
|
||||
/// it to us) never terminates, and this runs inside the write transaction that
|
||||
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
|
||||
/// this deep.
|
||||
fn reindex_subtree_tx(
|
||||
tx: &Transaction<'_>,
|
||||
folder: &str,
|
||||
folder_path: &str,
|
||||
folder_indexable: bool,
|
||||
) -> Result<()> {
|
||||
let descendants: Vec<(i64, String, bool)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||
n.trashed, 0
|
||||
FROM nodes n WHERE n.parent_uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
sub.path || '/' || n.name,
|
||||
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
WHERE sub.depth < 256
|
||||
)
|
||||
SELECT rowid, path, blocked FROM sub",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder, folder_path], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
out
|
||||
};
|
||||
if descendants.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||
let mut index = tx.prepare(
|
||||
"INSERT INTO nodes_fts (rowid, name, path)
|
||||
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||
)?;
|
||||
for (rowid, path, blocked) in descendants {
|
||||
set_path.execute(params![rowid, path])?;
|
||||
unindex.execute(params![rowid])?;
|
||||
if folder_indexable && !blocked {
|
||||
index.execute(params![rowid, path])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
|
||||
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||
let mut uids = Vec::new();
|
||||
for row in rows {
|
||||
uids.push(row?);
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// Whether a node belongs in the search index: it exists, and nothing on the
|
||||
/// way up to the top of its cached chain is trashed.
|
||||
///
|
||||
/// The chain does *not* have to reach a `parent_uid IS NULL` row. Only the My
|
||||
/// Files root is stored that way; a device folder's root lives in `device` /
|
||||
/// `sync_folder` and never gets a `nodes` row, so requiring a null-parent
|
||||
/// ancestor silently excluded every device-folder subtree from search — 29% of
|
||||
/// the nodes on the account this was found on. A subtree whose topmost cached
|
||||
/// row has an uncached parent is ordinary, reachable data.
|
||||
///
|
||||
/// What is still excluded is a *cycle*: the walk's own guard stops it, but
|
||||
/// [`path_of`] has no such guard, so an indexed cycle would hang a search. A
|
||||
/// clean chain ends at a row whose parent is null or simply not cached; a cycle
|
||||
/// ends at one whose parent is a row we have already visited.
|
||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
let indexable: i64 = tx.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
||||
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
||||
a.path || n.uid || char(31)
|
||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||
)
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
WHEN MAX(trashed) != 0 THEN 0
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM ancestors a
|
||||
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||
AND a.parent_uid IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
||||
) THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
FROM ancestors",
|
||||
[uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(indexable == 1)
|
||||
}
|
||||
|
||||
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
||||
let rows = {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
|
||||
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||
UNION
|
||||
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
||||
JOIN subtree s ON n.parent_uid = s.uid
|
||||
)
|
||||
SELECT rowid, uid, node_json FROM subtree",
|
||||
)?;
|
||||
let mapped = stmt.query_map([root_uid], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})?;
|
||||
let mut rows = Vec::new();
|
||||
for row in mapped {
|
||||
rows.push(row?);
|
||||
}
|
||||
rows
|
||||
};
|
||||
for (rowid, uid, json) in rows {
|
||||
let json = match json {
|
||||
Some(json) => {
|
||||
let mut node: Node = serde_json::from_str(&json)?;
|
||||
node.trashed = true;
|
||||
Some(serde_json::to_string(&node)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
tx.execute(
|
||||
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
|
||||
params![uid, json],
|
||||
)?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid, access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_node_uid(value: &str) -> Option<NodeUid> {
|
||||
let (volume, link) = value.split_once('~')?;
|
||||
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
|
||||
}
|
||||
|
||||
/// 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("ed) {
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! order, so a child never drains before the parent that gives it a real uid.
|
||||
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
|
||||
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
||||
pub const OP_RENAME: &str = "rename";
|
||||
|
||||
/// Drain-time authority retained by a queued rename.
|
||||
///
|
||||
/// The optimistic local move rewrites the node's persisted ancestry to the
|
||||
/// destination immediately. Keeping the original parent here prevents that
|
||||
/// rewrite from erasing the shared tree that admitted the move.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RenameMeta {
|
||||
pub original_parent_uid: String,
|
||||
}
|
||||
|
||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||
/// (offline.md Phase 3b).
|
||||
///
|
||||
@@ -63,6 +74,16 @@ pub fn op_supersedes(kind: &str) -> bool {
|
||||
/// drain replaces it with the uid the server assigns.
|
||||
pub const LOCAL_VOLUME: &str = "local";
|
||||
|
||||
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
|
||||
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
|
||||
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
|
||||
/// editor's `*.swp` — so its bytes never upload while it wears that name. A
|
||||
/// rename to the finished name un-parks it (see [`Db::set_create_hold`]), which
|
||||
/// is the only moment the completed file is meant to reach Drive. Without the
|
||||
/// park, every partial and every abandoned temp would upload and, on a
|
||||
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||
|
||||
/// 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).
|
||||
///
|
||||
@@ -98,15 +119,32 @@ pub struct PendingOp {
|
||||
}
|
||||
|
||||
/// How much work the queue owes the server, by whether it carries bytes.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Debug, Clone, 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,
|
||||
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
|
||||
/// the rename that gives it its finished name. They are queued but will
|
||||
/// never drain on their own, so counting them as "waiting to upload" would
|
||||
/// have the front-end promise progress that is not coming.
|
||||
pub parked: i64,
|
||||
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||
pub failing: i64,
|
||||
/// The most recent error text from a failing op, for the front-end to show
|
||||
/// instead of the bare count.
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Failed attempts after which an op is reported as failing rather than merely
|
||||
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
|
||||
/// is retrying at the slowest rate it ever will and has been for minutes.
|
||||
pub const FAILING_ATTEMPTS: i64 = 6;
|
||||
|
||||
/// The outcome of folding freshly written bytes into a queued create.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachedBlob {
|
||||
@@ -117,10 +155,37 @@ pub struct AttachedBlob {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||
)",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Read an existing operation's metadata before a superseding enqueue.
|
||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, kind],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map(|value| value.flatten())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let conn = self.conn.lock();
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||
let blob: Option<String> = conn
|
||||
let blob: Option<String> = tx
|
||||
.query_row(
|
||||
"SELECT blob_path FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
@@ -128,7 +193,7 @@ impl Db {
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
conn.execute(
|
||||
tx.execute(
|
||||
"DELETE FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![op.uid, op.kind],
|
||||
)?;
|
||||
@@ -136,10 +201,10 @@ impl Db {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
conn.execute(
|
||||
tx.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
(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,
|
||||
@@ -147,10 +212,57 @@ impl Db {
|
||||
op.name,
|
||||
op.blob_path,
|
||||
op.meta_json,
|
||||
op.created_at
|
||||
op.created_at,
|
||||
op.next_attempt_at
|
||||
],
|
||||
)?;
|
||||
Ok((conn.last_insert_rowid(), superseded))
|
||||
let id = tx.last_insert_rowid();
|
||||
tx.commit()?;
|
||||
Ok((id, superseded))
|
||||
}
|
||||
|
||||
/// Atomically replace every queued operation for `uid` and its queued
|
||||
/// descendants with one remote trash intent.
|
||||
///
|
||||
/// Staged blob paths are returned only after the transaction commits. A
|
||||
/// failed trash insert therefore rolls the deletions back and leaves every
|
||||
/// prior operation owning its bytes.
|
||||
pub fn replace_ops_with_trash(
|
||||
&self,
|
||||
uid: &str,
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, 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], |row| row.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.execute(
|
||||
"INSERT INTO pending_op
|
||||
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
|
||||
params![OP_TRASH, uid, name, created_at],
|
||||
)?;
|
||||
let id = tx.last_insert_rowid();
|
||||
tx.commit()?;
|
||||
Ok((id, blobs))
|
||||
}
|
||||
|
||||
/// Point a queued create at the bytes that were just written to it, returning
|
||||
@@ -179,11 +291,18 @@ impl Db {
|
||||
let Some((id, superseded)) = existing else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Clearing the backoff lets the fresh bytes be tried promptly — except
|
||||
// when the create is *parked* (a transient file, next_attempt_at at the
|
||||
// PARK_UNTIL sentinel). Attaching a new revision to a still-growing
|
||||
// download must not wake it; only the finalize rename does. So preserve a
|
||||
// park and reset an ordinary backoff.
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
|
||||
SET blob_path = ?2, meta_json = ?3, attempts = 0,
|
||||
next_attempt_at = CASE WHEN next_attempt_at >= ?4
|
||||
THEN next_attempt_at ELSE 0 END
|
||||
WHERE id = ?1",
|
||||
params![id, blob_path, meta_json],
|
||||
params![id, blob_path, meta_json, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(Some(AttachedBlob { id, superseded }))
|
||||
}
|
||||
@@ -229,6 +348,52 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Park or un-park a queued create/mkdir for `uid`, returning whether a row
|
||||
/// was touched.
|
||||
///
|
||||
/// Parking sets `next_attempt_at` to [`PARK_UNTIL`] so [`Db::next_due_op`]
|
||||
/// skips it indefinitely; un-parking sets it due now. Used to keep a
|
||||
/// transient file's bytes off Drive until it is renamed to its finished name
|
||||
/// (docs/BUGS.md B70). Un-parking a create that was never parked is harmless:
|
||||
/// it just makes the create due immediately, which for a normal local node it
|
||||
/// already is.
|
||||
pub fn set_create_hold(&self, uid: &str, held: bool) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let n = conn.execute(
|
||||
"UPDATE pending_op SET next_attempt_at = ?2
|
||||
WHERE uid = ?1 AND kind IN (?3, ?4)",
|
||||
params![uid, if held { PARK_UNTIL } else { 0 }, 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.read();
|
||||
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)
|
||||
}
|
||||
|
||||
/// Check whether *any* op of any kind is queued against `uid`.
|
||||
///
|
||||
/// Used as a safety interlock by callers that are about to remove a node
|
||||
/// they only know about from a stale snapshot: a queued op means the user's
|
||||
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||
/// blob that holds them (`docs/BUGS.md` B71).
|
||||
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||
params![uid],
|
||||
|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.
|
||||
///
|
||||
@@ -316,7 +481,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
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
|
||||
@@ -347,10 +512,130 @@ impl Db {
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
/// Take the oldest due op for this worker, marking it claimed so no other
|
||||
/// drain worker takes it, or `None` when there is nothing to do.
|
||||
///
|
||||
/// The drain is several workers over one queue, which is only safe because
|
||||
/// the claim is a column: a row is offered to exactly one worker, and the
|
||||
/// worker that took it is the only one that may retire, defer or fail it.
|
||||
///
|
||||
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
|
||||
///
|
||||
/// * `claimed_at = 0` — not already in flight somewhere else.
|
||||
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
|
||||
/// node, but there it has to hold absolutely: a queued rename and a queued
|
||||
/// revision of one file must land in the order they were made, and two
|
||||
/// workers on one node would also race over its staged blob and its
|
||||
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
|
||||
/// order for free — the older op is claimed first and the newer one is
|
||||
/// invisible until it retires.
|
||||
///
|
||||
/// Select and mark are one transaction, so two workers cannot both see the
|
||||
/// row as unclaimed.
|
||||
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let op = {
|
||||
let mut stmt = tx.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 claimed_at = 0
|
||||
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
|
||||
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||
ORDER BY id LIMIT 1",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
))?;
|
||||
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()?
|
||||
};
|
||||
if let Some(op) = op.as_ref() {
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
|
||||
params![op.id, now.max(1)],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(op)
|
||||
}
|
||||
|
||||
/// Give a claimed op back to the queue, whatever became of the attempt.
|
||||
///
|
||||
/// Called on every path out of a drain attempt rather than only the failing
|
||||
/// ones: a handler that retires its own row leaves nothing to release (the
|
||||
/// `UPDATE` matches nothing), and a handler that returns without retiring
|
||||
/// *and* without failing would otherwise leave the row claimed by a worker
|
||||
/// that has moved on — invisible to every worker, forever.
|
||||
pub fn release_op_claim(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop every claim in the table.
|
||||
///
|
||||
/// A claim describes a worker in *this* process, so one surviving on disk
|
||||
/// means the previous run died holding it. The single-writer lock
|
||||
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
|
||||
/// draining this queue while we are opening it.
|
||||
pub fn clear_op_claims(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||
/// not work this one is waiting for, and counting them would have an idle
|
||||
/// worker spin on a row it cannot have.
|
||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||
WHERE claimed_at = 0 \
|
||||
AND (parent_uid IS NULL OR substr(parent_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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
@@ -383,7 +668,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let uploads = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
@@ -394,7 +679,33 @@ impl Db {
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(PendingCounts { uploads, changes })
|
||||
let parked = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
|
||||
params![PARK_UNTIL],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let failing = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let last_error = conn
|
||||
.query_row(
|
||||
"SELECT last_error FROM pending_op
|
||||
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||
ORDER BY attempts DESC LIMIT 1",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
Ok(PendingCounts {
|
||||
uploads,
|
||||
changes,
|
||||
parked,
|
||||
failing,
|
||||
last_error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||
@@ -410,14 +721,166 @@ impl Db {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Clears any access-deferral window: whatever the op was waiting for has
|
||||
/// now been reported, so the next uncleared deferral starts its own window
|
||||
/// rather than escalating again on the following recheck.
|
||||
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
|
||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
|
||||
access_deferred_since = 0
|
||||
WHERE id = ?1",
|
||||
params![id, error, next_attempt_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Defer a due operation without recording a failed remote attempt.
|
||||
///
|
||||
/// Access checks happen before the drain calls Proton, so a locally denied
|
||||
/// operation has not failed remotely and must not consume a retry. Preserve
|
||||
/// the long-lived transient-create sentinel if this is ever called for one.
|
||||
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET next_attempt_at = CASE
|
||||
WHEN next_attempt_at >= ?3 THEN next_attempt_at
|
||||
ELSE ?2
|
||||
END
|
||||
WHERE id = ?1",
|
||||
params![id, next_attempt_at, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Defer an op the local access check refused, and report how long it has
|
||||
/// been refused for.
|
||||
///
|
||||
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
|
||||
/// on the retry clock, plus the one thing that was missing: the first
|
||||
/// deferral of a run stamps `access_deferred_since`, and every deferral
|
||||
/// returns that stamp. A caller can therefore tell a deferral that is one
|
||||
/// recheck old from one that has been repeating unchanged for days, which is
|
||||
/// the difference between a permission the user is mid-way through changing
|
||||
/// and a queue that will never drain.
|
||||
///
|
||||
/// Returns the ms at which the current run of deferrals began — `now` on the
|
||||
/// first one.
|
||||
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op
|
||||
SET next_attempt_at = CASE
|
||||
WHEN next_attempt_at >= ?4 THEN next_attempt_at
|
||||
ELSE ?3
|
||||
END,
|
||||
access_deferred_since = CASE
|
||||
WHEN access_deferred_since = 0 THEN ?2
|
||||
ELSE access_deferred_since
|
||||
END
|
||||
WHERE id = ?1",
|
||||
params![id, now, next_attempt_at, PARK_UNTIL],
|
||||
)?;
|
||||
let since = conn
|
||||
.query_row(
|
||||
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?;
|
||||
// A row that drained out from under this call leaves nothing to measure;
|
||||
// reporting `now` says "this run just started", which is the reading that
|
||||
// never escalates something that no longer exists.
|
||||
Ok(since.filter(|since| *since != 0).unwrap_or(now))
|
||||
}
|
||||
|
||||
/// Forget an op's access-deferral window, after an attempt the access check
|
||||
/// let through. The op is no longer blocked, so a later deferral is the
|
||||
/// start of a new run rather than the continuation of an old one.
|
||||
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE pending_op SET access_deferred_since = 0
|
||||
WHERE id = ?1 AND access_deferred_since <> 0",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every staged blob the queue still refers to.
|
||||
///
|
||||
/// The startup staging reconcile subtracts this from what is actually in
|
||||
/// `staging/`: the difference is bytes the kernel was told were written and
|
||||
/// that nothing is going to upload.
|
||||
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for row in rows {
|
||||
out.insert(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
|
||||
/// past [`OWN_SEALED_TTL_MS`].
|
||||
///
|
||||
/// One row per node, overwritten as the node advances. See
|
||||
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
|
||||
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
revision_id = excluded.revision_id,
|
||||
sealed_at = excluded.sealed_at",
|
||||
params![uid, revision_id, now_ms],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
|
||||
params![now_ms - OWN_SEALED_TTL_MS],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last revision of `uid` this daemon sealed itself, if it is still
|
||||
/// within [`OWN_SEALED_TTL_MS`].
|
||||
///
|
||||
/// The drain asks before treating a moved-on remote as a conflict: a
|
||||
/// revision we sealed means one writer stalled and resumed, which
|
||||
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
|
||||
/// B70 layer B).
|
||||
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT revision_id FROM own_sealed_rev
|
||||
WHERE uid = ?1 AND sealed_at >= ?2",
|
||||
params![uid, now_ms - OWN_SEALED_TTL_MS],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Forget what we sealed on `uid` — it has been trashed, so there is no
|
||||
/// revision left to chain onto.
|
||||
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a revision this daemon sealed stays recognisable as its own.
|
||||
///
|
||||
/// Bounds a table that would otherwise grow with every write for the life of
|
||||
/// the install. A week is far longer than any stall→resume — a browser download
|
||||
/// that pauses and continues, an editor that reopens its handle — and short
|
||||
/// enough that the table tracks recent work rather than accumulating history.
|
||||
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
|
||||
/// 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,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||
/// `None` keeps whatever is already stored rather than clearing it.
|
||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
/// Replace the timeline wholesale. Each item is
|
||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||
/// when the refresh could not resolve that photo's node, in which case the
|
||||
/// flag already stored is kept rather than silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
@@ -46,10 +54,14 @@ impl Db {
|
||||
// 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 learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
@@ -58,16 +70,16 @@ impl Db {
|
||||
{
|
||||
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)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
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));
|
||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let favorite = favorite.unwrap_or(learned_favorite);
|
||||
// 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.
|
||||
@@ -82,6 +94,7 @@ impl Db {
|
||||
seq as i64,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
favorite as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -93,18 +106,21 @@ impl Db {
|
||||
/// 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.
|
||||
/// `favorites` restricts the page to favourited photos.
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
// 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 sql = String::from(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||
);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if let Some(k) = kind {
|
||||
@@ -117,6 +133,9 @@ impl Db {
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("favorite = 1".to_string());
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
@@ -139,6 +158,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -154,7 +174,7 @@ impl Db {
|
||||
&self,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||
None => ("", Vec::new()),
|
||||
@@ -181,7 +201,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
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);
|
||||
@@ -203,10 +223,10 @@ impl Db {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
@@ -217,6 +237,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
@@ -226,6 +247,18 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favourite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||
params![uid, favorite as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -240,7 +273,7 @@ impl Db {
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use super::utils::MAX_PATH_DEPTH;
|
||||
use crate::Result;
|
||||
|
||||
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
|
||||
/// it, or `None` for a pin whose node is not cached. Reading the former as the
|
||||
/// latter is what made a non-recursively pinned folder render as a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinRow {
|
||||
pub uid: String,
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
pub is_dir: Option<bool>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -25,16 +38,23 @@ impl Db {
|
||||
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")?;
|
||||
/// Every directly-pinned entry, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||
let conn = self.read();
|
||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||
// NULL for a pin whose node was never cached.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.path, p.recursive, n.is_dir
|
||||
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
ORDER BY p.uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
Ok(PinRow {
|
||||
uid: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
recursive: r.get::<_, i64>(2)? != 0,
|
||||
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
@@ -47,8 +67,13 @@ impl Db {
|
||||
/// 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).
|
||||
///
|
||||
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
||||
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||
/// that runs per cached read.
|
||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let direct: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
@@ -59,14 +84,18 @@ impl Db {
|
||||
}
|
||||
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",
|
||||
&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
||||
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
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),
|
||||
)
|
||||
@@ -78,7 +107,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM pins WHERE recursive = 1
|
||||
@@ -100,7 +129,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Persisted effective access for roots shared with this account.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
|
||||
use super::Db;
|
||||
use crate::{Access, Error, Result};
|
||||
|
||||
impl Db {
|
||||
/// Load the complete, deliberately small shared-root authority map.
|
||||
///
|
||||
/// Mount state keeps this resident so ordinary node interning and subtree
|
||||
/// propagation never turn into one SQLite query per inode. The database
|
||||
/// remains the restart/offline authority and every explicit role change is
|
||||
/// written through before this cache is updated.
|
||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut access = HashMap::new();
|
||||
for row in rows {
|
||||
let (uid, value) = row?;
|
||||
let (volume, link) = uid.split_once('~').ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid shared-root uid {uid:?} stored in database"
|
||||
))
|
||||
})?;
|
||||
let access_value = Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})?;
|
||||
access.insert(
|
||||
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
access_value,
|
||||
);
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Read the effective access recorded for a shared-tree root.
|
||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.read();
|
||||
let value = conn
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Insert or replace the effective access for a shared-tree root.
|
||||
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||
params![uid.to_string(), access.as_db_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget a shared-tree root's effective access.
|
||||
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM share_access WHERE root_uid = ?1",
|
||||
[uid.to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail closed every persisted shared-tree authority.
|
||||
///
|
||||
/// Owned and device trees have no row in `share_access`, so this only
|
||||
/// affects roots already identified as shared with this account.
|
||||
pub fn downgrade_all_share_access(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute(
|
||||
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
|
||||
[],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Resolve a persisted node's effective access through its nearest recorded
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.read();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||
[uid.to_string()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = conn
|
||||
.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
||||
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
||||
a.path || a.parent_uid || char(31)
|
||||
FROM ancestors a
|
||||
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE a.parent_uid IS NOT NULL
|
||||
AND instr(
|
||||
a.path,
|
||||
char(31) || a.parent_uid || char(31)
|
||||
) = 0
|
||||
)
|
||||
SELECT sa.access
|
||||
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||
ORDER BY a.depth ASC
|
||||
LIMIT 1",
|
||||
[uid.to_string()],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
value
|
||||
.map(|value| {
|
||||
Access::from_db_str(&value).ok_or_else(|| {
|
||||
Error::Other(format!(
|
||||
"invalid effective share access {value:?} for {uid}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
.map(|access| Some(access.unwrap_or(Access::Owner)))
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||
@@ -32,7 +32,7 @@ impl Db {
|
||||
|
||||
/// Read a `sync_state` value as a string.
|
||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
@@ -66,7 +66,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
@@ -94,4 +94,26 @@ impl Db {
|
||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop every `sync_state` key starting with `prefix` — the per-entity
|
||||
/// stamps (one album, one shared folder) that are keyed by uid and so have
|
||||
/// no fixed name to clear individually.
|
||||
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
|
||||
// (a uid never does today, but this is not the place to rely on that)
|
||||
// matches literally instead of sweeping unrelated keys.
|
||||
let pattern = format!(
|
||||
"{}%",
|
||||
prefix
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
);
|
||||
conn.execute(
|
||||
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
|
||||
params![pattern],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ impl Db {
|
||||
|
||||
/// Every synced folder, oldest first.
|
||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
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",
|
||||
@@ -83,7 +83,7 @@ impl Db {
|
||||
|
||||
/// Look up one synced folder by id.
|
||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder WHERE id = ?1",
|
||||
@@ -115,14 +115,17 @@ impl Db {
|
||||
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.
|
||||
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
|
||||
/// that mode is satisfied and cleared in the same write. An opposite pending
|
||||
/// mode arrived after this transition started, so preserve it as the newer
|
||||
/// last-request intent.
|
||||
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",
|
||||
"UPDATE sync_folder
|
||||
SET mode = ?2,
|
||||
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
|
||||
WHERE id = ?1",
|
||||
params![id, mode],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -139,6 +142,18 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw a queued mode only if it is still the intent the caller
|
||||
/// inspected. A newer request must survive an older settlement attempt.
|
||||
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE sync_folder SET pending_mode = NULL
|
||||
WHERE id = ?1 AND pending_mode = ?2",
|
||||
params![id, expected],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -151,7 +166,7 @@ impl Db {
|
||||
|
||||
/// 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 conn = self.read();
|
||||
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",
|
||||
@@ -174,6 +189,28 @@ impl Db {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Whether `uid` is represented by a mirror location.
|
||||
///
|
||||
/// An on-demand location proves residency through its live inode state.
|
||||
/// Mirror locations have no FUSE state, so their folder root comes from
|
||||
/// `sync_folder` and descendants come from the last successful
|
||||
/// `sync_entry` baseline.
|
||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM sync_folder AS folder
|
||||
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
|
||||
WHERE folder.mode = 'mirror'
|
||||
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
|
||||
)",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Insert or replace one baseline row.
|
||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
@@ -39,7 +39,7 @@ impl Db {
|
||||
/// 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 conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Small query helpers shared across the table modules.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
@@ -8,14 +8,19 @@ use crate::Result;
|
||||
/// 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)?))
|
||||
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||
/// only for a row written before the `path` column existed, which
|
||||
/// [`path_of`] repairs by walking.
|
||||
pub(super) type HitRow = (String, String, Option<String>);
|
||||
|
||||
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
}
|
||||
|
||||
/// 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)>> {
|
||||
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_hits(
|
||||
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||
) -> Result<Vec<HitRow>> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
@@ -31,19 +36,58 @@ pub(super) fn like_escape(s: &str) -> String {
|
||||
.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"`.
|
||||
/// How deep the parent walk may go before it gives up. Real Drive trees are
|
||||
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||
/// directly and so never passed through the index's cycle check.
|
||||
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||
|
||||
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||
/// whose name is the mount and so contributes nothing.
|
||||
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||
if parent_path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent_path}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
///
|
||||
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||
/// column or was never written through — a search serving a keystroke used to
|
||||
/// run that walk once per candidate, which is the reason the column exists.
|
||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(
|
||||
let stored: Option<Option<String>> = conn
|
||||
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
match stored.flatten() {
|
||||
Some(path) => Ok(path),
|
||||
None => walk_path_of(conn, uid),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
||||
///
|
||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||
/// and returns the truncated path rather than hanging the caller.
|
||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"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 < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
|
||||
)?;
|
||||
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)?))
|
||||
})?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Crate-wide error type.
|
||||
|
||||
use proton_sdk::api::HumanVerification;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::control::ErrorKind;
|
||||
@@ -31,10 +32,40 @@ 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),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||
/// other I/O failure.
|
||||
///
|
||||
/// Worth distinguishing because it is the one storage failure the user can
|
||||
/// act on, and because the caller's response differs: a full disk is met by
|
||||
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||
/// compares the raw code.
|
||||
pub fn is_disk_full(&self) -> bool {
|
||||
match self {
|
||||
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||
// SQLite reports it as its own code rather than surfacing the errno.
|
||||
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||
e.code == rusqlite::ErrorCode::DiskFull
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A failure on its way to a front-end: prose for the user, plus the
|
||||
@@ -154,7 +185,9 @@ impl From<serde_json::Error> for CoreError {
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
|
||||
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,
|
||||
|
||||
@@ -2,16 +2,26 @@
|
||||
//! identity, XDG paths, and the login/session/keyring layer that every
|
||||
//! front-end (CLI today, daemon + GUI later) shares.
|
||||
|
||||
pub mod access;
|
||||
pub mod auth;
|
||||
pub mod batch;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
// Re-export the SDK surface downstream crates need, so they depend on
|
||||
|
||||
@@ -26,10 +26,20 @@ const SKIP_DIRS: &[&str] = &[
|
||||
"venv",
|
||||
".venv",
|
||||
"vendor",
|
||||
"dist",
|
||||
"dist-newstyle",
|
||||
"build",
|
||||
"Trash",
|
||||
];
|
||||
|
||||
/// Directory *paths* skipped when they end with one of these, for caches whose
|
||||
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
||||
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
||||
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
||||
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
||||
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
||||
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
@@ -90,7 +100,10 @@ pub fn scan(
|
||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||
return false;
|
||||
}
|
||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
||||
return false;
|
||||
}
|
||||
!is_skipped_path(path)
|
||||
});
|
||||
|
||||
let state = parking_lot::Mutex::new(SinkState {
|
||||
@@ -150,6 +163,17 @@ impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
||||
/// Matched on the path's own components so a directory merely *named* `mod`
|
||||
/// keeps being indexed.
|
||||
fn is_skipped_path(path: &Path) -> bool {
|
||||
let path = path.to_string_lossy();
|
||||
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
||||
path.strip_suffix(suffix)
|
||||
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||
/// JSON wire format) is UTF-8 only.
|
||||
@@ -221,4 +245,27 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// The Go module cache is the single largest source of index noise on a
|
||||
/// developer's machine (58% of the entries on the one this was measured
|
||||
/// on), and it cannot be pruned by directory name: `mod` is far too
|
||||
/// common a name to blacklist outright.
|
||||
#[test]
|
||||
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
||||
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
||||
assert!(names.contains(&"mine.conf"), "{names:?}");
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
//! Driving an external dmenu-style launcher (fuzzel, rofi, wofi, …).
|
||||
//!
|
||||
//! `pdfs-prompt` ships its own GTK HUD, but on a tiling-WM desktop the user
|
||||
//! already has a launcher bound to a key, themed to match everything else. This
|
||||
//! module lets the prompt borrow it: entries go out on the child's stdin, the
|
||||
//! chosen line comes back on stdout.
|
||||
//!
|
||||
//! Two things vary between launchers and are the only reasons this is not four
|
||||
//! lines of `Command`:
|
||||
//!
|
||||
//! * the flag that turns on filter mode and the one that sets the prompt text;
|
||||
//! * icon support, which fuzzel and rofi both spell `text\0icon\x1fNAME` and
|
||||
//! everything else would render as literal garbage.
|
||||
//!
|
||||
//! Selection is matched back by label rather than by index, because the one
|
||||
//! feature no launcher agrees on is index output. A line that matches nothing is
|
||||
//! the user's own typing — [`MenuChoice::Custom`] — which is what makes
|
||||
//! type-then-search work at all.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Separates the label from its icon name in the fuzzel/rofi protocol.
|
||||
const ICON_SEPARATOR: &str = "\0icon\x1f";
|
||||
|
||||
/// Launchers we know how to invoke, most-preferred first:
|
||||
/// `(program, filter-mode args, prompt flag, icon protocol)`.
|
||||
///
|
||||
/// The prompt flag is written as it is passed: fuzzel wants `--prompt=TEXT` as
|
||||
/// one argument, the rest take the text as a separate one.
|
||||
const KNOWN_MENUS: [MenuFlavor; 6] = [
|
||||
MenuFlavor {
|
||||
program: "fuzzel",
|
||||
mode: &["--dmenu"],
|
||||
prompt: PromptFlag::Joined("--prompt="),
|
||||
icons: true,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "rofi",
|
||||
mode: &["-dmenu"],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: true,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "wofi",
|
||||
mode: &["--dmenu"],
|
||||
prompt: PromptFlag::Separate("--prompt"),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "tofi",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Joined("--prompt-text="),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "bemenu",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: false,
|
||||
},
|
||||
MenuFlavor {
|
||||
program: "dmenu",
|
||||
mode: &[],
|
||||
prompt: PromptFlag::Separate("-p"),
|
||||
icons: false,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PromptFlag {
|
||||
/// `--prompt=TEXT`
|
||||
Joined(&'static str),
|
||||
/// `-p TEXT`
|
||||
Separate(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct MenuFlavor {
|
||||
program: &'static str,
|
||||
mode: &'static [&'static str],
|
||||
prompt: PromptFlag,
|
||||
icons: bool,
|
||||
}
|
||||
|
||||
/// Prompt behaviour from `config.json`, under `prompt`.
|
||||
///
|
||||
/// ```json
|
||||
/// "prompt": { "menu": ["fuzzel", "--dmenu", "--width=60"], "menu_limit": 40 }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PromptConfig {
|
||||
/// Which front end `pdfs-prompt` uses when invoked with no mode flag, so a
|
||||
/// desktop shortcut does not have to be re-bound to switch. `None` is
|
||||
/// [`PromptMode::Gtk`], the built-in HUD.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<PromptMode>,
|
||||
/// Launcher argv for `pdfs-prompt --dmenu`. Unset means "find one" — see
|
||||
/// [`resolve_menu`]. A `{prompt}` token anywhere in the argv is replaced by
|
||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu: Option<Vec<String>>,
|
||||
/// How many results to feed the launcher. Unset means
|
||||
/// [`DEFAULT_MENU_LIMIT`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu_limit: Option<usize>,
|
||||
/// Show icons when the launcher understands them. Defaults to true; set
|
||||
/// false for a launcher that advertises support but is themed without them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub menu_icons: Option<bool>,
|
||||
}
|
||||
|
||||
/// Which front end the prompt presents.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PromptMode {
|
||||
/// The built-in GTK HUD.
|
||||
#[default]
|
||||
Gtk,
|
||||
/// The user's own launcher, driven over stdin/stdout.
|
||||
Dmenu,
|
||||
}
|
||||
|
||||
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||
/// than the GTK prompt's cap because filtering happens launcher-side.
|
||||
pub const DEFAULT_MENU_LIMIT: usize = 50;
|
||||
|
||||
impl PromptConfig {
|
||||
pub fn resolved_mode(&self) -> PromptMode {
|
||||
self.mode.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn resolved_menu_limit(&self) -> usize {
|
||||
self.menu_limit.unwrap_or(DEFAULT_MENU_LIMIT)
|
||||
}
|
||||
|
||||
pub fn resolved_menu_icons(&self) -> bool {
|
||||
self.menu_icons.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line offered to the launcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MenuItem {
|
||||
/// What the user sees, and what identifies the choice on the way back.
|
||||
pub label: String,
|
||||
/// Icon theme name, used only by launchers that support the protocol.
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
pub fn new(label: impl Into<String>, icon: Option<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What came back from the launcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MenuChoice {
|
||||
/// Index into the items that were passed in.
|
||||
Item(usize),
|
||||
/// Text the user typed that matched no item — treat it as a query.
|
||||
Custom(String),
|
||||
/// Escape, or an empty selection.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The launcher to run: the configured one, or the first [`KNOWN_MENUS`] entry
|
||||
/// on `PATH`. Returns the argv as it should be spawned, filter-mode flags
|
||||
/// included.
|
||||
pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||
return Some(argv.clone());
|
||||
}
|
||||
let flavor = KNOWN_MENUS.iter().find(|f| on_path(f.program))?;
|
||||
let mut argv = vec![flavor.program.to_string()];
|
||||
argv.extend(flavor.mode.iter().map(|f| (*f).to_string()));
|
||||
Some(argv)
|
||||
}
|
||||
|
||||
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||
///
|
||||
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||
/// is this round-trip.
|
||||
pub fn run(argv: &[String], prompt: &str, items: &[MenuItem]) -> Result<MenuChoice> {
|
||||
let argv = with_prompt(argv, prompt);
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return Err(Error::Other("no launcher command configured".into()));
|
||||
};
|
||||
let icons = supports_icons(program);
|
||||
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| Error::Other(format!("cannot run launcher {program}: {e}")))?;
|
||||
|
||||
// Write on a worker: a launcher that starts reading only after it maps its
|
||||
// window would otherwise deadlock against a full pipe buffer.
|
||||
let payload = render(items, icons);
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| Error::Other("launcher stdin unavailable".into()))?;
|
||||
let writer = std::thread::spawn(move || {
|
||||
let _ = stdin.write_all(payload.as_bytes());
|
||||
let _ = stdin.flush();
|
||||
});
|
||||
|
||||
let mut selected = String::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
// Only the first line matters; multi-select is not a thing here, and a
|
||||
// launcher that emits more must not leave us blocked on a full pipe.
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
while reader.read_line(&mut line).unwrap_or(0) > 0 {
|
||||
if selected.is_empty() {
|
||||
selected = line.trim_end_matches('\n').to_string();
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
let _ = child.wait();
|
||||
let _ = writer.join();
|
||||
|
||||
Ok(interpret(&selected, items))
|
||||
}
|
||||
|
||||
/// Map the launcher's output back to a choice. Exposed for tests; the ambiguity
|
||||
/// it resolves (label vs. free text) is the whole contract with the launcher.
|
||||
fn interpret(selected: &str, items: &[MenuItem]) -> MenuChoice {
|
||||
// Some launchers echo the icon protocol back verbatim.
|
||||
let selected = selected
|
||||
.split(ICON_SEPARATOR)
|
||||
.next()
|
||||
.unwrap_or(selected)
|
||||
.trim_end();
|
||||
if selected.is_empty() {
|
||||
return MenuChoice::Cancelled;
|
||||
}
|
||||
match items.iter().position(|item| item.label == selected) {
|
||||
Some(index) => MenuChoice::Item(index),
|
||||
None => MenuChoice::Custom(selected.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stdin payload. Labels are stripped of newlines rather than escaped —
|
||||
/// there is no escape in this protocol, and a file name with a newline in it
|
||||
/// would otherwise become two entries.
|
||||
fn render(items: &[MenuItem], icons: bool) -> String {
|
||||
let mut out = String::new();
|
||||
for item in items {
|
||||
let label = item.label.replace(['\n', '\r'], " ");
|
||||
out.push_str(&label);
|
||||
if icons && let Some(icon) = &item.icon {
|
||||
out.push_str(ICON_SEPARATOR);
|
||||
out.push_str(icon);
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add the prompt text: substituted for `{prompt}` if the user placed one,
|
||||
/// otherwise appended using the launcher's own flag. An unrecognised launcher
|
||||
/// gets no prompt rather than a guessed flag that might not parse.
|
||||
fn with_prompt(argv: &[String], prompt: &str) -> Vec<String> {
|
||||
if argv.iter().any(|arg| arg.contains("{prompt}")) {
|
||||
return argv
|
||||
.iter()
|
||||
.map(|arg| arg.replace("{prompt}", prompt))
|
||||
.collect();
|
||||
}
|
||||
let mut argv = argv.to_vec();
|
||||
if prompt.is_empty() {
|
||||
return argv;
|
||||
}
|
||||
if let Some(flavor) = argv.first().and_then(|program| flavor_of(program)) {
|
||||
match flavor.prompt {
|
||||
PromptFlag::Joined(flag) => argv.push(format!("{flag}{prompt}")),
|
||||
PromptFlag::Separate(flag) => {
|
||||
argv.push(flag.to_string());
|
||||
argv.push(prompt.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
argv
|
||||
}
|
||||
|
||||
fn supports_icons(program: &str) -> bool {
|
||||
flavor_of(program).is_some_and(|flavor| flavor.icons)
|
||||
}
|
||||
|
||||
fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
||||
let base = Path::new(program).file_name()?.to_str()?;
|
||||
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||
}
|
||||
|
||||
fn on_path(program: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn items() -> Vec<MenuItem> {
|
||||
vec![
|
||||
MenuItem::new(
|
||||
"notes.md · My files / Docs",
|
||||
Some("text-x-generic".into()),
|
||||
),
|
||||
MenuItem::new("photo.png · Home / Pictures", None),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_matching_line_is_an_item_and_anything_else_is_a_query() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
interpret("notes.md · My files / Docs", &items),
|
||||
MenuChoice::Item(0)
|
||||
);
|
||||
assert_eq!(
|
||||
interpret("invoice", &items),
|
||||
MenuChoice::Custom("invoice".into())
|
||||
);
|
||||
assert_eq!(interpret("", &items), MenuChoice::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_echoed_icon_field_does_not_break_the_match() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
interpret(
|
||||
"notes.md · My files / Docs\0icon\x1ftext-x-generic",
|
||||
&items
|
||||
),
|
||||
MenuChoice::Item(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_are_only_rendered_for_launchers_that_understand_them() {
|
||||
let items = items();
|
||||
assert_eq!(
|
||||
render(&items, true),
|
||||
"notes.md · My files / Docs\0icon\x1ftext-x-generic\nphoto.png · Home / Pictures\n"
|
||||
);
|
||||
assert_eq!(
|
||||
render(&items, false),
|
||||
"notes.md · My files / Docs\nphoto.png · Home / Pictures\n"
|
||||
);
|
||||
assert!(supports_icons("fuzzel"));
|
||||
assert!(supports_icons("/usr/bin/rofi"));
|
||||
assert!(!supports_icons("wofi"));
|
||||
assert!(!supports_icons("mystery-menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_with_a_newline_stays_one_entry() {
|
||||
let sneaky = vec![MenuItem::new("two\nlines.txt", None)];
|
||||
assert_eq!(render(&sneaky, false), "two lines.txt\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_uses_each_launchers_own_flag() {
|
||||
assert_eq!(
|
||||
with_prompt(&["fuzzel".into(), "--dmenu".into()], "Drive"),
|
||||
vec!["fuzzel", "--dmenu", "--prompt=Drive"]
|
||||
);
|
||||
assert_eq!(
|
||||
with_prompt(&["rofi".into(), "-dmenu".into()], "Drive"),
|
||||
vec!["rofi", "-dmenu", "-p", "Drive"]
|
||||
);
|
||||
// Unknown launcher: no guessed flag.
|
||||
assert_eq!(
|
||||
with_prompt(&["mystery-menu".into()], "Drive"),
|
||||
vec!["mystery-menu"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_placeholder_wins_over_the_flag() {
|
||||
assert_eq!(
|
||||
with_prompt(
|
||||
&[
|
||||
"rofi".into(),
|
||||
"-dmenu".into(),
|
||||
"-p".into(),
|
||||
"{prompt} >".into()
|
||||
],
|
||||
"Drive"
|
||||
),
|
||||
vec!["rofi", "-dmenu", "-p", "Drive >"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_launcher_is_used_verbatim() {
|
||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||
assert_eq!(resolve_menu(Some(&configured)), Some(configured.clone()));
|
||||
// An empty list is "not configured", not "run nothing".
|
||||
assert_eq!(
|
||||
resolve_menu(Some(&Vec::new())),
|
||||
resolve_menu(None),
|
||||
"an empty menu list must fall back to detection"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Unified presentation model for every local Proton Drive location.
|
||||
//!
|
||||
//! Device sync state remains owned by `sync_folder`; these types are the stable
|
||||
//! shape exposed to front-ends after that state is joined with the `mount`
|
||||
//! presentation table.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::control::SyncProgress;
|
||||
|
||||
/// How a location makes remote content available locally.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountMode {
|
||||
/// A full local copy reconciled in both directions.
|
||||
Mirror,
|
||||
/// A FUSE session that fetches content as it is accessed.
|
||||
OnDemand,
|
||||
/// A mode written by a newer client.
|
||||
#[default]
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl MountMode {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "mirror",
|
||||
Self::OnDemand => "ondemand",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for MountMode {
|
||||
fn from(value: &str) -> Self {
|
||||
match value {
|
||||
"mirror" => Self::Mirror,
|
||||
"ondemand" => Self::OnDemand,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the location itself was declared writable.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MountAccess {
|
||||
Rw,
|
||||
Ro,
|
||||
}
|
||||
|
||||
impl MountAccess {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rw => "rw",
|
||||
Self::Ro => "ro",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MountAccess {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// What remote root a local location presents.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum MountKind {
|
||||
/// The account's primary My Files mount.
|
||||
MyFiles,
|
||||
/// A folder owned by this machine's Proton Drive device.
|
||||
Device { sync_folder_id: i64 },
|
||||
/// A future standalone mount rooted at a node shared with this account.
|
||||
Shared { share_root_uid: String },
|
||||
}
|
||||
|
||||
/// One local Proton Drive location as exposed over the control protocol.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct MountSpec {
|
||||
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
||||
/// [`MountKind::Device`], not this id.
|
||||
pub id: i64,
|
||||
pub kind: MountKind,
|
||||
pub local_path: String,
|
||||
pub root_uid: String,
|
||||
pub root_share_id: String,
|
||||
pub mode: MountMode,
|
||||
pub access: MountAccess,
|
||||
/// `idle` | `syncing` | `error` | `conflict` for device locations.
|
||||
pub state: String,
|
||||
/// Last successful device sync, epoch seconds; `0` when not applicable.
|
||||
pub last_sync: i64,
|
||||
/// A device mode switch accepted but not yet applied.
|
||||
#[serde(default)]
|
||||
pub pending_mode: Option<MountMode>,
|
||||
/// Whether a FUSE session currently owns `local_path`.
|
||||
#[serde(default)]
|
||||
pub mounted: bool,
|
||||
/// Live device sync progress, absent when no pass is running.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mount_mode_keeps_legacy_wire_strings() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::Mirror).unwrap(),
|
||||
r#""mirror""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&MountMode::OnDemand).unwrap(),
|
||||
r#""ondemand""#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
|
||||
MountMode::Mirror
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
|
||||
MountMode::OnDemand
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_mode_tolerates_unknown_values() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
|
||||
MountMode::Unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! How a file chosen in the prompt (or the browser) is actually opened.
|
||||
//!
|
||||
//! The default is `xdg-open`, which routes through the desktop's MIME
|
||||
//! associations. That is the right answer for a GNOME session and the wrong one
|
||||
//! for a tiling-WM setup where "open a text file" means "run `nvim` inside my
|
||||
//! terminal" — there is no desktop file for that, and pointing `xdg-open` at a
|
||||
//! terminal editor globally breaks every graphical caller.
|
||||
//!
|
||||
//! So the rules live in `config.json` under `open_with`: an ordered list of
|
||||
//! name/class patterns mapped to argv, each optionally wrapped in a terminal.
|
||||
//! First match wins; anything unmatched falls through to `default` (itself
|
||||
//! `xdg-open` unless overridden). Nothing here is required — an absent
|
||||
//! `open_with` reproduces the previous behaviour exactly.
|
||||
//!
|
||||
//! ```json
|
||||
//! "open_with": {
|
||||
//! "terminal": ["alacritty", "-e"],
|
||||
//! "rules": [
|
||||
//! { "match": ["@text", "*.rs", "*.toml"], "command": ["nvim"], "terminal": true },
|
||||
//! { "match": ["*.png"], "command": ["imv"] }
|
||||
//! ]
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Environment override for the terminal used by `"terminal": true` rules.
|
||||
/// Checked before `$TERMINAL` so a support case can redirect it without editing
|
||||
/// config.
|
||||
pub const TERMINAL_ENV: &str = "PDFS_TERMINAL";
|
||||
|
||||
/// Terminals we know how to hand a command line to, most-preferred first, with
|
||||
/// the argument that means "run this, don't parse it as options". `kitty` and
|
||||
/// `wezterm start --` take the command positionally.
|
||||
const KNOWN_TERMINALS: [(&str, &[&str]); 11] = [
|
||||
("alacritty", &["-e"]),
|
||||
("foot", &["-e"]),
|
||||
("kitty", &[]),
|
||||
("ghostty", &["-e"]),
|
||||
("wezterm", &["start", "--"]),
|
||||
("gnome-terminal", &["--"]),
|
||||
("konsole", &["-e"]),
|
||||
("xfce4-terminal", &["-x"]),
|
||||
("urxvt", &["-e"]),
|
||||
("st", &["-e"]),
|
||||
("xterm", &["-e"]),
|
||||
];
|
||||
|
||||
/// The opener policy as it appears in `config.json`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenWith {
|
||||
/// Terminal argv used by rules with `"terminal": true`, e.g.
|
||||
/// `["alacritty", "-e"]`. Unset means "resolve one" — see
|
||||
/// [`resolve_terminal`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub terminal: Option<Vec<String>>,
|
||||
/// What to run when no rule matches. Unset means `xdg-open`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<Vec<String>>,
|
||||
/// Ordered; first match wins.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub rules: Vec<OpenRule>,
|
||||
}
|
||||
|
||||
/// One `pattern set -> command` mapping.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenRule {
|
||||
/// Patterns this rule answers to. Either a shell-style glob against the file
|
||||
/// name (`*.md`, `notes-*.txt`) or one of the classes `@dir`, `@text`,
|
||||
/// `@document`, `@image`, `@media`, `@any`. Matching is case-insensitive.
|
||||
#[serde(rename = "match", default)]
|
||||
pub patterns: Vec<String>,
|
||||
/// argv to run. A `{}` token is replaced by the path; without one the path
|
||||
/// is appended. `$VAR`/`${VAR}` tokens expand from the environment, so
|
||||
/// `["$EDITOR"]` follows the user's editor.
|
||||
#[serde(default)]
|
||||
pub command: Vec<String>,
|
||||
/// Wrap [`command`](Self::command) in the terminal emulator.
|
||||
#[serde(default)]
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
impl OpenWith {
|
||||
/// The argv that opens `path`, terminal wrapping included. Never fails: an
|
||||
/// unmatched path — or a rule whose terminal cannot be resolved — degrades
|
||||
/// to the default opener rather than doing nothing.
|
||||
pub fn command_for(&self, path: &Path, is_dir: bool) -> Vec<String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
self.command_for_named(path, &name, is_dir)
|
||||
}
|
||||
|
||||
/// [`command_for`](Self::command_for) with the rules matched against `name`
|
||||
/// instead of the last component of `path`.
|
||||
///
|
||||
/// A materialised Drive file lives in the content cache under its content
|
||||
/// hash — no extension, no original name — so matching the on-disk path
|
||||
/// classifies every download as "unknown" and hands it to `xdg-open`, which
|
||||
/// sniffs the bytes and picks whatever claims `text/plain` (LibreOffice, for
|
||||
/// a Markdown file). The rules are about *what the user opened*, so the
|
||||
/// caller passes that name and the argv still gets the real path.
|
||||
pub fn command_for_named(&self, path: &Path, name: &str, is_dir: bool) -> Vec<String> {
|
||||
for rule in &self.rules {
|
||||
if rule.command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !rule.patterns.iter().any(|p| matches(p, name, is_dir)) {
|
||||
continue;
|
||||
}
|
||||
let argv = expand(&rule.command, path);
|
||||
if !rule.terminal {
|
||||
return argv;
|
||||
}
|
||||
match self.terminal_argv() {
|
||||
Some(mut terminal) => {
|
||||
terminal.extend(argv);
|
||||
return terminal;
|
||||
}
|
||||
// A terminal rule with no terminal to run it in would silently
|
||||
// launch a TUI with no tty. Fall through to the desktop instead.
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"no terminal found for a terminal open rule; using the default opener"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let fallback = self
|
||||
.default
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["xdg-open".to_string()]);
|
||||
expand(&fallback, path)
|
||||
}
|
||||
|
||||
/// The configured terminal, or a resolved one.
|
||||
fn terminal_argv(&self) -> Option<Vec<String>> {
|
||||
match &self.terminal {
|
||||
Some(argv) if !argv.is_empty() => Some(argv.clone()),
|
||||
_ => resolve_terminal(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch `path` under `policy`. Fire-and-forget: the child outlives us, and a
|
||||
/// spawn failure is logged rather than propagated — every caller is a UI action
|
||||
/// with nowhere useful to report to.
|
||||
pub fn open(policy: &OpenWith, path: &Path, is_dir: bool) {
|
||||
let argv = policy.command_for(path, is_dir);
|
||||
spawn(&argv, path);
|
||||
}
|
||||
|
||||
/// [`open`] for a path whose own file name says nothing about its type — see
|
||||
/// [`OpenWith::command_for_named`].
|
||||
pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
||||
let argv = policy.command_for_named(path, name, is_dir);
|
||||
spawn(&argv, path);
|
||||
}
|
||||
|
||||
fn spawn(argv: &[String], path: &Path) {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return;
|
||||
};
|
||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||
if let Err(e) = Command::new(program).args(args).spawn() {
|
||||
tracing::error!(command = ?argv, "open failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`open`] with the policy from the user's `config.json`, read once per
|
||||
/// process. For callers that open the occasional path and have no reason to
|
||||
/// carry a policy around; long-lived windows should hold their own copy.
|
||||
pub fn open_default(path: &Path, is_dir: bool) {
|
||||
open(default_policy(), path, is_dir);
|
||||
}
|
||||
|
||||
/// [`open_default`] matching the rules against `name` — see
|
||||
/// [`OpenWith::command_for_named`].
|
||||
pub fn open_default_named(path: &Path, name: &str, is_dir: bool) {
|
||||
open_named(default_policy(), path, name, is_dir);
|
||||
}
|
||||
|
||||
fn default_policy() -> &'static OpenWith {
|
||||
static POLICY: std::sync::OnceLock<OpenWith> = std::sync::OnceLock::new();
|
||||
POLICY.get_or_init(|| {
|
||||
crate::config::AppDirs::new()
|
||||
.map(|dirs| dirs.load_config().resolved_open_with())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// `$PDFS_TERMINAL`, then `$TERMINAL`, then the first [`KNOWN_TERMINALS`] entry
|
||||
/// on `PATH`. An env value may be a full command line (`"foot -e"`), in which
|
||||
/// case it is used verbatim; a bare program name gains that terminal's "run
|
||||
/// this" argument so `TERMINAL=alacritty` does the obvious thing.
|
||||
pub fn resolve_terminal() -> Option<Vec<String>> {
|
||||
for key in [TERMINAL_ENV, "TERMINAL"] {
|
||||
let Ok(value) = std::env::var(key) else {
|
||||
continue;
|
||||
};
|
||||
let argv: Vec<String> = value.split_whitespace().map(str::to_string).collect();
|
||||
match argv.len() {
|
||||
0 => continue,
|
||||
1 => return Some(with_exec_flag(&argv[0])),
|
||||
_ => return Some(argv),
|
||||
}
|
||||
}
|
||||
|
||||
KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|(program, _)| on_path(program))
|
||||
.map(|(program, _)| with_exec_flag(program))
|
||||
}
|
||||
|
||||
/// A bare terminal name plus whatever it needs before a command.
|
||||
fn with_exec_flag(program: &str) -> Vec<String> {
|
||||
let base = Path::new(program)
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or(program);
|
||||
let mut argv = vec![program.to_string()];
|
||||
let flags = KNOWN_TERMINALS
|
||||
.iter()
|
||||
.find(|(known, _)| *known == base)
|
||||
.map_or(&["-e"][..], |(_, flags)| *flags);
|
||||
argv.extend(flags.iter().map(|f| (*f).to_string()));
|
||||
argv
|
||||
}
|
||||
|
||||
fn on_path(program: &str) -> bool {
|
||||
let Some(path) = std::env::var_os("PATH") else {
|
||||
return false;
|
||||
};
|
||||
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
|
||||
}
|
||||
|
||||
/// Substitute `{}` (or append) the path, and expand `$VAR` tokens. Tokens that
|
||||
/// expand to nothing are dropped, so `["$EDITOR"]` with no `EDITOR` set leaves
|
||||
/// an empty argv and falls back at the call site.
|
||||
fn expand(argv: &[String], path: &Path) -> Vec<String> {
|
||||
let target = path.display().to_string();
|
||||
let mut out = Vec::with_capacity(argv.len() + 1);
|
||||
let mut substituted = false;
|
||||
for token in argv {
|
||||
if token.contains("{}") {
|
||||
out.push(token.replace("{}", &target));
|
||||
substituted = true;
|
||||
continue;
|
||||
}
|
||||
match expand_env(token) {
|
||||
Some(value) => out.push(value),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return vec!["xdg-open".to_string(), target];
|
||||
}
|
||||
if !substituted {
|
||||
out.push(target);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `$VAR` / `${VAR}` for a whole token, `None` when the variable is unset or
|
||||
/// empty so the token drops out. A token that is not a variable reference is
|
||||
/// returned unchanged; this is deliberately not a shell — no word splitting, no
|
||||
/// quoting rules.
|
||||
fn expand_env(token: &str) -> Option<String> {
|
||||
let Some(name) = token
|
||||
.strip_prefix("${")
|
||||
.and_then(|rest| rest.strip_suffix('}'))
|
||||
.or_else(|| token.strip_prefix('$'))
|
||||
else {
|
||||
return Some(token.to_string());
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
// Follow `$EDITOR`'s convention of preferring the graphical-capable one.
|
||||
let value = if name == "EDITOR" {
|
||||
std::env::var("VISUAL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| std::env::var("EDITOR").ok())
|
||||
} else {
|
||||
std::env::var(name).ok()
|
||||
};
|
||||
value.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Whether `pattern` — a glob or an `@class` — matches this name.
|
||||
fn matches(pattern: &str, name: &str, is_dir: bool) -> bool {
|
||||
let name_lower = name.to_lowercase();
|
||||
match pattern.to_lowercase().as_str() {
|
||||
"@any" => true,
|
||||
"@dir" => is_dir,
|
||||
"@text" => !is_dir && class_text(&name_lower),
|
||||
"@document" => !is_dir && class_document(&name_lower),
|
||||
"@image" => !is_dir && class_image(&name_lower),
|
||||
"@media" => !is_dir && class_media(&name_lower),
|
||||
other if other.starts_with('@') => false,
|
||||
other => glob_match(other, &name_lower),
|
||||
}
|
||||
}
|
||||
|
||||
fn extension(name: &str) -> &str {
|
||||
Path::new(name)
|
||||
.extension()
|
||||
.and_then(OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Plain-text-ish files a terminal editor is a sensible default for. Extensions
|
||||
/// only: the daemon's hits may name files that are not on this machine yet, so
|
||||
/// there is nothing to sniff.
|
||||
fn class_text(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"txt"
|
||||
| "md"
|
||||
| "markdown"
|
||||
| "rst"
|
||||
| "org"
|
||||
| "log"
|
||||
| "conf"
|
||||
| "cfg"
|
||||
| "ini"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "json"
|
||||
| "xml"
|
||||
| "csv"
|
||||
| "tsv"
|
||||
| "env"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "go"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "css"
|
||||
| "html"
|
||||
| "sql"
|
||||
| "lua"
|
||||
| "vim"
|
||||
| "nix"
|
||||
| "patch"
|
||||
| "diff"
|
||||
) || name == "makefile"
|
||||
|| name == "dockerfile"
|
||||
}
|
||||
|
||||
fn class_document(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"pdf"
|
||||
| "doc"
|
||||
| "docx"
|
||||
| "odt"
|
||||
| "rtf"
|
||||
| "xls"
|
||||
| "xlsx"
|
||||
| "ods"
|
||||
| "ppt"
|
||||
| "pptx"
|
||||
| "odp"
|
||||
| "epub"
|
||||
) || class_text(name)
|
||||
}
|
||||
|
||||
fn class_image(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
|
||||
)
|
||||
}
|
||||
|
||||
fn class_media(name: &str) -> bool {
|
||||
matches!(
|
||||
extension(name),
|
||||
"mp4"
|
||||
| "mkv"
|
||||
| "webm"
|
||||
| "mov"
|
||||
| "avi"
|
||||
| "mp3"
|
||||
| "flac"
|
||||
| "wav"
|
||||
| "ogg"
|
||||
| "opus"
|
||||
| "m4a"
|
||||
| "aac"
|
||||
)
|
||||
}
|
||||
|
||||
/// `*` and `?` globbing, no character classes and no path semantics — patterns
|
||||
/// are matched against a bare file name. Iterative with backtracking so a
|
||||
/// pathological pattern cannot blow the stack.
|
||||
fn glob_match(pattern: &str, name: &str) -> bool {
|
||||
let pattern: Vec<char> = pattern.chars().collect();
|
||||
let name: Vec<char> = name.chars().collect();
|
||||
let (mut p, mut n) = (0usize, 0usize);
|
||||
// Where to resume if the current `*` turns out to have matched too little.
|
||||
let (mut star, mut resume) = (None, 0usize);
|
||||
|
||||
while n < name.len() {
|
||||
match pattern.get(p) {
|
||||
Some('*') => {
|
||||
star = Some(p);
|
||||
resume = n;
|
||||
p += 1;
|
||||
}
|
||||
Some('?') => {
|
||||
p += 1;
|
||||
n += 1;
|
||||
}
|
||||
Some(c) if *c == name[n] => {
|
||||
p += 1;
|
||||
n += 1;
|
||||
}
|
||||
_ => match star {
|
||||
Some(s) => {
|
||||
p = s + 1;
|
||||
resume += 1;
|
||||
n = resume;
|
||||
}
|
||||
None => return false,
|
||||
},
|
||||
}
|
||||
}
|
||||
pattern[p..].iter().all(|c| *c == '*')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||
OpenRule {
|
||||
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||
command: command.iter().map(|c| (*c).to_string()).collect(),
|
||||
terminal,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cache_blob_is_classified_by_the_name_it_was_opened_as() {
|
||||
let policy = OpenWith {
|
||||
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||
default: None,
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
// What the daemon hands back: content-hash name, no extension. Matched
|
||||
// on its own it is "unknown" and would land in xdg-open's lap.
|
||||
let blob = Path::new("/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6");
|
||||
assert_eq!(
|
||||
policy.command_for(blob, false),
|
||||
vec![
|
||||
"xdg-open",
|
||||
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
policy.command_for_named(blob, "ONBOARDING.md", false),
|
||||
vec![
|
||||
"alacritty",
|
||||
"-e",
|
||||
"nvim",
|
||||
"/home/me/.cache/proton-drive-linux/content/364d1d8cc8e6"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_policy_is_xdg_open() {
|
||||
let policy = OpenWith::default();
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/a.md"), false),
|
||||
vec!["xdg-open".to_string(), "/tmp/a.md".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_rule_wraps_the_command() {
|
||||
let policy = OpenWith {
|
||||
terminal: Some(vec!["alacritty".into(), "-e".into()]),
|
||||
default: None,
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/notes.md"), false),
|
||||
vec!["alacritty", "-e", "nvim", "/tmp/notes.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_matching_rule_wins_and_globs_are_case_insensitive() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![
|
||||
rule(&["*.PNG"], &["imv"], false),
|
||||
rule(&["@image"], &["eog"], false),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/shot.png"), false),
|
||||
vec!["imv", "/tmp/shot.png"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placeholder_positions_the_path_instead_of_appending_it() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![rule(&["@any"], &["sh", "-c", "cat {} | less"], false)],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/a.log"), false),
|
||||
vec!["sh", "-c", "cat /tmp/a.log | less"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directories_only_match_the_dir_class() {
|
||||
let policy = OpenWith {
|
||||
terminal: None,
|
||||
default: None,
|
||||
rules: vec![
|
||||
rule(&["@text"], &["nvim"], false),
|
||||
rule(&["@dir"], &["nautilus"], false),
|
||||
],
|
||||
};
|
||||
assert_eq!(
|
||||
policy.command_for(Path::new("/tmp/notes.md"), true),
|
||||
vec!["nautilus", "/tmp/notes.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_that_cannot_find_a_terminal_falls_back_to_the_default() {
|
||||
// A terminal that is spelled out in config is always honoured; this is
|
||||
// the other path — resolution must not invent one from the test host.
|
||||
let policy = OpenWith {
|
||||
terminal: Some(Vec::new()),
|
||||
default: Some(vec!["gio".into(), "open".into()]),
|
||||
rules: vec![rule(&["@text"], &["nvim"], true)],
|
||||
};
|
||||
let argv = policy.command_for(Path::new("/tmp/a.md"), false);
|
||||
// Either a terminal exists on this machine (wrapped nvim) or it does
|
||||
// not (the default). Both are correct; what must not happen is a bare
|
||||
// `nvim` with no tty.
|
||||
assert!(
|
||||
argv == vec!["gio", "open", "/tmp/a.md"]
|
||||
|| argv.ends_with(&["nvim".to_string(), "/tmp/a.md".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_backtrack() {
|
||||
assert!(glob_match("*.md", "notes.md"));
|
||||
assert!(glob_match("*notes*.md", "my-notes-2024.md"));
|
||||
assert!(glob_match("a?c.txt", "abc.txt"));
|
||||
assert!(!glob_match("*.md", "notes.mdx"));
|
||||
assert!(!glob_match("a?c.txt", "ac.txt"));
|
||||
assert!(glob_match("*", "anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_terminal_name_gains_its_exec_flag() {
|
||||
assert_eq!(with_exec_flag("alacritty"), vec!["alacritty", "-e"]);
|
||||
assert_eq!(with_exec_flag("kitty"), vec!["kitty"]);
|
||||
assert_eq!(
|
||||
with_exec_flag("/usr/bin/wezterm"),
|
||||
vec!["/usr/bin/wezterm", "start", "--"]
|
||||
);
|
||||
// Unknown terminals get the near-universal `-e`.
|
||||
assert_eq!(with_exec_flag("mystery-term"), vec!["mystery-term", "-e"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! 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};
|
||||
|
||||
use crate::mounts::MountMode;
|
||||
|
||||
/// File name of the profile.
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// Folder, directly under the device root, that holds the profile.
|
||||
///
|
||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||
/// the profile cannot live at the device root itself even though that is where
|
||||
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
|
||||
/// the way in the web UI, and the restore ignores it when listing the device's
|
||||
/// restorable folders.
|
||||
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
|
||||
|
||||
/// 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`. Unknown values written by a newer client do not
|
||||
/// invalidate the rest of the profile.
|
||||
pub mode: MountMode,
|
||||
}
|
||||
|
||||
/// 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: MountMode::Mirror,
|
||||
}],
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
|
||||
let bytes = sample().to_bytes().unwrap();
|
||||
let json = String::from_utf8(bytes).unwrap();
|
||||
assert!(json.contains(r#""mode": "mirror""#));
|
||||
|
||||
let old = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "old-client",
|
||||
"saved_at": 1,
|
||||
"folders": [
|
||||
{
|
||||
"remote_uid": "vol~mirror",
|
||||
"local_path": "/home/me/Mirror",
|
||||
"mode": "mirror"
|
||||
},
|
||||
{
|
||||
"remote_uid": "vol~ondemand",
|
||||
"local_path": "/home/me/Cloud",
|
||||
"mode": "ondemand"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let profile = Profile::parse(old).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
|
||||
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_folder_mode_does_not_reject_profile() {
|
||||
let json = br#"{
|
||||
"version": 1,
|
||||
"device_uid": "dev-1",
|
||||
"hostname": "future-client",
|
||||
"saved_at": 1,
|
||||
"folders": [{
|
||||
"remote_uid": "vol~future",
|
||||
"local_path": "/home/me/Future",
|
||||
"mode": "streaming"
|
||||
}]
|
||||
}"#;
|
||||
let profile = Profile::parse(json).unwrap();
|
||||
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
//! A persistent [`CacheRepository`] for the SDK's Drive entity cache.
|
||||
//!
|
||||
//! The SDK caches decrypted node metadata (name, size, parent, the share whose
|
||||
//! membership signs operations on it) so navigating a tree does not re-fetch and
|
||||
//! re-decrypt what it already resolved. That cache defaults to memory, which
|
||||
//! means every daemon restart pays for the whole walk again. This backs it with
|
||||
//! SQLite so it survives one.
|
||||
//!
|
||||
//! **Its own database file, not `cache.db`.** The daemon's `Db` is a single
|
||||
//! `Mutex<Connection>` shared by every FUSE thread and the control socket; SDK
|
||||
//! cache traffic is frequent, small, and entirely reconstructible, so putting it
|
||||
//! on that mutex would add contention to the daemon's hottest lock (and to a
|
||||
//! write-ahead log whose growth is already sensitive to how long that lock is
|
||||
//! held) for no durability benefit. A separate file also means no schema
|
||||
//! migration: this store can be deleted and rebuilt at any time.
|
||||
//!
|
||||
//! **What it holds.** Decrypted metadata, never key material — the SDK keeps
|
||||
//! node keys and hash keys in a separate in-memory secret cache that nothing
|
||||
//! here can reach. The same names and sizes are already persisted in `cache.db`
|
||||
//! (`nodes`), so this file widens no exposure that the state directory did not
|
||||
//! already have; `AppDirs::ensure` is what keeps that directory private.
|
||||
//!
|
||||
//! **Staleness.** A persisted cache outlives the process, so an entry can
|
||||
//! describe a node another client has since changed. The daemon's event loop is
|
||||
//! what closes that: it resumes from a persisted cursor and calls the SDK's
|
||||
//! `invalidate_caches_for_event` for every event it replays, including the ones
|
||||
//! that happened while the daemon was down. The one case that leaves no trail is
|
||||
//! a *seeded* cursor — a first-ever mount, or a cursor the daemon lost — where
|
||||
//! nothing says what changed, so the caller [`clear`](SdkCache::clear)s this
|
||||
//! store instead of trusting it.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use proton_sdk::cache::CacheRepository;
|
||||
use proton_sdk::error::{ProtonError, Result as SdkResult};
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::config::AppDirs;
|
||||
use crate::error::Result;
|
||||
|
||||
/// Process-wide handle, so the daemon's event loop can clear the same store the
|
||||
/// client is writing through without threading it down every call.
|
||||
static SHARED: OnceLock<Arc<SdkCache>> = OnceLock::new();
|
||||
|
||||
/// SQLite-backed entity cache for the SDK.
|
||||
pub struct SdkCache {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl SdkCache {
|
||||
/// Open (creating if needed) the entity cache under the app's state
|
||||
/// directory. The first call wins; later calls return the same handle, which
|
||||
/// is what lets the event loop clear the store the Drive client is using.
|
||||
pub fn shared(dirs: &AppDirs) -> Result<Arc<Self>> {
|
||||
if let Some(existing) = SHARED.get() {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
let cache = Arc::new(Self::open(&dirs.state_dir().join("sdk_cache.db"))?);
|
||||
Ok(SHARED.get_or_init(|| cache).clone())
|
||||
}
|
||||
|
||||
/// The already-open shared cache, if one was opened in this process.
|
||||
pub fn opened() -> Option<Arc<Self>> {
|
||||
SHARED.get().cloned()
|
||||
}
|
||||
|
||||
/// Open the store at `path`, creating its schema.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
// Tags cascade with their entry, so a `set` that replaces a value cannot
|
||||
// leave the old value's tags behind pointing at the new one.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS entry (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tag_tag ON tag(tag);",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop every entry. Used when the daemon cannot prove the cache is current
|
||||
/// — a seeded event cursor — and on logout.
|
||||
pub fn clear_now(&self) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute_batch("DELETE FROM tag; DELETE FROM entry;")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_now(&self, key: &str, value: &str, tags: &[String]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO entry (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
tx.execute("DELETE FROM tag WHERE key = ?1", params![key])?;
|
||||
{
|
||||
let mut stmt = tx.prepare("INSERT INTO tag (key, tag) VALUES (?1, ?2)")?;
|
||||
for tag in tags {
|
||||
stmt.execute(params![key, tag])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_now(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT value FROM entry WHERE key = ?1")?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
match rows.next()? {
|
||||
Some(row) => Ok(Some(row.get(0)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_now(&self, key: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM entry WHERE key = ?1", params![key])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_by_tag_now(&self, tag: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"DELETE FROM entry WHERE key IN (SELECT key FROM tag WHERE tag = ?1)",
|
||||
params![tag],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entries carrying **all** of `tags` — the set intersection the SDK's
|
||||
/// `get_by_tags` contract specifies, not a union.
|
||||
fn get_by_tags_now(&self, tags: &[String]) -> Result<Vec<(String, String)>> {
|
||||
if tags.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; tags.len()].join(",");
|
||||
let sql = format!(
|
||||
"SELECT e.key, e.value FROM entry e
|
||||
JOIN tag t ON t.key = e.key
|
||||
WHERE t.tag IN ({placeholders})
|
||||
GROUP BY e.key
|
||||
HAVING COUNT(DISTINCT t.tag) = {}",
|
||||
tags.len()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(tags), |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache failure is never fatal to the operation that triggered it — the SDK
|
||||
/// treats this repository as best-effort — but it still has to come back as the
|
||||
/// SDK's error type.
|
||||
fn sdk_error(e: crate::error::Error) -> ProtonError {
|
||||
ProtonError::invalid_operation(format!("sdk entity cache: {e}"))
|
||||
}
|
||||
|
||||
// Every method is a local SQLite statement on its own connection: microseconds,
|
||||
// no network, no shared lock with the daemon's database. Running them inline on
|
||||
// the caller's task is cheaper than the thread hop that moving them off would
|
||||
// cost.
|
||||
#[async_trait]
|
||||
impl CacheRepository for SdkCache {
|
||||
async fn set(&self, key: &str, value: &str, tags: &[String]) -> SdkResult<()> {
|
||||
self.set_now(key, value, tags).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> SdkResult<Option<String>> {
|
||||
self.get_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove(&self, key: &str) -> SdkResult<()> {
|
||||
self.remove_now(key).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn remove_by_tag(&self, tag: &str) -> SdkResult<()> {
|
||||
self.remove_by_tag_now(tag).map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn clear(&self) -> SdkResult<()> {
|
||||
self.clear_now().map_err(sdk_error)
|
||||
}
|
||||
|
||||
async fn get_by_tags(&self, tags: &[String]) -> SdkResult<Vec<(String, String)>> {
|
||||
self.get_by_tags_now(tags).map_err(sdk_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cache() -> SdkCache {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE entry (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE tag (
|
||||
key TEXT NOT NULL REFERENCES entry(key) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (key, tag)
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
SdkCache {
|
||||
conn: Mutex::new(conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|t| t.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_replaces_the_value_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "one", &tags(&["a", "b"])).unwrap();
|
||||
cache.set_now("k", "two", &tags(&["b"])).unwrap();
|
||||
|
||||
assert_eq!(cache.get_now("k").unwrap().as_deref(), Some("two"));
|
||||
// The tag dropped by the second write must not still select the entry —
|
||||
// that is how a stale invalidation would miss it.
|
||||
assert!(cache.get_by_tags_now(&tags(&["a"])).unwrap().is_empty());
|
||||
assert_eq!(cache.get_by_tags_now(&tags(&["b"])).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_by_tags_intersects_rather_than_unions() {
|
||||
let cache = cache();
|
||||
cache.set_now("both", "1", &tags(&["x", "y"])).unwrap();
|
||||
cache.set_now("one", "2", &tags(&["x"])).unwrap();
|
||||
|
||||
let hits = cache.get_by_tags_now(&tags(&["x", "y"])).unwrap();
|
||||
assert_eq!(hits, vec![("both".to_string(), "1".to_string())]);
|
||||
// An empty tag list selects nothing, matching the SDK contract.
|
||||
assert!(cache.get_by_tags_now(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_by_tag_takes_the_entry_and_its_tags() {
|
||||
let cache = cache();
|
||||
cache.set_now("k", "v", &tags(&["gone"])).unwrap();
|
||||
cache.remove_by_tag_now("gone").unwrap();
|
||||
|
||||
assert!(cache.get_now("k").unwrap().is_none());
|
||||
assert!(cache.get_by_tags_now(&tags(&["gone"])).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_the_store() {
|
||||
let cache = cache();
|
||||
cache.set_now("a", "1", &tags(&["t"])).unwrap();
|
||||
cache.set_now("b", "2", &[]).unwrap();
|
||||
cache.clear_now().unwrap();
|
||||
|
||||
assert!(cache.get_now("a").unwrap().is_none());
|
||||
assert!(cache.get_now("b").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 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",
|
||||
];
|
||||
|
||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
||||
/// revision — the app renames the finished file to its real name, and only that
|
||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
||||
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||
".crdownload", // Chromium/Brave/Edge
|
||||
".part", // Firefox, wget, aria2c
|
||||
".partial", // some downloaders / older IE
|
||||
".download", // Safari / macOS
|
||||
".tmp", // generic scratch
|
||||
".temp", // generic scratch
|
||||
".swp", // vim swap
|
||||
".swx", // vim swap (secondary)
|
||||
];
|
||||
|
||||
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
|
||||
/// be uploaded on an on-demand mount until it is renamed to its finished name.
|
||||
///
|
||||
/// Recognises browser download temps, editor swap/backup files, and lock files.
|
||||
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
|
||||
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
|
||||
/// mirror-folder reconcile); keep the two conceptually in step.
|
||||
pub fn is_transient_name(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
|
||||
return true;
|
||||
}
|
||||
// Emacs/gedit/kate backup copies.
|
||||
if name.ends_with('~') {
|
||||
return true;
|
||||
}
|
||||
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
|
||||
if name.starts_with(".~lock.") || name.starts_with("~$") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_download_and_editor_names_are_recognised() {
|
||||
for name in [
|
||||
"Unconfirmed 540477.crdownload",
|
||||
"movie.mkv.part",
|
||||
"big.iso.partial",
|
||||
"photo.jpg.download",
|
||||
"export.xml.tmp",
|
||||
"notes.TEMP",
|
||||
".notes.txt.swp",
|
||||
"doc.txt~",
|
||||
".~lock.report.odt#",
|
||||
"~$budget.xlsx",
|
||||
"FOO.CRDOWNLOAD",
|
||||
] {
|
||||
assert!(is_transient_name(name), "{name} should be transient");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_names_are_not_transient() {
|
||||
for name in [
|
||||
"teamspeak.tar.gz",
|
||||
"nils.zip",
|
||||
"report.xml",
|
||||
"archive.partly.zip", // ".partly" is not ".part"
|
||||
"template.dotx",
|
||||
"readme",
|
||||
] {
|
||||
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ description = "FUSE on-demand filesystem for Proton Drive"
|
||||
[dependencies]
|
||||
pdfs-core.workspace = true
|
||||
proton-drive-rs.workspace = true
|
||||
futures.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Photo albums: the album listing, and one album's contents.
|
||||
//!
|
||||
//! Both are persisted projections of what the photos volume reports, refreshed
|
||||
//! on the same stale-while-serving rule as the timeline
|
||||
//! ([`crate::photos`]): a listing already on disk is served immediately and
|
||||
//! refreshed behind the caller, and only an empty one waits on the network.
|
||||
//!
|
||||
//! An album is a folder node carrying album properties, so it has a name like
|
||||
//! any other node; the properties supply the photo count and the cover. Albums
|
||||
//! other people share with us come from a second listing and live on *their*
|
||||
//! photos volume — which is why an album's photos are persisted per album rather
|
||||
//! than looked up in our own timeline, where a shared album's photos never
|
||||
//! appear.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
||||
use pdfs_core::db::StoredAlbum;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::warn;
|
||||
|
||||
use super::{
|
||||
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
|
||||
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
|
||||
};
|
||||
|
||||
impl Core {
|
||||
/// The album listing, served from disk and refreshed behind the caller when
|
||||
/// stale. `None` means the account has no photos volume — the same signal
|
||||
/// [`Core::photos_timeline`] gives.
|
||||
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
|
||||
let count = self.db.albums_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
// Nothing on disk, so this one request waits for the fetch — unless
|
||||
// the account is already known to have no photos volume.
|
||||
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.rt.block_on(self.refresh_albums())? {
|
||||
return Ok(None);
|
||||
}
|
||||
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||
self.spawn_albums_refresh();
|
||||
}
|
||||
|
||||
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||
Ok(Some(albums.into_iter().map(album_info).collect()))
|
||||
}
|
||||
|
||||
/// Re-enumerate the albums — ours and the ones shared with us — and replace
|
||||
/// the persisted listing. Returns false when the account has no photos
|
||||
/// volume.
|
||||
pub(crate) async fn refresh_albums(&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());
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut uids = photos
|
||||
.enumerate_album_node_uids()
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "albums"))?;
|
||||
|
||||
// Shared-with-me albums are a separate listing on the sharer's volume.
|
||||
// Best-effort: an account whose sharing listing fails still gets its own
|
||||
// albums rather than an error page.
|
||||
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
|
||||
Ok(shared) => {
|
||||
let keys = shared.iter().map(|u| u.to_string()).collect();
|
||||
uids.extend(shared);
|
||||
keys
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "enumerating shared-with-me albums failed");
|
||||
HashSet::new()
|
||||
}
|
||||
};
|
||||
|
||||
// The listings carry uids only; the name, photo count and cover come from
|
||||
// the album nodes themselves. A chunk that fails to resolve drops those
|
||||
// albums from this refresh rather than failing the whole listing.
|
||||
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
let key = node.uid.to_string();
|
||||
let props = node.album.as_ref();
|
||||
albums.push(StoredAlbum {
|
||||
shared: shared.contains(&key),
|
||||
uid: key,
|
||||
name: node.name,
|
||||
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
|
||||
cover_uid: props
|
||||
.and_then(|a| a.cover_photo_uid.as_ref())
|
||||
.map(|u| u.to_string()),
|
||||
last_activity: props.and_then(|a| a.last_activity_time),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
// Newest activity first, and albums the server gave no activity time for
|
||||
// last — an album nobody has touched belongs below the ones in use. Ties
|
||||
// fall back to the name so the order is stable between refreshes.
|
||||
albums.sort_by(|a, b| {
|
||||
b.last_activity
|
||||
.cmp(&a.last_activity)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
|
||||
self.db.albums_replace(&albums).map_err(CoreError::from)?;
|
||||
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Refresh the album listing off the request path. At most one runs at a time.
|
||||
pub(crate) fn spawn_albums_refresh(&self) {
|
||||
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let core = self.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_albums().await {
|
||||
warn!(error = %e, "background album refresh failed");
|
||||
}
|
||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
/// A page of one album's photos, newest capture first. The album's contents
|
||||
/// are fetched on first open and refreshed behind the caller afterwards, so
|
||||
/// paging never waits on the network twice.
|
||||
pub(crate) fn album_photos(
|
||||
&self,
|
||||
album: &NodeUid,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> CoreResult<Vec<PhotoItem>> {
|
||||
let key = album.to_string();
|
||||
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
|
||||
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
self.rt.block_on(self.refresh_album(album))?;
|
||||
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
|
||||
let core = self.clone();
|
||||
let album = album.clone();
|
||||
self.rt.spawn(async move {
|
||||
if let Err(e) = core.refresh_album(&album).await {
|
||||
warn!(%album, error = %e, "background album refresh failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.album_photos_page(&key, offset, limit)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
}
|
||||
|
||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let items = photos
|
||||
.enumerate_album(album)
|
||||
.await
|
||||
.map_err(|e| CoreError::from_api(&e, "album"))?;
|
||||
|
||||
// Same enrichment as the timeline: the listing carries a uid and a
|
||||
// capture time, and the gallery needs a name and media type to classify
|
||||
// each entry. Best-effort per chunk.
|
||||
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 album photo metadata 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();
|
||||
let key = album.to_string();
|
||||
self.db
|
||||
.album_photos_replace(&key, &rows)
|
||||
.map_err(CoreError::from)?;
|
||||
let _ = self
|
||||
.db
|
||||
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||
/// to the timeline can just as easily be a change to an album.
|
||||
pub(crate) fn invalidate_albums(&self) {
|
||||
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
|
||||
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project a persisted album into the wire item a front-end paints.
|
||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||
AlbumInfo {
|
||||
uid: album.uid,
|
||||
name: album.name,
|
||||
photo_count: album.photo_count,
|
||||
// A cover uid the daemon cannot parse is no cover: the front-end would
|
||||
// only ask for a thumbnail that can never be resolved.
|
||||
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
|
||||
last_activity: album.last_activity,
|
||||
shared: album.shared,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
//! Remote-event invalidation and local-index background workers.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||
/// which event carried them.
|
||||
///
|
||||
/// Returns whether this mount actually held the node, so the caller can evict
|
||||
/// its content blob once if *any* mount did.
|
||||
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
// Capture the inode before `forget` clears the uid mapping.
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let Some((parent, name)) = st.forget(uid) else {
|
||||
return false;
|
||||
};
|
||||
match child {
|
||||
Some(child) => notify.delete(parent, child, name),
|
||||
None => notify.inval_entry(parent, name),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn hide_foreign_deleted_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) {
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let dentry = child.and_then(|ino| {
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map(|entry| (entry.parent, entry.node.name.clone()))
|
||||
});
|
||||
let changed = st.downgrade_shared_subtree(uid);
|
||||
st.hide_foreign_subtree(uid);
|
||||
notify.extend_inodes(&changed);
|
||||
if let (Some((parent, name)), Some(child)) = (dentry, child) {
|
||||
notify.delete(parent, child, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the fail-closed part of an access event and install the same denial
|
||||
/// in resident state even when SQLite rejects the write. The caller must not
|
||||
/// acknowledge the event when this returns an error.
|
||||
fn apply_access_downgrade(
|
||||
db: &Db,
|
||||
event: &DriveEvent,
|
||||
mut deny: impl FnMut(Option<&NodeUid>),
|
||||
) -> pdfs_core::Result<()> {
|
||||
match event {
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
let result = db.set_share_access(node_uid, Access::Viewer);
|
||||
deny(Some(node_uid));
|
||||
result
|
||||
}
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
let result = db.downgrade_all_share_access().map(|_| ());
|
||||
deny(None);
|
||||
result
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_foreign_delete(
|
||||
db: &Db,
|
||||
uid: &NodeUid,
|
||||
mut deny_and_hide: impl FnMut(&NodeUid),
|
||||
) -> pdfs_core::Result<()> {
|
||||
let result = db.tombstone_foreign_subtree(uid);
|
||||
deny_and_hide(uid);
|
||||
result
|
||||
}
|
||||
|
||||
fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||
match event {
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
!is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. }
|
||||
) || is_foreign_node_delete(event, own_volume)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Applied to **every** mounted inode space, not just the primary one. This task
|
||||
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
||||
/// not a mount — so a file trashed from another device has to be withdrawn from
|
||||
/// whichever of our sessions is showing it, which for anything under a sync
|
||||
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
||||
/// the primary state alone left forks serving a deleted file indefinitely, the
|
||||
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||
/// mount must be notified through its **own** channel; that pairing is what
|
||||
/// [`Core::for_each_mount`] exists to preserve.
|
||||
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
||||
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
||||
let serializes_shared_publication =
|
||||
event_serializes_shared_publication(event, &core.primary_root_uid.volume_id);
|
||||
let _publication = serializes_shared_publication.then(|| core.shared_publication.lock());
|
||||
if serializes_shared_publication {
|
||||
core.invalidate_shared_refreshes();
|
||||
}
|
||||
let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event
|
||||
&& foreign_delete
|
||||
{
|
||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||
core.for_each_mount(|st, notify| {
|
||||
hide_foreign_deleted_and_notify(st, notify, uid);
|
||||
});
|
||||
})
|
||||
} else {
|
||||
apply_access_downgrade(&core.db, event, |root| {
|
||||
core.for_each_mount(|st, notify| {
|
||||
let changed = match root {
|
||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||
None => st.downgrade_known_shared_access(),
|
||||
};
|
||||
notify.extend_inodes(&changed);
|
||||
});
|
||||
})
|
||||
};
|
||||
match event {
|
||||
DriveEvent::NodeUpdated {
|
||||
node_uid,
|
||||
parent_node_uid,
|
||||
is_trashed,
|
||||
..
|
||||
} => {
|
||||
// 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).
|
||||
//
|
||||
// A write that has already drained is the same story one step later:
|
||||
// the drain brought the tree level with the revision it sealed, so
|
||||
// the feed's report of that revision has nothing to add, and acting
|
||||
// on it evicts the very bytes we uploaded. Claimed unconditionally,
|
||||
// even for a trash, so an echo we no longer care about does not
|
||||
// linger to suppress a later foreign change.
|
||||
let echo = core.take_self_change(node_uid);
|
||||
let ours = !*is_trashed && (echo || core.pending.lock().contains_key(node_uid));
|
||||
if ours {
|
||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||
}
|
||||
let mut had_node = false;
|
||||
core.for_each_mount(|st, notify| {
|
||||
if *is_trashed {
|
||||
// Trashing makes a node vanish from its parent listing.
|
||||
had_node |= forget_and_notify(st, notify, node_uid);
|
||||
} else if !ours && 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 too.
|
||||
had_node = true;
|
||||
st.invalidate_listing(ino);
|
||||
notify.inval_inode(ino);
|
||||
}
|
||||
});
|
||||
// Once, not once per mount: the content cache is per-daemon.
|
||||
if had_node {
|
||||
core.cache.evict(node_uid);
|
||||
}
|
||||
// A create, rename or move-in shows up as a change to the parent
|
||||
// listing too, which is recorded for the end of the batch rather
|
||||
// than acted on here — see [`DirtyParents`].
|
||||
if let Some(parent_uid) = parent_node_uid {
|
||||
match ours {
|
||||
true => dirty
|
||||
.ours
|
||||
.entry(parent_uid.clone())
|
||||
.or_default()
|
||||
.insert(node_uid.clone()),
|
||||
false => dirty.foreign.insert(parent_uid.clone()),
|
||||
};
|
||||
}
|
||||
}
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
core.cache.evict(node_uid);
|
||||
if !foreign_delete {
|
||||
core.for_each_mount(|st, notify| {
|
||||
forget_and_notify(st, notify, node_uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Losing event continuity or any shared-access signal makes persisted
|
||||
// shared roots untrustworthy. Keep owned/device trees unchanged, but
|
||||
// fail every known shared subtree closed until a root refresh carries a
|
||||
// current membership role.
|
||||
DriveEvent::ContinuityLost { .. }
|
||||
| DriveEvent::ScopeAccessLost { .. }
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||
core.for_each_mount(|st, notify| {
|
||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||
for &ino in &dirs {
|
||||
st.invalidate_listing(ino);
|
||||
notify.inval_inode(ino);
|
||||
}
|
||||
});
|
||||
}
|
||||
// No substantive local change; the cursor advance is handled by the
|
||||
// caller persisting the event id.
|
||||
DriveEvent::CursorAdvanced { .. } => {}
|
||||
}
|
||||
apply_result
|
||||
}
|
||||
|
||||
fn acknowledge_applied_event(
|
||||
db: &Db,
|
||||
event: &DriveEvent,
|
||||
applied: pdfs_core::Result<()>,
|
||||
) -> pdfs_core::Result<DriveEventId> {
|
||||
applied?;
|
||||
let id = event.id().clone();
|
||||
db.set_event_cursor(id.as_str())?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The folders one batch of events changed, collected so the listings are
|
||||
/// dropped once at the end of the batch rather than once per event.
|
||||
///
|
||||
/// Both halves of that matter. A burst of uploads produces one event per file
|
||||
/// all naming the *same* folder, and dropping the listing per event made every
|
||||
/// interleaved lookup re-enumerate that folder from the API — quadratic in its
|
||||
/// size, and slow enough to stall a busy mount. Splitting foreign changes from
|
||||
/// our own then removes the re-enumeration entirely for the common case: a file
|
||||
/// this daemon created is already in the tree under the right parent, so there
|
||||
/// is nothing for a re-enumeration to discover.
|
||||
///
|
||||
/// Neither is a weakening. The state after the batch is what it always was.
|
||||
#[derive(Default)]
|
||||
struct DirtyParents {
|
||||
/// Folders changed by someone else. Their listings must go: an event names
|
||||
/// no name, so a rename inside a folder is indistinguishable from a write to
|
||||
/// a file in it, and only re-enumerating can tell us which happened.
|
||||
foreign: HashSet<NodeUid>,
|
||||
/// Folders changed by this daemon, and the children the changes were about.
|
||||
/// Checked per mount — a mount that already lists every one of those
|
||||
/// children under that parent keeps its listing.
|
||||
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||
}
|
||||
|
||||
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||
/// `child_uid`, so a change we made to that child leaves nothing to re-read.
|
||||
///
|
||||
/// "Nothing cached" counts as current: an absent listing is re-enumerated on the
|
||||
/// next `readdir` regardless, so there is nothing to drop.
|
||||
fn listing_accounts_for(st: &State, parent_uid: &NodeUid, child_uid: &NodeUid) -> bool {
|
||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||
return true;
|
||||
};
|
||||
let Some(children) = st.children.get(&parent) else {
|
||||
return true;
|
||||
};
|
||||
let Some(&child) = st.by_uid.get(child_uid) else {
|
||||
return false;
|
||||
};
|
||||
children.contains(&child) && st.entries.get(&child).is_some_and(|e| e.parent == parent)
|
||||
}
|
||||
|
||||
/// Drop the cached listing of every folder a batch of events changed, in every
|
||||
/// mount that holds one, so the next `readdir`/`lookup` re-enumerates it.
|
||||
///
|
||||
/// Runs strictly after the whole batch is applied: doing it per event would
|
||||
/// leave a window where a concurrent `readdir` re-populates a listing that a
|
||||
/// later event in the same batch then has to drop again.
|
||||
fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||
return;
|
||||
}
|
||||
fn drop_listing(st: &mut State, notify: &mut NotifyBatch, parent_uid: &NodeUid) {
|
||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||
return;
|
||||
};
|
||||
st.invalidate_listing(parent);
|
||||
notify.inval_inode(parent);
|
||||
}
|
||||
core.for_each_mount(|st, notify| {
|
||||
for parent_uid in &dirty.foreign {
|
||||
drop_listing(st, notify, parent_uid);
|
||||
}
|
||||
for (parent_uid, children) in &dirty.ours {
|
||||
if dirty.foreign.contains(parent_uid) {
|
||||
continue;
|
||||
}
|
||||
// One child this mount has not placed is enough: the listing it is
|
||||
// serving is missing something the user just made.
|
||||
if children
|
||||
.iter()
|
||||
.all(|child| listing_accounts_for(st, parent_uid, child))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
drop_listing(st, notify, parent_uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||
/// to be applied to every mounted inode space, and the registry that enumerates
|
||||
/// them lives on the `Core` (see [`apply_event`]).
|
||||
pub(super) async fn run_event_sync(
|
||||
client: ProtonDriveClient,
|
||||
scope: DriveEventScopeId,
|
||||
core: Core,
|
||||
) {
|
||||
let db = core.db.clone();
|
||||
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) => {
|
||||
// Nothing says what changed before this cursor, so a persisted SDK
|
||||
// entity cache from an earlier run cannot be trusted: drop it rather
|
||||
// than serve metadata no event will ever invalidate.
|
||||
if let Some(cache) = pdfs_core::sdkcache::SdkCache::opened()
|
||||
&& let Err(e) = cache.clear_now()
|
||||
{
|
||||
warn!(error = %e, "clearing the persisted SDK entity cache failed");
|
||||
}
|
||||
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());
|
||||
let Some(c) = &head else {
|
||||
break None;
|
||||
};
|
||||
if let Err(e) = db.set_event_cursor(c.as_str()) {
|
||||
warn!(error = %e, ?delay, "persist seed cursor failed; retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(ONLINE_PROBE_MAX);
|
||||
continue;
|
||||
}
|
||||
break Some(c.clone());
|
||||
}
|
||||
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");
|
||||
// Folders whose listing this batch invalidates, applied once at the end
|
||||
// rather than per event — see [`DirtyParents`]. Every `break` below falls
|
||||
// through to that flush, so an interrupted batch still publishes what it
|
||||
// did apply.
|
||||
let mut dirty = DirtyParents::default();
|
||||
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; retaining cursor for retry");
|
||||
break;
|
||||
}
|
||||
let applied = match acknowledge_applied_event(
|
||||
&db,
|
||||
event,
|
||||
apply_event(&core, event, &mut dirty),
|
||||
) {
|
||||
Ok(applied) => applied,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Advance only after this exact event is durably acknowledged. If a
|
||||
// later event fails, polling resumes from here and replays nothing
|
||||
// that was not already made visible locally.
|
||||
cursor = Some(applied);
|
||||
}
|
||||
flush_dirty_parents(&core, &dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
fn folder(volume: &str, link: &str, parent: Option<NodeUid>, name: &str) -> Node {
|
||||
Node {
|
||||
uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||
parent_uid: parent,
|
||||
kind: NodeKind::Folder,
|
||||
name: name.to_string(),
|
||||
creation_time: 1,
|
||||
modification_time: 1,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
||||
let own_volume = VolumeId::from("own");
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete"),
|
||||
node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
let generation = AtomicU64::new(7);
|
||||
let captured = generation.load(Ordering::SeqCst);
|
||||
|
||||
assert!(event_serializes_shared_publication(&event, &own_volume));
|
||||
generation.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
assert!(!refresh_generation_is_current(
|
||||
captured,
|
||||
generation.load(Ordering::SeqCst)
|
||||
));
|
||||
let own_delete = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("own-delete"),
|
||||
node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
assert!(!event_serializes_shared_publication(
|
||||
&own_delete,
|
||||
&own_volume
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = folder("own", "root", None, "My Files");
|
||||
let deleted = folder(
|
||||
"foreign",
|
||||
"deleted",
|
||||
Some(root.uid.clone()),
|
||||
"Deleted foreign",
|
||||
);
|
||||
let deep = folder(
|
||||
"foreign",
|
||||
"deep",
|
||||
Some(deleted.uid.clone()),
|
||||
"Searchable descendant",
|
||||
);
|
||||
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||
.unwrap();
|
||||
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_RENAME.to_string(),
|
||||
uid: deep.uid.to_string(),
|
||||
parent_uid: Some(deleted.uid.to_string()),
|
||||
name: Some("pending".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: Some("{}".to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete-success"),
|
||||
node_uid: deleted.uid.clone(),
|
||||
parent_node_uid: deleted.parent_uid.clone(),
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||
|
||||
assert!(denied.get());
|
||||
assert_eq!(acknowledged.as_str(), "foreign-delete-success");
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("foreign-delete-success")
|
||||
);
|
||||
for uid in [&deleted.uid, &deep.uid] {
|
||||
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
|
||||
}
|
||||
assert!(db.search("Searchable", 10).unwrap().is_empty());
|
||||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||
assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = folder("own", "root", None, "My Files");
|
||||
let deleted = folder(
|
||||
"foreign",
|
||||
"deleted",
|
||||
Some(root.uid.clone()),
|
||||
"Deleted foreign",
|
||||
);
|
||||
let deep = folder(
|
||||
"foreign",
|
||||
"deep",
|
||||
Some(deleted.uid.clone()),
|
||||
"Still searchable",
|
||||
);
|
||||
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||
.unwrap();
|
||||
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||
db.set_event_cursor("prior-event").unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_foreign_tombstone
|
||||
BEFORE UPDATE OF trashed ON nodes
|
||||
WHEN OLD.uid = 'foreign~deleted'
|
||||
BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("foreign-delete-failed"),
|
||||
node_uid: deleted.uid.clone(),
|
||||
parent_node_uid: deleted.parent_uid.clone(),
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||
|
||||
assert!(applied.is_err());
|
||||
assert!(
|
||||
denied.get(),
|
||||
"resident state must fail closed on DB failure"
|
||||
);
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("prior-event")
|
||||
);
|
||||
assert!(
|
||||
!db.node_by_uid(&deleted.uid.to_string())
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.trashed
|
||||
);
|
||||
assert_eq!(db.search("searchable", 10).unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
db.share_access(&deleted.uid).unwrap(),
|
||||
Some(Access::Editor),
|
||||
"the failed atomic transaction must not partially publish"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("shared");
|
||||
db.set_share_access(&root, Access::Editor).unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_share_update
|
||||
BEFORE UPDATE ON share_access
|
||||
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("event-1"),
|
||||
node_uid: root,
|
||||
parent_node_uid: None,
|
||||
};
|
||||
let denied = Cell::new(false);
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||
denied.set(root.is_some());
|
||||
});
|
||||
assert!(applied.is_err());
|
||||
assert!(denied.get(), "resident state must fail closed immediately");
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert!(
|
||||
db.get_event_cursor().unwrap().is_none(),
|
||||
"failed downgrade must leave the event unacknowledged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("resident-without-row");
|
||||
assert!(db.share_access(&root).unwrap().is_none());
|
||||
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
|
||||
root.clone(),
|
||||
Access::Editor,
|
||||
)]));
|
||||
let event = DriveEvent::NodeDeleted {
|
||||
id: DriveEventId::from("event-missing-row"),
|
||||
node_uid: root.clone(),
|
||||
parent_node_uid: None,
|
||||
};
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |target| {
|
||||
assert_eq!(target, Some(&root));
|
||||
resident.borrow_mut().insert(root.clone(), Access::Viewer);
|
||||
});
|
||||
assert!(applied.is_ok());
|
||||
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
|
||||
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
|
||||
|
||||
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||
assert_eq!(acknowledged.as_str(), "event-missing-row");
|
||||
assert_eq!(
|
||||
db.get_event_cursor().unwrap().as_deref(),
|
||||
Some("event-missing-row")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||
let root = uid("shared");
|
||||
db.set_share_access(&root, Access::Editor).unwrap();
|
||||
db.with_conn(|conn| {
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER reject_all_share_updates
|
||||
BEFORE UPDATE ON share_access
|
||||
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let event = DriveEvent::ContinuityLost {
|
||||
id: DriveEventId::from("event-2"),
|
||||
};
|
||||
let denied_all = Cell::new(false);
|
||||
|
||||
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||
denied_all.set(root.is_none());
|
||||
});
|
||||
assert!(applied.is_err());
|
||||
assert!(
|
||||
denied_all.get(),
|
||||
"all resident shared roots must fail closed immediately"
|
||||
);
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
assert!(db.get_event_cursor().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@
|
||||
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 std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
@@ -27,6 +27,83 @@ use tracing::{info, warn};
|
||||
use super::transfers::CountingReader;
|
||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// A client that connects, sends a request and then never reads its reply used
|
||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||
/// responses (photo bytes) over a local socket, finite so a wedged peer can't
|
||||
/// hold a permit.
|
||||
const CONTROL_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_CONTROL_HANDLERS: usize = 64;
|
||||
/// Ceiling on any client-supplied row `limit`. Every in-tree caller asks for far
|
||||
/// less (200 for activity, 60 for a photo page), but the value goes straight
|
||||
/// into `SQL LIMIT ?` under the daemon's single connection — an unclamped one
|
||||
/// materialises the whole account, and then serialises it into one JSON line.
|
||||
const MAX_ROW_LIMIT: usize = 1000;
|
||||
|
||||
/// Clamp a client-supplied row limit to [`MAX_ROW_LIMIT`].
|
||||
fn clamp_limit(limit: usize) -> usize {
|
||||
limit.min(MAX_ROW_LIMIT)
|
||||
}
|
||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct ControlHandlerPermit;
|
||||
|
||||
impl ControlHandlerPermit {
|
||||
fn acquire() -> Option<Self> {
|
||||
ACTIVE_CONTROL_HANDLERS
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
||||
})
|
||||
.ok()
|
||||
.map(|_| Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlHandlerPermit {
|
||||
fn drop(&mut self) {
|
||||
ACTIVE_CONTROL_HANDLERS.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
break;
|
||||
}
|
||||
let take = available
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\n')
|
||||
.map_or(available.len(), |index| index + 1);
|
||||
if bytes.len().saturating_add(take) > MAX_CONTROL_REQUEST_BYTES as usize {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"control request exceeds size limit",
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&available[..take]);
|
||||
reader.consume(take);
|
||||
if bytes.ends_with(b"\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if bytes.len() as u64 > MAX_CONTROL_REQUEST_BYTES || !bytes.ends_with(b"\n") {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"control request exceeds limit or is not newline terminated",
|
||||
));
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -44,6 +121,14 @@ fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
/// 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) {
|
||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set request timeout");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = stream.set_write_timeout(Some(CONTROL_WRITE_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set response timeout");
|
||||
return;
|
||||
}
|
||||
let mut reader = BufReader::new(match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -51,15 +136,25 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
return;
|
||||
}
|
||||
});
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_err() || line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let line = match read_request_line(&mut reader) {
|
||||
Ok(Some(line)) if !line.trim().is_empty() => line,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "control: rejected request");
|
||||
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();
|
||||
let staging = core.cache.staging_usage();
|
||||
CtlResponse::Status {
|
||||
parked_uploads: queued.parked.max(0) as u64,
|
||||
failing_ops: queued.failing.max(0) as u64,
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
@@ -73,18 +168,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.pin(&rel) {
|
||||
Ok(name) => CtlResponse::Ok {
|
||||
message: format!("pinned {name}"),
|
||||
},
|
||||
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) => CtlResponse::Ok {
|
||||
message: format!("unpinned {name}"),
|
||||
},
|
||||
Ok(name) => {
|
||||
core.touch_profile();
|
||||
CtlResponse::Ok {
|
||||
message: format!("unpinned {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -110,6 +211,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
RefreshScope::Photos => {
|
||||
core.invalidate_photos();
|
||||
// Whatever changed the timeline can just as easily have
|
||||
// changed an album; one scope covers the whole photos view.
|
||||
core.invalidate_albums();
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
@@ -125,7 +229,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
limit,
|
||||
kind,
|
||||
range,
|
||||
}) => match core.photos_timeline(offset, limit, kind, range) {
|
||||
favorites,
|
||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -138,6 +243,44 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: if favorite {
|
||||
"added to favourites".to_string()
|
||||
} else {
|
||||
"removed from favourites".to_string()
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("invalid uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
|
||||
Ok(Some(items)) => CtlResponse::Albums {
|
||||
available: true,
|
||||
items,
|
||||
},
|
||||
Ok(None) => CtlResponse::Albums {
|
||||
available: false,
|
||||
items: Vec::new(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AlbumPhotos { uid, offset, limit }) => match parse_uid(&uid) {
|
||||
// An album page is a timeline page as far as the front-end is
|
||||
// concerned, so it comes back in the same reply — with `counts`
|
||||
// unset, which describe the whole timeline rather than this album.
|
||||
Some(album) => match core.album_photos(&album, offset, clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
counts: None,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||
},
|
||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||
Ok(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
@@ -215,23 +358,42 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.open_file(&rel) {
|
||||
// A uid addresses the node wherever it lives; the path only works for
|
||||
// the primary mount's tree. Prefer the uid when the client sent one.
|
||||
Ok(CtlRequest::OpenFile { path, uid }) => {
|
||||
let opened = match uid.as_deref() {
|
||||
Some(uid) => core.open_file_uid(uid),
|
||||
None => rel_to_mount(mountpoint, &path).and_then(|rel| core.open_file(&rel)),
|
||||
};
|
||||
match opened {
|
||||
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(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(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),
|
||||
Ok(CtlRequest::SearchLocal { query, limit }) => {
|
||||
match core.search_local(&query, clamp_limit(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, clamp_limit(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),
|
||||
},
|
||||
@@ -404,6 +566,45 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
||||
Ok(stats) => {
|
||||
// The deep check reads every page — tens of seconds on a real
|
||||
// database, all of it holding the connection every FUSE handler
|
||||
// needs — so it runs on a thread and this request returns
|
||||
// whatever the last completed check found. `integrity_checked`
|
||||
// tells the caller which answer it is holding, since "no
|
||||
// problems found" and "never looked" are both an empty list;
|
||||
// `integrity_running` says a fresh answer is on its way.
|
||||
let (integrity_problems, integrity_checked, integrity_running) = if deep {
|
||||
core.start_or_read_integrity_check()
|
||||
} else {
|
||||
(Vec::new(), false, false)
|
||||
};
|
||||
CtlResponse::CacheReport {
|
||||
integrity_running,
|
||||
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:?}"))),
|
||||
},
|
||||
// Ack and vacuum on a thread. `VACUUM` rewrites the whole database
|
||||
// under the connection mutex — minutes on a large one — and the
|
||||
// request's own socket timeout is 10 s, so running it inline both froze
|
||||
// the mount and lost its own answer. Progress shows up as a job in
|
||||
// `GetQueueStatus`; the outcome goes to the log.
|
||||
Ok(CtlRequest::CacheVacuum) => match core.start_vacuum() {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "compacting the cache database in the background".into(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
||||
items: core.transfers.snapshot(),
|
||||
jobs: core.jobs_snapshot(),
|
||||
@@ -417,9 +618,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
cfg.cache_budget = Some(bytes);
|
||||
dirs.save_config(&cfg)
|
||||
}) {
|
||||
Ok(Ok(())) => CtlResponse::Ok {
|
||||
message: format!("cache budget set to {bytes} bytes"),
|
||||
},
|
||||
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}"
|
||||
))),
|
||||
@@ -432,6 +636,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(items) => CtlResponse::Devices { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListLocations) => match core.list_locations() {
|
||||
Ok(items) => CtlResponse::Locations { 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}"),
|
||||
@@ -444,6 +652,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
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
|
||||
@@ -455,6 +667,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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())
|
||||
@@ -483,15 +696,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => {
|
||||
match core.remove_sync_folder(id, delete_remote) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "removed synced folder".to_string(),
|
||||
},
|
||||
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 }
|
||||
}
|
||||
@@ -507,6 +724,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -532,6 +771,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ShareNodeByUid {
|
||||
uid,
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
}) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) {
|
||||
Ok((proton, external)) => {
|
||||
core.log_activity(
|
||||
ActivityKind::Share,
|
||||
&uid,
|
||||
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, &uid, &e, false);
|
||||
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 },
|
||||
@@ -539,6 +800,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) {
|
||||
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdateShareRole {
|
||||
path,
|
||||
id,
|
||||
@@ -553,6 +818,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdateShareRoleByUid {
|
||||
uid,
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
}) => match core.update_share_role_by_uid(&uid, &id, kind, &role) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("role updated to {role}"),
|
||||
},
|
||||
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) {
|
||||
@@ -567,6 +843,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => {
|
||||
match core.remove_share_entry_by_uid(&uid, &id, kind) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &uid, "access removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::CreatePublicLink {
|
||||
path,
|
||||
role,
|
||||
@@ -585,6 +872,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::CreatePublicLinkByUid {
|
||||
uid,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) {
|
||||
Ok(link) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &uid, "link created", true);
|
||||
CtlResponse::PublicLink { link }
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::PublicLink, &uid, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
@@ -597,10 +899,110 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => {
|
||||
match core.remove_public_link_by_uid(&uid, &id) {
|
||||
Ok(()) => {
|
||||
core.log_activity(ActivityKind::Unshare, &uid, "link removed", true);
|
||||
CtlResponse::Ok {
|
||||
message: "public link removed".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.list_revisions(&rel) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListRevisionsByUid { uid }) => match core.list_revisions_by_uid(&uid) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RestoreRevision { path, revision_id }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.restore_revision(&rel, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("restoring version {revision_id} of {path}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::RestoreRevisionByUid { uid, revision_id }) => {
|
||||
match core.restore_revision_by_uid(&uid, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("restoring version {revision_id}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DeleteRevision { path, revision_id }) => {
|
||||
match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.delete_revision(&rel, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("deleted version {revision_id} of {path}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::DeleteRevisionByUid { uid, revision_id }) => {
|
||||
match core.delete_revision_by_uid(&uid, &revision_id) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: format!("deleted version {revision_id}"),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SaveRevisionAs {
|
||||
path,
|
||||
revision_id,
|
||||
dest,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.save_revision_as(&rel, &revision_id, Path::new(&dest)) {
|
||||
Ok(written) => CtlResponse::Ok {
|
||||
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SaveRevisionAsByUid {
|
||||
uid,
|
||||
revision_id,
|
||||
dest,
|
||||
}) => match core.save_revision_as_by_uid(&uid, &revision_id, Path::new(&dest)) {
|
||||
Ok(written) => CtlResponse::Ok {
|
||||
message: format!("wrote {} to {dest}", human_bytes(written)),
|
||||
},
|
||||
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);
|
||||
@@ -649,7 +1051,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
||||
items: core.list_activity(limit),
|
||||
items: core.list_activity(clamp_limit(limit)),
|
||||
},
|
||||
// The request did not parse: the caller sent something malformed.
|
||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||
@@ -684,12 +1086,22 @@ pub(crate) fn run_control_socket(
|
||||
for conn in listener.incoming() {
|
||||
match conn {
|
||||
Ok(stream) => {
|
||||
let Some(permit) = ControlHandlerPermit::acquire() else {
|
||||
warn!(
|
||||
limit = MAX_CONTROL_HANDLERS,
|
||||
"control: rejecting connection at handler limit"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
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))
|
||||
.spawn(move || {
|
||||
let _permit = permit;
|
||||
handle_control_conn(&core, &username, &mountpoint, stream);
|
||||
})
|
||||
{
|
||||
warn!(error = %e, "control: spawn handler failed");
|
||||
}
|
||||
@@ -700,3 +1112,38 @@ pub(crate) fn run_control_socket(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod request_limit_tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn accepts_one_bounded_newline_terminated_request() {
|
||||
let mut input = Cursor::new(b"{\"Status\":null}\n".to_vec());
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap().as_deref(),
|
||||
Some("{\"Status\":null}\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_request_without_a_terminating_newline() {
|
||||
let mut input = Cursor::new(b"{\"Status\":null}".to_vec());
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_a_request_larger_than_the_limit() {
|
||||
let mut bytes = vec![b'x'; MAX_CONTROL_REQUEST_BYTES as usize + 1];
|
||||
bytes.push(b'\n');
|
||||
let mut input = Cursor::new(bytes);
|
||||
assert_eq!(
|
||||
read_request_line(&mut input).unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,26 +11,128 @@ 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::batch;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
|
||||
SyncProgress,
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
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, now_secs, parse_uid, sync_folder_info, this_hostname,
|
||||
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
|
||||
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
||||
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||
};
|
||||
|
||||
/// A mode that this daemon is allowed to apply.
|
||||
///
|
||||
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
||||
/// forward-compatible unknown values never reach either destructive transition.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ApplicableMountMode {
|
||||
Mirror,
|
||||
OnDemand,
|
||||
}
|
||||
|
||||
impl ApplicableMountMode {
|
||||
fn parse(value: &str) -> Result<Self, String> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Ok(Self::Mirror),
|
||||
MountMode::OnDemand => Ok(Self::OnDemand),
|
||||
MountMode::Unknown => Err(format!("unknown mode {value:?} (want mirror|ondemand)")),
|
||||
}
|
||||
}
|
||||
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Mirror => "mirror",
|
||||
Self::OnDemand => "ondemand",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_mode_transition(
|
||||
current: &str,
|
||||
target: &str,
|
||||
) -> Result<Option<ApplicableMountMode>, String> {
|
||||
let current = ApplicableMountMode::parse(current)
|
||||
.map_err(|error| format!("unsupported current mode: {error}"))?;
|
||||
let target = ApplicableMountMode::parse(target)
|
||||
.map_err(|error| format!("unsupported target mode: {error}"))?;
|
||||
Ok((current != target).then_some(target))
|
||||
}
|
||||
|
||||
fn restore_snapshot_matches(snapshot: &StoredSyncFolder, current: &StoredSyncFolder) -> bool {
|
||||
current.id == snapshot.id
|
||||
&& current.local_path == snapshot.local_path
|
||||
&& current.remote_uid == snapshot.remote_uid
|
||||
&& current.remote_share_id == snapshot.remote_share_id
|
||||
&& current.mode == "ondemand"
|
||||
}
|
||||
|
||||
fn pending_intent_matches(pending: Option<&str>, expected: Option<&str>) -> bool {
|
||||
expected.is_none_or(|expected| pending == Some(expected))
|
||||
}
|
||||
|
||||
fn commit_after_teardown<T, E>(
|
||||
teardown: Result<(), E>,
|
||||
commit: impl FnOnce() -> T,
|
||||
) -> Result<T, E> {
|
||||
teardown?;
|
||||
Ok(commit())
|
||||
}
|
||||
|
||||
/// 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 ----------------------------------------------------------
|
||||
|
||||
/// Unified local-location listing for the control protocol.
|
||||
pub(crate) fn list_locations(&self) -> CoreResult<Vec<MountSpec>> {
|
||||
let progress = self.sync_progress.lock();
|
||||
let mut locations = self
|
||||
.db
|
||||
.mount_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
for location in &mut locations {
|
||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||
location.progress = progress.get(sync_folder_id).cloned();
|
||||
}
|
||||
}
|
||||
Ok(locations)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -42,12 +144,14 @@ impl Core {
|
||||
// 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(),
|
||||
@@ -78,21 +182,97 @@ impl Core {
|
||||
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 both to validate any cached
|
||||
// record and to recover an existing device by name.
|
||||
// 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
|
||||
@@ -204,6 +384,15 @@ impl Core {
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
// Syncing a local `.proton-drive-linux` would land on the folder holding
|
||||
// this device's own profile (`find_device_child_folder` reuses by name),
|
||||
// and the sync engine would then delete or overwrite it.
|
||||
if name == pdfs_core::profile::PROFILE_DIR_NAME {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{name} is reserved for this device's profile backup"
|
||||
)));
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -335,6 +524,8 @@ impl Core {
|
||||
/// 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 lock = self.sync_lock(id);
|
||||
let _guard = lock.lock();
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
@@ -345,19 +536,23 @@ impl Core {
|
||||
// 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) = self.mounts.lock().remove(&id) {
|
||||
if let Err(e) = session.umount_and_join() {
|
||||
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]))
|
||||
// Taken in its own statement so the registry lock is released before the
|
||||
// teardown: an edition-2024 `if let` chain holds the guard for the whole
|
||||
// body, and `teardown` unmounts and joins the session thread — which can
|
||||
// be inside a multi-second transfer. The shutdown path takes the same
|
||||
// lock, so holding it here risks systemd's stop timeout landing mid-
|
||||
// unmount and stranding the endpoint.
|
||||
let taken = self.mounts.lock().remove(&id);
|
||||
if let Some(mount) = taken
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(CoreError::internal(format!(
|
||||
"cannot remove synced folder: unmount {} failed: {error}",
|
||||
folder.local_path
|
||||
)));
|
||||
}
|
||||
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
||||
if !self
|
||||
.db
|
||||
.sync_folder_remove(id)
|
||||
@@ -365,8 +560,18 @@ impl Core {
|
||||
{
|
||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||
}
|
||||
drop(_guard);
|
||||
// Stop watching the folder we just dropped.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
if delete_remote
|
||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||
&& let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(&[uid]))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
warn!(id, error = %e, "delete remote device folder failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -385,15 +590,16 @@ impl Core {
|
||||
/// (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,
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
db: self.db.clone(),
|
||||
}));
|
||||
fork.primary = false;
|
||||
let share_access = self.state().share_access.clone();
|
||||
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||
// 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());
|
||||
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
// Size upgrades are keyed by inode, which is per-fork too.
|
||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||
fork
|
||||
}
|
||||
|
||||
@@ -409,49 +615,84 @@ impl Core {
|
||||
/// 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)"
|
||||
)));
|
||||
}
|
||||
// Preserve the existing control behavior: reject an unsupported request
|
||||
// before looking up the folder, then validate the persisted source too.
|
||||
let target_mode = ApplicableMountMode::parse(mode).map_err(CoreError::invalid)?;
|
||||
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}"));
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||
if current_mode == target_mode {
|
||||
let mode_str = target_mode.as_str();
|
||||
let lock = self.sync_lock(id);
|
||||
match lock.try_lock() {
|
||||
Some(_guard) => {
|
||||
let current = 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}"))
|
||||
})?;
|
||||
let locked_mode =
|
||||
ApplicableMountMode::parse(¤t.mode).map_err(|error| {
|
||||
CoreError::invalid(format!("unsupported current mode: {error}"))
|
||||
})?;
|
||||
if locked_mode == target_mode {
|
||||
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(if current.pending_mode.is_some() {
|
||||
format!("staying {mode_str}")
|
||||
} else {
|
||||
format!("already {mode_str}")
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// A transition or reconcile owns the folder lock. Record
|
||||
// this request instead of clearing the old target: if a
|
||||
// queued switch already passed its recheck, its mode write
|
||||
// preserves this opposite marker and the engine switches
|
||||
// back next.
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
return Ok(format!("staying {mode_str}"));
|
||||
}
|
||||
}
|
||||
return Ok(format!("already {mode}"));
|
||||
}
|
||||
let mode = target_mode;
|
||||
let mode_str = target_mode.as_str();
|
||||
|
||||
match self.apply_sync_folder_mode(id, mode) {
|
||||
match self.apply_sync_folder_mode(id, mode, None) {
|
||||
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))
|
||||
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
Ok(match mode {
|
||||
"ondemand" => format!(
|
||||
ApplicableMountMode::OnDemand => format!(
|
||||
"{} will go on-demand once its local changes are uploaded",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
_ => format!(
|
||||
ApplicableMountMode::Mirror => format!(
|
||||
"{} will start mirroring once the current sync finishes",
|
||||
base_name(&folder.local_path)
|
||||
),
|
||||
})
|
||||
}
|
||||
Err(SwitchBlocked::Superseded) => Err(CoreError::internal(
|
||||
"direct mode request was unexpectedly superseded",
|
||||
)),
|
||||
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
|
||||
}
|
||||
}
|
||||
@@ -465,27 +706,45 @@ impl Core {
|
||||
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(mode) = folder.pending_mode.as_deref() else {
|
||||
let Some(expected_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) {
|
||||
let mode = match select_mode_transition(&folder.mode, expected_mode) {
|
||||
Ok(Some(mode)) => mode,
|
||||
Ok(None) => {
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
id,
|
||||
current_mode = %folder.mode,
|
||||
pending_mode = %expected_mode,
|
||||
%error,
|
||||
"withdrawing unsafe queued mode transition"
|
||||
);
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mode_str = mode.as_str();
|
||||
match self.apply_sync_folder_mode(id, mode, Some(expected_mode)) {
|
||||
Ok(message) => {
|
||||
info!(id, mode, "applied queued mode switch");
|
||||
info!(id, mode = mode_str, "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");
|
||||
info!(id, mode = mode_str, "queued mode switch still waiting");
|
||||
}
|
||||
Err(SwitchBlocked::Superseded) => {
|
||||
info!(id, mode = mode_str, "queued mode switch was replaced");
|
||||
}
|
||||
Err(SwitchBlocked::Failed(e)) => {
|
||||
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
|
||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
||||
warn!(id, mode = mode_str, error = %e, "queued mode switch failed; withdrawing");
|
||||
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||
self.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("couldn't switch {}", base_name(&folder.local_path)),
|
||||
@@ -511,8 +770,10 @@ impl Core {
|
||||
pub(crate) fn apply_sync_folder_mode(
|
||||
&self,
|
||||
id: i64,
|
||||
mode: &str,
|
||||
mode: ApplicableMountMode,
|
||||
expected_pending: Option<&str>,
|
||||
) -> Result<String, SwitchBlocked> {
|
||||
let mode_str = mode.as_str();
|
||||
// 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`
|
||||
@@ -530,13 +791,18 @@ impl Core {
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode == mode {
|
||||
return Ok(format!("already {mode}"));
|
||||
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
||||
return Err(SwitchBlocked::Superseded);
|
||||
}
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("unsupported current mode: {e}")))?;
|
||||
if current_mode == mode {
|
||||
return Ok(format!("already {mode_str}"));
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
|
||||
match mode {
|
||||
"ondemand" => {
|
||||
ApplicableMountMode::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
|
||||
@@ -572,38 +838,42 @@ impl Core {
|
||||
// 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.
|
||||
self.db
|
||||
.sync_folder_set_mode(id, "ondemand")
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
|
||||
// Reclaim the disk: empty the local dir (keep it as the mountpoint).
|
||||
if let Err(e) = evict_dir_contents(&local) {
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"evict {}: {e}",
|
||||
local.display()
|
||||
)));
|
||||
if let Err(e) = self.db.sync_folder_set_mode(id, "ondemand") {
|
||||
return Err(SwitchBlocked::Failed(format!("db: {e:?}")));
|
||||
}
|
||||
|
||||
let session = match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(session) => session,
|
||||
Err(e) => {
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(e.to_string()));
|
||||
}
|
||||
};
|
||||
self.mounts.lock().insert(id, session);
|
||||
// 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 mount = 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.publish_secondary_mount(id, mount)
|
||||
.map_err(SwitchBlocked::Failed)?;
|
||||
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) = self.mounts.lock().remove(&id)
|
||||
&& let Err(e) = session.umount_and_join()
|
||||
ApplicableMountMode::Mirror => {
|
||||
// ondemand→mirror: tear down the secondary mount first. Taken
|
||||
// before the teardown so the registry lock is not held across
|
||||
// the unmount and thread join (see `remove_sync_folder`).
|
||||
let taken = self.mounts.lock().remove(&id);
|
||||
if let Some(mount) = taken
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"unmount {} failed; folder remains on-demand: {error}",
|
||||
local.display()
|
||||
)));
|
||||
}
|
||||
// The evicted local tree makes the old baseline lie ("everything
|
||||
// deleted locally"); clear it so the reconcile is a pure download.
|
||||
@@ -631,18 +901,52 @@ impl Core {
|
||||
&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 fs = ProtonFs::new(self.fork_state(), root);
|
||||
Session::new(fs, local, &config)
|
||||
.and_then(|s| s.spawn())
|
||||
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))
|
||||
) -> CoreResult<SecondaryMount> {
|
||||
let core = self.fork_state();
|
||||
let session_live = core.session_live.clone();
|
||||
// The fork owns this state/path registration. `spawn_session` only
|
||||
// marks it live after FUSE has spawned successfully.
|
||||
core.register_state(local);
|
||||
let session = spawn_session(&core, local, root)
|
||||
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
|
||||
Ok(SecondaryMount::new(
|
||||
session,
|
||||
fuse_connection_id(local),
|
||||
session_live,
|
||||
))
|
||||
}
|
||||
|
||||
/// Publish one newly spawned secondary or explicitly tear it down when
|
||||
/// shutdown has closed publication or another session already owns the id.
|
||||
fn publish_secondary_mount(&self, id: i64, mount: SecondaryMount) -> Result<(), String> {
|
||||
let rejected = self.mounts.lock().insert(id, mount);
|
||||
let Err((reason, mount)) = rejected else {
|
||||
return Ok(());
|
||||
};
|
||||
let reason = match reason {
|
||||
SecondaryInsertRejection::Closed => "daemon is shutting down",
|
||||
SecondaryInsertRejection::Duplicate => "folder already has a live mount",
|
||||
};
|
||||
match mount.teardown() {
|
||||
Ok(()) => Err(reason.to_string()),
|
||||
Err(error) => Err(format!("{reason}; rejected mount teardown failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_restore_error_if_current(&self, snapshot: &StoredSyncFolder) {
|
||||
let lock = self.sync_lock(snapshot.id);
|
||||
let _guard = lock.lock();
|
||||
if self
|
||||
.db
|
||||
.sync_folder_get(snapshot.id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|current| restore_snapshot_matches(snapshot, ¤t))
|
||||
{
|
||||
let _ = self
|
||||
.db
|
||||
.sync_folder_set_state(snapshot.id, "error", now_secs());
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
|
||||
@@ -663,7 +967,61 @@ impl Core {
|
||||
if folder.mode != "ondemand" {
|
||||
continue;
|
||||
}
|
||||
let local = PathBuf::from(&folder.local_path);
|
||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
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");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
||||
self.mark_restore_error_if_current(&folder);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let lock = self.sync_lock(folder.id);
|
||||
let _guard = lock.lock();
|
||||
let current = match self.db.sync_folder_get(folder.id) {
|
||||
Ok(Some(current)) if restore_snapshot_matches(&folder, ¤t) => current,
|
||||
Ok(_) => {
|
||||
info!(
|
||||
id = folder.id,
|
||||
"restore on-demand: folder changed while fetching"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
id = folder.id,
|
||||
?error,
|
||||
"restore on-demand: cannot re-read folder"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !self.mounts.lock().is_accepting() {
|
||||
info!(id = folder.id, "restore on-demand: shutdown in progress");
|
||||
continue;
|
||||
}
|
||||
let local = PathBuf::from(¤t.local_path);
|
||||
// A daemon killed before it could unmount can leave a dead FUSE
|
||||
// connection over the path. Clear it only after the row identity
|
||||
// and mode have been revalidated under the folder lock.
|
||||
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
|
||||
@@ -671,11 +1029,6 @@ impl Core {
|
||||
.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) => {
|
||||
@@ -697,38 +1050,16 @@ impl Core {
|
||||
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;
|
||||
match self.spawn_ondemand_mount(&local, root) {
|
||||
Ok(mount) => match self.publish_secondary_mount(folder.id, mount) {
|
||||
Ok(()) => {
|
||||
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
||||
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(id = folder.id, %error, "restore on-demand: mount rejected");
|
||||
}
|
||||
},
|
||||
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);
|
||||
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
|
||||
@@ -739,3 +1070,118 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
let mut baseline_clears = 0;
|
||||
let mut mode_writes = 0;
|
||||
if let Ok(mode) = ApplicableMountMode::parse("streaming") {
|
||||
match mode {
|
||||
ApplicableMountMode::Mirror => {
|
||||
unmounts += 1;
|
||||
baseline_clears += 1;
|
||||
mode_writes += 1;
|
||||
}
|
||||
ApplicableMountMode::OnDemand => {
|
||||
mode_writes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!((unmounts, baseline_clears, mode_writes), (0, 0, 0));
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("streaming"),
|
||||
Err("unknown mode \"streaming\" (want mirror|ondemand)".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("mirror"),
|
||||
Ok(ApplicableMountMode::Mirror)
|
||||
);
|
||||
assert_eq!(
|
||||
ApplicableMountMode::parse("ondemand"),
|
||||
Ok(ApplicableMountMode::OnDemand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_source_mode_with_pending_mirror_cannot_select_a_transition() {
|
||||
let mut destructive_helper_reached = false;
|
||||
let selected = select_mode_transition("streaming", "mirror");
|
||||
if let Ok(Some(_)) = selected {
|
||||
destructive_helper_reached = true;
|
||||
}
|
||||
|
||||
assert!(!destructive_helper_reached);
|
||||
assert_eq!(
|
||||
selected,
|
||||
Err(
|
||||
"unsupported current mode: unknown mode \"streaming\" (want mirror|ondemand)"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
||||
StoredSyncFolder {
|
||||
id: 7,
|
||||
local_path: local_path.to_string(),
|
||||
remote_uid: remote_uid.to_string(),
|
||||
remote_share_id: "share".to_string(),
|
||||
mode: mode.to_string(),
|
||||
pending_mode: None,
|
||||
state: "idle".to_string(),
|
||||
last_sync: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_restore_identity_or_mode_is_rejected() {
|
||||
let snapshot = folder("/mnt/folder", "volume~node", "ondemand");
|
||||
assert!(restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~node", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/replaced", "volume~node", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~other", "ondemand")
|
||||
));
|
||||
assert!(!restore_snapshot_matches(
|
||||
&snapshot,
|
||||
&folder("/mnt/folder", "volume~node", "mirror")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teardown_failure_prevents_follow_up_state_changes() {
|
||||
let mut commits = 0;
|
||||
let result = commit_after_teardown::<_, &str>(Err("busy"), || {
|
||||
commits += 1;
|
||||
});
|
||||
assert_eq!(result, Err("busy"));
|
||||
assert_eq!(commits, 0);
|
||||
|
||||
commit_after_teardown::<_, &str>(Ok(()), || commits += 1).unwrap();
|
||||
assert_eq!(commits, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_replaced_queued_intent_cannot_transition() {
|
||||
assert!(pending_intent_matches(Some("ondemand"), Some("ondemand")));
|
||||
assert!(!pending_intent_matches(None, Some("ondemand")));
|
||||
assert!(!pending_intent_matches(Some("mirror"), Some("ondemand")));
|
||||
assert!(pending_intent_matches(Some("mirror"), None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! 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 and the exact liveness flag
|
||||
/// published with its forked inode state.
|
||||
///
|
||||
/// Keeping these together makes every teardown mark the fork unroutable before
|
||||
/// unmounting, including paths that can block on an in-flight FUSE request.
|
||||
pub(super) struct SecondaryMount {
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SecondaryInsertRejection {
|
||||
Closed,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// Secondary sessions and the daemon-wide gate controlling whether newly
|
||||
/// spawned sessions may become visible. The gate and map share one lock so
|
||||
/// shutdown cannot race an insertion between checking and publishing.
|
||||
pub(super) struct SecondaryMountRegistry<T> {
|
||||
accepting: bool,
|
||||
entries: HashMap<i64, T>,
|
||||
}
|
||||
|
||||
impl<T> Default for SecondaryMountRegistry<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
accepting: false,
|
||||
entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> SecondaryMountRegistry<T> {
|
||||
pub(super) fn open(&mut self) {
|
||||
self.accepting = true;
|
||||
}
|
||||
|
||||
pub(super) fn is_accepting(&self) -> bool {
|
||||
self.accepting
|
||||
}
|
||||
|
||||
pub(super) fn close(&mut self) {
|
||||
self.accepting = false;
|
||||
}
|
||||
|
||||
pub(super) fn insert(
|
||||
&mut self,
|
||||
id: i64,
|
||||
mount: T,
|
||||
) -> Result<(), (SecondaryInsertRejection, T)> {
|
||||
if !self.accepting {
|
||||
return Err((SecondaryInsertRejection::Closed, mount));
|
||||
}
|
||||
if self.entries.contains_key(&id) {
|
||||
return Err((SecondaryInsertRejection::Duplicate, mount));
|
||||
}
|
||||
self.entries.insert(id, mount);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
|
||||
self.entries.remove(id)
|
||||
}
|
||||
|
||||
pub(super) fn contains_key(&self, id: &i64) -> bool {
|
||||
self.entries.contains_key(id)
|
||||
}
|
||||
|
||||
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
|
||||
self.close();
|
||||
self.entries.drain().collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecondaryMount {
|
||||
pub(super) fn new(
|
||||
session: BackgroundSession,
|
||||
conn: Option<u32>,
|
||||
session_live: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session,
|
||||
conn,
|
||||
session_live,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn teardown(self) -> std::io::Result<()> {
|
||||
teardown_session(&self.session_live, || {
|
||||
umount_session_unblocked(self.session, self.conn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||
/// caller, so `mount` itself never reads config or the environment.
|
||||
pub struct MountOptions {
|
||||
/// Proton account the session belongs to, for display and per-user paths.
|
||||
pub username: String,
|
||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||
pub sweep_mode: SweepMode,
|
||||
}
|
||||
|
||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||
///
|
||||
/// Primary My Files and secondary on-demand device locations use this same
|
||||
/// construction path so mount options, stale-mount recovery, registry
|
||||
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
|
||||
/// state/path registration and performs it exactly once before calling here.
|
||||
pub(super) fn spawn_session(
|
||||
core: &Core,
|
||||
mountpoint: &Path,
|
||||
root: Node,
|
||||
) -> std::io::Result<BackgroundSession> {
|
||||
let mut config = Config::default();
|
||||
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||
// `release` currently relies on being serialised against the `open`+`read`
|
||||
// an application issues right after `close(2)` — see the comment on
|
||||
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
MountOption::DefaultPermissions,
|
||||
];
|
||||
|
||||
clear_stale_mount(mountpoint);
|
||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||
let fs = ProtonFs::new(core.clone(), root);
|
||||
if core.primary {
|
||||
// Only My Files rehydrates the daemon-wide node cache. A secondary
|
||||
// inode space is rooted at one device subtree and must not import every
|
||||
// unrelated persisted node.
|
||||
core.hydrate();
|
||||
}
|
||||
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||
let _ = core.notifier.set(session.notifier());
|
||||
core.session_live.store(true, Ordering::Release);
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Make a session unroutable before beginning any teardown that may block.
|
||||
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
|
||||
session_live.store(false, Ordering::Release);
|
||||
teardown()
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Resolve the primary share id away from startup and publish it only if the
|
||||
/// projected root has not changed while the request was in flight.
|
||||
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
|
||||
match self.rt.block_on(self.client.context_share_id(root_uid)) {
|
||||
Ok(share_id) => {
|
||||
match self
|
||||
.db
|
||||
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
|
||||
{
|
||||
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
|
||||
Ok(false) => {
|
||||
debug!(%root_uid, "discarded stale My Files share-id result");
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%root_uid, error = %error, "persist My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
debug!(%root_uid, error = %error, "resolve My Files share id failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
options: MountOptions,
|
||||
) -> std::io::Result<MountOutcome> {
|
||||
let MountOptions {
|
||||
username,
|
||||
sweep_mode,
|
||||
} = options;
|
||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
let share_access = db
|
||||
.all_share_access()
|
||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||
|
||||
// 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(),
|
||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
block_flight: Arc::new(BlockFlight::default()),
|
||||
prefetch: Arc::new(Prefetch::default()),
|
||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
shared_publication: Arc::new(Mutex::new(())),
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||
sweep_mode,
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
session_live: Arc::new(AtomicBool::new(false)),
|
||||
transfers: TransferRegistry::new(),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state(mountpoint);
|
||||
|
||||
// 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();
|
||||
// And finally the staged blobs neither of those accounts for: bytes a
|
||||
// failed release left behind with nothing pointing at them.
|
||||
core.reconcile_staging();
|
||||
// Several drain workers over one queue. One thread meant a single large
|
||||
// upload held every queued rename, trash and small write behind it for as
|
||||
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||
// workers off each other's nodes, which is the only ordering the queue
|
||||
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pdfs-drain-{worker}"))
|
||||
.spawn(move || core.run_pending_drain(worker == 0))?;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
|
||||
// `Off` skips the thread entirely rather than starting an idle one, so the
|
||||
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
|
||||
if sweep_mode == SweepMode::Off {
|
||||
info!("conflict sweep disabled by configuration");
|
||||
} else {
|
||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
||||
}
|
||||
|
||||
// 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())?;
|
||||
}
|
||||
|
||||
// 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))?;
|
||||
}
|
||||
|
||||
// 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}"
|
||||
)));
|
||||
}
|
||||
// Do not accept control requests until `spawn_session` has transitioned the
|
||||
// primary registration to live. The listener may queue a connection during
|
||||
// this short window, but its handler cannot observe `mounted = false`.
|
||||
let bg = match spawn_session(&core, mountpoint, root) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
core.mounts.lock().open();
|
||||
// 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 control_core = core.clone();
|
||||
let username = username.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("pdfs-control".into())
|
||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||
{
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(unmount_error) = mount.teardown() {
|
||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||
}
|
||||
}
|
||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||
}
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// Re-establish on-demand mounts only after the primary session is live.
|
||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||
// fetch to observe that closure, and then drain a stable registry.
|
||||
let restore_worker = {
|
||||
let restore_core = core.clone();
|
||||
match std::thread::Builder::new()
|
||||
.name("pdfs-restore-ondemand".into())
|
||||
.spawn(move || restore_core.restore_ondemand_mounts())
|
||||
{
|
||||
Ok(worker) => Some(worker),
|
||||
Err(error) => {
|
||||
warn!(%error, "start on-demand restore worker failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||
if online {
|
||||
let repair_core = core.clone();
|
||||
let root_uid = core.primary_root_uid.clone();
|
||||
if let Err(error) = std::thread::Builder::new()
|
||||
.name("pdfs-share-id".into())
|
||||
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
|
||||
{
|
||||
warn!(%error, "start My Files share-id repair failed");
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || 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.
|
||||
core.mounts.lock().close();
|
||||
let _ = teardown_session(&core.session_live, || 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).
|
||||
if let Some(worker) = restore_worker
|
||||
&& worker.join().is_err()
|
||||
{
|
||||
warn!("on-demand restore worker panicked");
|
||||
}
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(e) = mount.teardown() {
|
||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
|
||||
|
||||
#[test]
|
||||
fn session_is_unroutable_before_teardown_starts() {
|
||||
let live = AtomicBool::new(true);
|
||||
teardown_session(&live, || {
|
||||
assert!(
|
||||
!live.load(Ordering::Acquire),
|
||||
"teardown must never run while the registry still reports mounted"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
|
||||
let mut mounts = SecondaryMountRegistry::default();
|
||||
assert_eq!(
|
||||
mounts.insert(7, "closed"),
|
||||
Err((SecondaryInsertRejection::Closed, "closed"))
|
||||
);
|
||||
|
||||
mounts.open();
|
||||
assert_eq!(mounts.insert(7, "first"), Ok(()));
|
||||
assert_eq!(
|
||||
mounts.insert(7, "duplicate"),
|
||||
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
|
||||
);
|
||||
assert!(mounts.contains_key(&7));
|
||||
|
||||
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
|
||||
assert!(!mounts.is_accepting());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 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::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
||||
@@ -14,14 +14,82 @@ 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 proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::{
|
||||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, THUMB_GEN_CONCURRENCY, TIMELINE_ENRICH_CHUNK,
|
||||
TIMELINE_TTL, ThumbAttempt, node_size, now_ms, parse_uid, ratio_of, scale_thumbnail,
|
||||
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,
|
||||
@@ -29,6 +97,7 @@ impl Core {
|
||||
limit: usize,
|
||||
kind: Option<PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> CoreResult<Option<Vec<PhotoItem>>> {
|
||||
let count = self.db.photos_count().map_err(CoreError::from)?;
|
||||
if count == 0 {
|
||||
@@ -48,7 +117,7 @@ impl Core {
|
||||
|
||||
let page = self
|
||||
.db
|
||||
.photos_page(offset, limit, kind, range)
|
||||
.photos_page(offset, limit, kind, range, favorites)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect()))
|
||||
}
|
||||
@@ -71,9 +140,42 @@ impl Core {
|
||||
ratio: photo.ratio,
|
||||
no_thumb: photo.thumb_state == db::THUMB_NONE,
|
||||
kind: photo.kind,
|
||||
favorite: photo.favorite,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add or remove Proton's `Favorite` tag on a photo, then record the change
|
||||
/// locally so the gallery reflects it without waiting for a timeline refresh.
|
||||
///
|
||||
/// The SDK reports one outcome per photo rather than failing the call, so the
|
||||
/// single outcome is unwrapped back into this request's result.
|
||||
pub(crate) fn set_photo_favorite(&self, uid: &NodeUid, favorite: bool) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
let update = PhotoTagsUpdate {
|
||||
node_uid: uid.clone(),
|
||||
tags_to_add: if favorite {
|
||||
vec![PhotoTag::Favorite]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
tags_to_remove: if favorite {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![PhotoTag::Favorite]
|
||||
},
|
||||
};
|
||||
let outcomes = self
|
||||
.rt
|
||||
.block_on(photos.update_photos(std::slice::from_ref(&update)))
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
pdfs_core::batch::into_unit(outcomes)
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
self.db
|
||||
.photos_set_favorite(&uid.to_string(), favorite)
|
||||
.map_err(CoreError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -86,7 +188,20 @@ impl Core {
|
||||
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 mut stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||
// Album covers and the contents of an album shared with us are not in our
|
||||
// own timeline, so their capture time — the cache's validity tag — only
|
||||
// exists on the album rows. Fill in whatever the timeline didn't cover.
|
||||
{
|
||||
let have: HashSet<String> = stored.iter().map(|p| p.uid.clone()).collect();
|
||||
let missing: Vec<String> = keys
|
||||
.iter()
|
||||
.filter(|k| !have.contains(*k))
|
||||
.cloned()
|
||||
.collect();
|
||||
stored.extend(self.db.album_photos_by_uid(&missing).unwrap_or_default());
|
||||
}
|
||||
let stored = stored;
|
||||
let tags: HashMap<String, i64> = stored
|
||||
.iter()
|
||||
.map(|p| (p.uid.clone(), p.capture_time))
|
||||
@@ -261,11 +376,17 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist what a thumbnail attempt learned about a photo.
|
||||
/// Persist what a thumbnail attempt learned about a photo, against the
|
||||
/// timeline and against every album the photo sits in — one attempt, one
|
||||
/// verdict, wherever the photo is painted.
|
||||
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) {
|
||||
let key = uid.to_string();
|
||||
if let Err(e) = self.db.photo_set_thumb(&key, state, ratio) {
|
||||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||||
}
|
||||
if let Err(e) = self.db.album_photo_set_thumb(&key, state, ratio) {
|
||||
warn!(%uid, error = %e, "recording album thumbnail state failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||
@@ -294,7 +415,7 @@ impl Core {
|
||||
// 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();
|
||||
let mut meta: HashMap<String, (Option<String>, Option<String>, bool)> = HashMap::new();
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
@@ -303,19 +424,35 @@ impl Core {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||
// The favourite state is a tag on the photo node, so it
|
||||
// rides along with the metadata resolve rather than
|
||||
// costing a listing of its own.
|
||||
let favorite = node
|
||||
.photo
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.tags.contains(&PhotoTag::Favorite));
|
||||
meta.insert(
|
||||
node.uid.to_string(),
|
||||
(Some(node.name), media_type, favorite),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "resolving photo metadata for a timeline chunk failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||
let rows: Vec<db::TimelineRow> = 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)
|
||||
// An unresolved photo keeps whatever was learned before, for the
|
||||
// favourite flag as much as for the name and media type.
|
||||
match meta.get(&key).cloned() {
|
||||
Some((name, media_type, favorite)) => {
|
||||
(key, it.capture_time, name, media_type, Some(favorite))
|
||||
}
|
||||
None => (key, it.capture_time, None, None, None),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.db.photos_replace(&rows).map_err(CoreError::from)?;
|
||||
@@ -361,3 +498,60 @@ impl Core {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! 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::mounts::MountMode;
|
||||
use pdfs_core::profile::{
|
||||
PROFILE_DIR_NAME, 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`
|
||||
/// inside the device's `.proton-drive-linux` folder, 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;
|
||||
|
||||
// The device root itself rejects file creation (B78), so the profile
|
||||
// lives one level down, in a folder created on first save.
|
||||
let dir_uid = self.ensure_profile_dir(&root_uid)?;
|
||||
|
||||
// Replace the existing document rather than adding a second file of the
|
||||
// same name: the profile is a single mutable record, and a folder with
|
||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||
// nothing about which to trust.
|
||||
let existing = self.find_device_child_file(&dir_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(
|
||||
&dir_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: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||
})
|
||||
.collect();
|
||||
let pins = self
|
||||
.db
|
||||
.pin_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|row| ProfilePin {
|
||||
uid: row.uid,
|
||||
path: row.path,
|
||||
recursive: row.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 `parent_uid`.
|
||||
///
|
||||
/// 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,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_folder_children_node_uids(parent_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))
|
||||
}
|
||||
|
||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||
/// first save on this device.
|
||||
///
|
||||
/// Reused by stable identity rather than recreated: a second folder of the
|
||||
/// same name would split the profile across two documents, and the restore
|
||||
/// would have no way to tell which is current.
|
||||
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
|
||||
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||
info!(name = PROFILE_DIR_NAME, "created device profile folder");
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Download and parse this device's profile, if there is one.
|
||||
///
|
||||
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
||||
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
||||
/// next save writes to the folder, and the legacy document is left alone
|
||||
/// rather than trashed, so an older client on another machine keeps working.
|
||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
||||
None => None,
|
||||
};
|
||||
let uid = match current {
|
||||
Some(uid) => uid,
|
||||
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
||||
Some(uid) => {
|
||||
info!("reading legacy profile from the device root");
|
||||
uid
|
||||
}
|
||||
None => 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()
|
||||
// `.proton-drive-linux` holds the profile itself, not user data:
|
||||
// offering it as a restorable folder would sync the bookkeeping
|
||||
// back onto the machine it describes.
|
||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||
.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.to_string())
|
||||
.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 restore_mode(&item.mode) {
|
||||
Some(mode) => mode,
|
||||
None => {
|
||||
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
|
||||
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 == MountMode::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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only modes this build can safely restore. `MountMode` itself is
|
||||
/// tolerant so a future value does not reject the whole profile; the folder
|
||||
/// carrying that value is skipped here with the message assembled above.
|
||||
fn restore_mode(value: &str) -> Option<MountMode> {
|
||||
match MountMode::from(value) {
|
||||
MountMode::Mirror => Some(MountMode::Mirror),
|
||||
MountMode::OnDemand => Some(MountMode::OnDemand),
|
||||
MountMode::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
|
||||
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
|
||||
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
|
||||
assert_eq!(restore_mode("streaming"), None);
|
||||
assert_eq!(restore_mode("unknown"), None);
|
||||
}
|
||||
|
||||
/// The device root refuses file creation (B78), so every upload in
|
||||
/// `save_profile` must be addressed to the profile folder. Checked against
|
||||
/// the source because both branches are pure network calls.
|
||||
#[test]
|
||||
fn the_profile_is_never_uploaded_to_the_device_root() {
|
||||
let source = include_str!("profile.rs");
|
||||
let save = function_source(source, "fn save_profile(");
|
||||
assert!(
|
||||
save.contains("ensure_profile_dir(&root_uid)"),
|
||||
"save_profile resolves the profile folder"
|
||||
);
|
||||
for upload in [
|
||||
"upload_new_revision_from",
|
||||
"upload_file_replacing_draft_from",
|
||||
] {
|
||||
let call = save.find(upload).expect("upload call exists");
|
||||
let args = &save[call..];
|
||||
let root = args.find("&root_uid").unwrap_or(usize::MAX);
|
||||
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
|
||||
assert!(dir < root, "{upload} must target the profile folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// A read has to keep finding profiles written before the fix, which sat in
|
||||
/// the device root.
|
||||
#[test]
|
||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||
let source = include_str!("profile.rs");
|
||||
let load = function_source(source, "fn load_profile(");
|
||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||
assert!(
|
||||
load.find("PROFILE_DIR_NAME").unwrap()
|
||||
< load.find("legacy profile").expect("legacy fallback exists"),
|
||||
"the folder is consulted before the device root"
|
||||
);
|
||||
}
|
||||
|
||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||
#[test]
|
||||
fn the_profile_folder_is_not_offered_as_user_data() {
|
||||
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
|
||||
assert!(
|
||||
restorable.contains("n.name != PROFILE_DIR_NAME"),
|
||||
"restorable folders exclude the profile folder"
|
||||
);
|
||||
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
|
||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||
}
|
||||
|
||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||
let start = source.find(signature).expect("function signature exists");
|
||||
let body = &source[start..];
|
||||
let open = body.find('{').expect("function body starts");
|
||||
let mut depth = 0usize;
|
||||
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
|
||||
match byte {
|
||||
b'{' => depth += 1,
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return &body[..open + offset + 1];
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
panic!("function body closes")
|
||||
}
|
||||
|
||||
fn assert_before(source: &str, first: &str, second: &str) {
|
||||
let first_at = source.find(first).expect("first operation exists");
|
||||
let second_at = source.find(second).expect("second operation exists");
|
||||
assert!(first_at < second_at, "{first} must precede {second}");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! File version history: listing a file's revisions, restoring one, deleting
|
||||
//! one, and writing an old one out to a local path.
|
||||
//!
|
||||
//! Proton Drive keeps every revision a client ever committed, and until now the
|
||||
//! daemon only ever addressed the active one — a file that a sync pass or a
|
||||
//! `(sync-conflict)` fork overwrote could only be recovered from whatever the
|
||||
//! local `recovery/` directory happened to hold. This is the remote equivalent,
|
||||
//! and it is the reason a *restore* here is not a download-then-upload: the
|
||||
//! server re-points the file at an existing revision, so no content crosses the
|
||||
//! wire and nothing lands in the drain queue.
|
||||
//!
|
||||
//! Two things about the SDK surface leak into every method here:
|
||||
//!
|
||||
//! - **A restore is asynchronous.** The server answers 202 and swaps the active
|
||||
//! revision in the background, so the daemon drops its cached content for the
|
||||
//! file rather than trying to describe the new one. The next read repopulates
|
||||
//! from whatever is live by then.
|
||||
//! - **The active revision cannot be deleted**, and the server — not this code —
|
||||
//! is what enforces that. [`Core::delete_revision_for_uid`] refuses locally
|
||||
//! too, so the user gets a sentence instead of an API code.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{ActivityKind, RevisionInfo};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::Revision;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::info;
|
||||
|
||||
use super::Core;
|
||||
|
||||
/// A [`Revision`] as the control protocol reports it.
|
||||
fn revision_info(revision: Revision) -> RevisionInfo {
|
||||
RevisionInfo {
|
||||
is_active: revision.is_active(),
|
||||
id: revision.revision_id,
|
||||
created: revision.creation_time,
|
||||
size_on_storage: revision.size_on_storage,
|
||||
claimed_size: revision.claimed_size,
|
||||
claimed_modified: revision.claimed_modification_time,
|
||||
// An empty signer means the node key signed it anonymously, which is not
|
||||
// an address a front-end can show.
|
||||
signed_by: revision.signature_email.filter(|email| !email.is_empty()),
|
||||
has_thumbnails: revision.has_thumbnails,
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// The version history of the file at mountpoint-relative `rel`.
|
||||
pub(crate) fn list_revisions(&self, rel: &Path) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_revisions`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_revisions_by_uid(&self, uid: &str) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_revisions_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_revisions_for_uid(&self, uid: &NodeUid) -> CoreResult<Vec<RevisionInfo>> {
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
Ok(revisions.into_iter().map(revision_info).collect())
|
||||
}
|
||||
|
||||
/// Make `revision_id` the current content of the file at `rel`.
|
||||
pub(crate) fn restore_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)?;
|
||||
// The size and mtime a listing shows describe the revision that was
|
||||
// active a moment ago.
|
||||
self.invalidate_parent_listing(rel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Core::restore_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn restore_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.restore_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn restore_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
// Restoring rewrites the file's content: the same authority a write
|
||||
// needs, not the one a read needs.
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "restore revision"))?;
|
||||
if let Err(e) = self
|
||||
.rt
|
||||
.block_on(self.client.restore_revision(uid, revision_id))
|
||||
{
|
||||
let error = CoreError::from_api(&e, "restore revision");
|
||||
self.log_activity(ActivityKind::Restore, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
// The active revision is changing behind our back — anything describing
|
||||
// the old one (cached blocks, an open reader, the size in a listing) is
|
||||
// stale from here on, and the server may not have applied the swap yet.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Restore,
|
||||
name,
|
||||
format!("restored version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "restored an earlier revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permanently delete `revision_id` from the history of the file at `rel`.
|
||||
pub(crate) fn delete_revision(&self, rel: &Path, revision_id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
/// [`Core::delete_revision`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn delete_revision_by_uid(&self, uid: &str, revision_id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.delete_revision_for_uid(&uid, revision_id, &name)
|
||||
}
|
||||
|
||||
fn delete_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
) -> CoreResult<()> {
|
||||
self.require_uid_writable(uid)
|
||||
.map_err(|error| self.errno_error(error, "delete revision"))?;
|
||||
// The server rejects deleting the active revision; saying so here is a
|
||||
// sentence the user can act on instead of an API code, and it costs one
|
||||
// listing the front-end already made to show the version list.
|
||||
let revisions = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_revisions(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list revisions"))?;
|
||||
let target = revisions
|
||||
.iter()
|
||||
.find(|r| r.revision_id == revision_id)
|
||||
.ok_or_else(|| CoreError::not_found(format!("no version {revision_id} on {name}")))?;
|
||||
if target.is_active() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"version {revision_id} is the current content of {name}; restore another version first"
|
||||
)));
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.delete_revision(uid, revision_id))
|
||||
.map_err(|e| CoreError::from_api(&e, "delete revision"))?;
|
||||
self.log_activity(
|
||||
ActivityKind::DeleteForever,
|
||||
name,
|
||||
format!("deleted version {revision_id}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, name, "deleted a revision");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `revision_id` of the file at `rel` to the absolute local path
|
||||
/// `dest`, leaving the file itself untouched.
|
||||
pub(crate) fn save_revision_as(
|
||||
&self,
|
||||
rel: &Path,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
let name = rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| uid.to_string());
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
/// [`Core::save_revision_as`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn save_revision_as_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
revision_id: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
let name = uid.to_string();
|
||||
self.save_revision_for_uid(&uid, revision_id, &name, dest)
|
||||
}
|
||||
|
||||
fn save_revision_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
revision_id: &str,
|
||||
name: &str,
|
||||
dest: &Path,
|
||||
) -> CoreResult<u64> {
|
||||
if !dest.is_absolute() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"destination must be an absolute path: {}",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
// Never silently replace something the user already has: an old version
|
||||
// written over the wrong file is exactly the loss this feature exists to
|
||||
// undo.
|
||||
if dest.exists() {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} already exists",
|
||||
dest.display()
|
||||
)));
|
||||
}
|
||||
let file = std::fs::File::create(dest)
|
||||
.map_err(|e| CoreError::internal(format!("create {}: {e}", dest.display())))?;
|
||||
let mut out = std::io::BufWriter::new(file);
|
||||
let written = match self
|
||||
.rt
|
||||
.block_on(self.client.download_revision_to(uid, revision_id, &mut out))
|
||||
.map_err(|e| CoreError::from_api(&e, "download revision"))
|
||||
.and_then(|()| {
|
||||
use std::io::Write as _;
|
||||
out.flush()
|
||||
.map_err(|e| CoreError::internal(format!("write {}: {e}", dest.display())))?;
|
||||
let written = std::fs::metadata(dest)
|
||||
.map(|m| m.len())
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", dest.display())))?;
|
||||
Ok(written)
|
||||
}) {
|
||||
Ok(written) => written,
|
||||
Err(error) => {
|
||||
// A half-written file looks like a successful export to every
|
||||
// tool that opens it afterwards.
|
||||
let _ = std::fs::remove_file(dest);
|
||||
self.log_activity(ActivityKind::Download, name, &error, false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.log_activity(
|
||||
ActivityKind::Download,
|
||||
name,
|
||||
format!("saved version {revision_id} to {}", dest.display()),
|
||||
true,
|
||||
);
|
||||
info!(%uid, revision_id, dest = %dest.display(), "wrote an earlier revision to disk");
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,53 @@ use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
use super::{Core, ROOT_INO, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||
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`].
|
||||
///
|
||||
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||
/// guaranteed to have — but `path` is filled in when the node is resident under
|
||||
/// the synthetic `Shared with me/` directory, which is what lets a front-end
|
||||
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
|
||||
/// stay false: they describe content this account owns a local copy policy for,
|
||||
/// which a share on someone else's volume is not.
|
||||
fn shared_entry_of(core: &Core, 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,
|
||||
};
|
||||
let role = n
|
||||
.membership
|
||||
.as_ref()
|
||||
.and_then(|m| m.role_exact())
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
DirEntry {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
size,
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
role,
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
@@ -30,11 +73,33 @@ impl Core {
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
/// [`Core::share_node`] for a node addressed through any daemon location.
|
||||
pub(crate) fn share_node_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.share_node_for_uid(&uid, emails, role, message)
|
||||
}
|
||||
|
||||
fn share_node_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
emails: &[String],
|
||||
role: &str,
|
||||
message: Option<&str>,
|
||||
) -> CoreResult<(usize, usize)> {
|
||||
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))
|
||||
.block_on(self.client.invite_users(uid, &invitees, message))
|
||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||
}
|
||||
|
||||
@@ -44,11 +109,26 @@ impl Core {
|
||||
rel: &Path,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::list_share`] for a node addressed through any daemon location.
|
||||
pub(crate) fn list_share_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.list_share_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn list_share_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||
let mut entries = Vec::new();
|
||||
for m in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.block_on(self.client.list_share_members(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -60,7 +140,7 @@ impl Core {
|
||||
}
|
||||
for inv in self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.block_on(self.client.list_share_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -72,7 +152,7 @@ impl Core {
|
||||
}
|
||||
for ext in self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.block_on(self.client.list_external_invitations(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||
{
|
||||
entries.push(ShareEntry {
|
||||
@@ -85,7 +165,7 @@ impl Core {
|
||||
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.map(public_link_info);
|
||||
|
||||
@@ -102,12 +182,35 @@ impl Core {
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
/// [`Core::update_share_role`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_share_role_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||
}
|
||||
|
||||
fn update_share_role_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
role: &str,
|
||||
) -> CoreResult<()> {
|
||||
let role = role_from_str(role)?;
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.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)
|
||||
@@ -119,7 +222,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.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)
|
||||
@@ -143,11 +246,32 @@ impl Core {
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
/// [`Core::remove_share_entry`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_share_entry_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||
}
|
||||
|
||||
fn remove_share_entry_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
kind: ShareEntryKind,
|
||||
) -> CoreResult<()> {
|
||||
match kind {
|
||||
ShareEntryKind::Member => {
|
||||
let member = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.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)
|
||||
@@ -159,7 +283,7 @@ impl Core {
|
||||
ShareEntryKind::ProtonInvite => {
|
||||
let inv = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.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)
|
||||
@@ -171,7 +295,7 @@ impl Core {
|
||||
ShareEntryKind::ExternalInvite => {
|
||||
let ext = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.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)
|
||||
@@ -192,13 +316,33 @@ impl Core {
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::create_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn create_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||
}
|
||||
|
||||
fn create_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
role: &str,
|
||||
password: Option<&str>,
|
||||
expires: Option<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let role = role_from_str(role)?;
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.create_public_link(&uid, role, password, expires),
|
||||
)
|
||||
.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))
|
||||
}
|
||||
@@ -206,9 +350,20 @@ impl Core {
|
||||
/// 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)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.remove_public_link_for_uid(&uid, id)
|
||||
}
|
||||
|
||||
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.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"))?;
|
||||
@@ -234,30 +389,68 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.map(|n| {
|
||||
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: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: n.uid.to_string(),
|
||||
path: String::new(),
|
||||
}
|
||||
})
|
||||
.map(|node| shared_entry_of(self, node))
|
||||
.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(|node| shared_entry_of(self, node))
|
||||
.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 =
|
||||
@@ -333,6 +526,43 @@ impl Core {
|
||||
.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"))?;
|
||||
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||
Ok((q.max_space, q.used_space))
|
||||
}
|
||||
|
||||
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||
/// refetched after that, and `None` when there is no usable answer at all —
|
||||
/// offline with nothing cached.
|
||||
///
|
||||
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||
/// the last known figures keep being served rather than degrading to zero.
|
||||
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||
&& at.elapsed() < QUOTA_TTL
|
||||
{
|
||||
return Some((max_space, used_space));
|
||||
}
|
||||
match self.account_quota() {
|
||||
Ok(q) => Some(q),
|
||||
Err(error) => {
|
||||
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
|
||||
/// List the nodes I have shared with others, each with a summary of its share
|
||||
@@ -394,15 +624,33 @@ impl Core {
|
||||
/// 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("/"))
|
||||
// Searched across every mount: a node browsed through a sync folder is
|
||||
// interned in that fork's inode space, so the primary state alone had no
|
||||
// entry for it and the caller silently showed an empty path
|
||||
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||
// it, which is the one the user reached it through.
|
||||
let mut path = None;
|
||||
self.for_each_state(|st| {
|
||||
if path.is_some() {
|
||||
return;
|
||||
}
|
||||
path = walk_to_root(st, uid);
|
||||
});
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
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("/"))
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Background conflict sweep.
|
||||
//!
|
||||
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
||||
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
||||
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
||||
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
||||
//! first-sync of an already-identical file. This sweep reconciles them after the
|
||||
//! fact:
|
||||
//!
|
||||
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
||||
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
||||
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
||||
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
||||
//! user can decide which version wins.
|
||||
//!
|
||||
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
||||
//! revision's decrypted extended attributes, which enumeration already reads. A
|
||||
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
||||
//! never removes a file it cannot prove is a duplicate.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::config::SweepMode;
|
||||
use pdfs_core::control::ActivityKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
||||
|
||||
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
||||
/// the sweep off the hot path while still cleaning up within a few minutes.
|
||||
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Delay before the first sweep, so it does not compete with the burst of work a
|
||||
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
||||
/// returning the original file name it was a copy of. `None` when `name` is not a
|
||||
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
||||
/// formats, so the two halves of the product agree on what a conflict is.
|
||||
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||
const MARKER: &str = " (sync-conflict ";
|
||||
let start = name.find(MARKER)?;
|
||||
let after = start + MARKER.len();
|
||||
let close_rel = name[after..].find(')')?;
|
||||
let close = after + close_rel;
|
||||
let inner = &name[after..close];
|
||||
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||
let mut parts = inner.splitn(2, '-');
|
||||
let stamp = parts.next().unwrap_or_default();
|
||||
let suffix = parts.next();
|
||||
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
||||
return None;
|
||||
}
|
||||
let mut base = String::with_capacity(start + name.len() - close);
|
||||
base.push_str(&name[..start]);
|
||||
base.push_str(&name[close + 1..]);
|
||||
Some(base)
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||
/// docs for the identical/divergent policy.
|
||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
||||
loop {
|
||||
self.sweep_conflicts_once();
|
||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/// One sweep pass over every node the daemon currently knows.
|
||||
fn sweep_conflicts_once(&self) {
|
||||
let stored = match self.db.load_all() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "conflict sweep: load nodes failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let nodes: Vec<_> = stored
|
||||
.into_iter()
|
||||
.map(|stored| stored.node)
|
||||
.filter(|node| self.is_own_or_virtual(&node.uid))
|
||||
.collect();
|
||||
|
||||
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
continue;
|
||||
}
|
||||
let parent = node
|
||||
.parent_uid
|
||||
.as_ref()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||
}
|
||||
|
||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
continue;
|
||||
}
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let parent = node
|
||||
.parent_uid
|
||||
.as_ref()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
||||
|
||||
match decide(node, sibling, &base, self.sweep_mode) {
|
||||
SweepAction::Remove => {
|
||||
// Only while online — the remote trash is the point. Offline,
|
||||
// it waits for a later pass.
|
||||
if online {
|
||||
self.remove_conflict_copy(node, &base);
|
||||
}
|
||||
}
|
||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
||||
/// locally — the same sequence the interactive unlink path uses after a trash.
|
||||
///
|
||||
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
||||
/// enumerated every node up front and has been doing network I/O since. Before
|
||||
/// touching anything remote this re-reads the node and refuses to act unless
|
||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||
/// or its staged blob: a write that arrives after that check is left for the
|
||||
/// drain to resolve against the now-trashed node.
|
||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
if !self.duplicate_still_removable(snapshot) {
|
||||
return;
|
||||
}
|
||||
match self
|
||||
.rt
|
||||
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||
.and_then(batch::into_unit)
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if super::is_gone(&e) => {}
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Every mount, not just the primary: a conflict copy inside a sync
|
||||
// folder lives in that fork's inode space, and leaving it hooked up
|
||||
// there keeps a node we have just trashed remotely visible and
|
||||
// readable there (`docs/BUGS.md` B74).
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||
// proved the queue empty before the trash above, but that proof is stale
|
||||
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||
// that window stages bytes, and discarding here would unlink the staged
|
||||
// blob holding the only copy of them. Leaving the op queued is the
|
||||
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
||||
}
|
||||
self.invalidate_trash();
|
||||
self.conflict_notified.lock().remove(uid);
|
||||
self.log_activity(
|
||||
ActivityKind::Trash,
|
||||
name,
|
||||
format!("auto-removed duplicate of {base}"),
|
||||
true,
|
||||
);
|
||||
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
||||
}
|
||||
|
||||
/// Re-check, immediately before the destructive call, everything the pass
|
||||
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
||||
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
||||
/// removed one costs the user data.
|
||||
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
|
||||
// A queued op means bytes are still owed an upload; trashing the node
|
||||
// under them turns an ordinary upload into a conflict fork.
|
||||
match self.db.has_any_op(&uid.to_string()) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
||||
return false;
|
||||
}
|
||||
// Cannot prove the queue is empty — assume it is not.
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Someone is writing to it, or holds it open. Trashing now would pull the
|
||||
// node out from under a live handle.
|
||||
if self.is_busy(uid) {
|
||||
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Finally, the node itself must not have moved on since the snapshot.
|
||||
match self.db.node_by_uid(&uid.to_string()) {
|
||||
Ok(Some(fresh)) => {
|
||||
if fresh.trashed {
|
||||
return false;
|
||||
}
|
||||
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
||||
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
||||
return false;
|
||||
}
|
||||
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
||||
|| node_size(&fresh) != node_size(snapshot)
|
||||
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
||||
{
|
||||
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
// Already gone locally, or unreadable — either way, not ours to remove.
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a node has a write in flight or a file handle open against it.
|
||||
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||
/// writer/holder check.
|
||||
/// Asked across **every** mount. The sweep runs on the primary Core but
|
||||
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
let mut busy = false;
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
return;
|
||||
};
|
||||
busy |=
|
||||
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
||||
});
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||
/// daemon run so the activity feed is not spammed each pass.
|
||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||
return;
|
||||
}
|
||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum SweepAction {
|
||||
/// Provably redundant: trash it, subject to the runtime interlocks in
|
||||
/// [`Core::duplicate_still_removable`].
|
||||
Remove,
|
||||
/// Only the user can resolve this one; surface it with this detail.
|
||||
Flag(String),
|
||||
}
|
||||
|
||||
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
||||
/// and the configured `mode`.
|
||||
///
|
||||
/// Pure, so the policy that governs deletion is testable without a mount, a
|
||||
/// runtime, or an account — the same split used for `revision_changed` and
|
||||
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
||||
fn decide(
|
||||
node: &proton_drive_rs::Node,
|
||||
sibling: Option<&proton_drive_rs::Node>,
|
||||
base: &str,
|
||||
mode: SweepMode,
|
||||
) -> SweepAction {
|
||||
match sibling {
|
||||
Some(sib) if is_identical(node, sib) => {
|
||||
if mode.is_enforcing() {
|
||||
SweepAction::Remove
|
||||
} else {
|
||||
// Report-only: say what would happen, remove nothing. The point is
|
||||
// to accumulate field evidence that the identity check is right
|
||||
// before it is trusted with deletion (docs/BUGS.md B71).
|
||||
SweepAction::Flag(format!(
|
||||
"identical to {base}; would be removed (sweep is report-only)"
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
||||
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
||||
/// present content SHA-1. A missing digest on either side is not proof, so the
|
||||
/// sweep treats it as divergent rather than risk removing a real difference.
|
||||
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
||||
if node_size(a) != node_size(b) {
|
||||
return false;
|
||||
}
|
||||
match (node_content_sha1(a), node_content_sha1(b)) {
|
||||
(Some(x), Some(y)) => x == y,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
/// A file node with the two fields the sweep's identity check reads.
|
||||
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
||||
proton_drive_rs::Node {
|
||||
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
||||
parent_uid: None,
|
||||
kind: NodeKind::File {
|
||||
media_type: "text/plain".into(),
|
||||
total_size_on_storage: size,
|
||||
active_revision_state: None,
|
||||
active_revision_id: Some("rev".into()),
|
||||
claimed_size: Some(size),
|
||||
claimed_modification_time: None,
|
||||
content_sha1: sha1.map(String::from),
|
||||
},
|
||||
name: name.into(),
|
||||
creation_time: 0,
|
||||
modification_time: 0,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The only input shape that may ever produce a removal: same size, same
|
||||
/// present digest, live sibling, enforcing mode.
|
||||
#[test]
|
||||
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Remove
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
// Same inputs as the removal case above; only the mode differs.
|
||||
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
||||
else {
|
||||
panic!("report mode must never remove");
|
||||
};
|
||||
assert!(detail.contains("would be removed"), "{detail}");
|
||||
assert!(detail.contains("report-only"), "{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_size_is_a_real_conflict() {
|
||||
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_digest_is_never_proof_of_identity() {
|
||||
// Equal sizes are not enough: two different 100-byte files are common.
|
||||
// A copy the sweep cannot fingerprint must survive, in either direction.
|
||||
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, a);
|
||||
let live = file("f.txt", 100, b);
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into()),
|
||||
"{a:?} vs {b:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
let live = file("f.txt", 100, Some("bbb"));
|
||||
assert_eq!(
|
||||
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("differs from f.txt".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_orphaned_copy_is_surfaced_not_removed() {
|
||||
// No live original left to reconcile against — the copy may be the only
|
||||
// surviving version of the file, so it is never the sweep's to remove.
|
||||
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||
assert_eq!(
|
||||
decide(©, None, "f.txt", SweepMode::Enforce),
|
||||
SweepAction::Flag("no original f.txt to reconcile against".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_needs_both_size_and_digest_to_match() {
|
||||
let a = file("a", 100, Some("aaa"));
|
||||
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
||||
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
||||
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
||||
assert!(!is_identical(&a, &file("b", 100, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_conflict_names_and_leaves_others_alone() {
|
||||
assert_eq!(
|
||||
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
||||
Some("test_export.xml")
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
||||
Some("README")
|
||||
);
|
||||
// The sync engine's de-duplicated suffix form.
|
||||
assert_eq!(
|
||||
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
// Not a conflict copy.
|
||||
assert_eq!(conflict_base_name("report.xml"), None);
|
||||
// Looks close but the token is not a stamp.
|
||||
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dotted_stem_keeps_its_inner_dots() {
|
||||
assert_eq!(
|
||||
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
||||
Some("archive.tar.gz")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 ------------------------------------------------------------
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! 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.is_empty() && 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`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||
conflict_path_with_suffix(path, stamp, 0)
|
||||
}
|
||||
|
||||
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
||||
/// exists. This keeps conflict preservation from ever replacing an older copy.
|
||||
pub(super) fn conflict_path_with_suffix(path: &Path, stamp: i64, suffix: u32) -> PathBuf {
|
||||
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 suffix = if suffix == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{suffix}")
|
||||
};
|
||||
let name = match ext {
|
||||
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
|
||||
};
|
||||
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,
|
||||
/// First sync of a file that already exists on both sides with identical
|
||||
/// content: record the baseline without transferring anything. Without this,
|
||||
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
||||
/// pre-existing file, because a missing baseline makes both sides read as
|
||||
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
|
||||
AdoptBaseline,
|
||||
}
|
||||
|
||||
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(l), Some(r)) => match (local_changed, remote_changed) {
|
||||
(false, false) => FilePlan::Unchanged,
|
||||
(true, false) => FilePlan::UploadRevision,
|
||||
(false, true) => FilePlan::Download,
|
||||
// With a baseline, both-changed is a genuine concurrent edit and must
|
||||
// be preserved as a conflict. Without one, "both changed" only means
|
||||
// this is the first sync and there was nothing to compare to: adopt
|
||||
// the baseline when the sizes already match rather than conflict-copy
|
||||
// an already-identical file. Divergent sizes stay a real conflict.
|
||||
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
|
||||
(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 first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
||||
// No baseline yet (first sync). Same size on both sides is a pre-existing
|
||||
// identical file, not a concurrent edit: adopt it rather than cut a
|
||||
// conflict copy of every file in the folder.
|
||||
let local = local(10, 40);
|
||||
let remote = remote(30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&local), Some(&remote), None),
|
||||
FilePlan::AdoptBaseline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_sync_of_differing_sizes_is_still_a_conflict() {
|
||||
// No baseline and the sizes disagree: a genuine both-sides divergence,
|
||||
// preserved non-destructively as a conflict.
|
||||
let local = local(10, 20);
|
||||
let remote = remote(30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&local), Some(&remote), None),
|
||||
FilePlan::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ 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::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||
@@ -272,20 +272,50 @@ impl<R: Read> Read for OwnedCountingReader<R> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
|
||||
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path),
|
||||
/// and can be told to stop.
|
||||
pub struct CountingReader<'a, R> {
|
||||
inner: R,
|
||||
guard: &'a TransferGuard,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl<'a, R: Read> CountingReader<'a, R> {
|
||||
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
||||
Self { inner, guard }
|
||||
Self {
|
||||
inner,
|
||||
guard,
|
||||
cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Abandon the upload as soon as the SDK asks for its next bytes when
|
||||
/// `cancel` is set.
|
||||
///
|
||||
/// This is the only cancellation point an upload has: the SDK takes a plain
|
||||
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
|
||||
/// hand over the next block is what stops it. Used when a newer revision of
|
||||
/// the same file is queued — the bytes on the wire have already been
|
||||
/// superseded, and finishing them only spends uplink on a revision the next
|
||||
/// op replaces.
|
||||
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
|
||||
self.cancel = Some(cancel);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read> Read for CountingReader<'_, R> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
if let Some(cancel) = &self.cancel
|
||||
&& cancel.load(Ordering::Relaxed)
|
||||
{
|
||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||
// and the SDK would seal a truncated revision from it.
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"upload superseded by a newer write",
|
||||
));
|
||||
}
|
||||
let n = self.inner.read(buf)?;
|
||||
self.guard.add(n as u64);
|
||||
Ok(n)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
//! 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);
|
||||
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
|
||||
return Err((
|
||||
t.name,
|
||||
format!("destination access denied: errno {}", error.code()),
|
||||
));
|
||||
}
|
||||
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"))?;
|
||||
self.require_uid_writable(&parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
|
||||
// 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();
|
||||
let ino = st.intern(parent_ino, node);
|
||||
if let Some(kids) = st.children.get_mut(&parent_ino)
|
||||
&& !kids.contains(&ino)
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
}
|
||||
// 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 existing = {
|
||||
let st = self.state();
|
||||
st.children.get(&pino).and_then(|kids| {
|
||||
kids.iter().find_map(|ino| {
|
||||
st.entries
|
||||
.get(ino)
|
||||
.filter(|entry| entry.node.is_folder() && entry.node.name == name)
|
||||
.map(|entry| (*ino, entry.uid.clone()))
|
||||
})
|
||||
})
|
||||
};
|
||||
if let Some((ino, uid)) = existing {
|
||||
self.require_uid_writable(&uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
return Ok((ino, uid));
|
||||
}
|
||||
self.require_uid_writable(parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||
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();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
&& !kids.contains(&ino)
|
||||
{
|
||||
kids.push(ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
self.require_uid_writable(&new_uid)
|
||||
.map_err(|error| self.errno_error(error, "created upload destination"))?;
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Synthetic nodes and naming rules for the mounted shared tree.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
|
||||
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
|
||||
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
|
||||
const MAX_COMPONENT_BYTES: usize = 255;
|
||||
|
||||
pub(crate) fn shared_with_me_uid() -> NodeUid {
|
||||
NodeUid::new(
|
||||
VolumeId::from(VIRTUAL_VOLUME),
|
||||
LinkId::from(SHARED_WITH_ME_LINK),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
|
||||
uid.volume_id.as_str() == VIRTUAL_VOLUME
|
||||
}
|
||||
|
||||
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
|
||||
uid.volume_id == *own_volume || is_virtual_uid(uid)
|
||||
}
|
||||
|
||||
pub(crate) fn is_primary_root_listing(
|
||||
primary: bool,
|
||||
folder_uid: &NodeUid,
|
||||
primary_root_uid: &NodeUid,
|
||||
) -> bool {
|
||||
primary && folder_uid == primary_root_uid
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
|
||||
Node {
|
||||
uid: shared_with_me_uid(),
|
||||
parent_uid: Some(parent_uid),
|
||||
name,
|
||||
kind: NodeKind::Folder,
|
||||
creation_time: timestamp,
|
||||
modification_time: timestamp,
|
||||
trashed: false,
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
// A synthetic folder is neither a photo nor an album.
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
|
||||
/// a later real collision only controls whether the synthetic dentry is exposed.
|
||||
pub(crate) fn virtual_root_name(
|
||||
real_names: &HashSet<String>,
|
||||
pinned: Option<&str>,
|
||||
) -> (String, bool) {
|
||||
if let Some(pinned) = pinned {
|
||||
return (pinned.to_string(), !real_names.contains(pinned));
|
||||
}
|
||||
for n in 0usize.. {
|
||||
let candidate = match n {
|
||||
0 => SHARED_WITH_ME_BASE.to_string(),
|
||||
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
|
||||
};
|
||||
if !real_names.contains(&candidate) {
|
||||
return (candidate, true);
|
||||
}
|
||||
}
|
||||
unreachable!("the numbered virtual-root namespace is unbounded")
|
||||
}
|
||||
|
||||
/// Give every shared root a unique Linux component name, independent of API
|
||||
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
|
||||
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
|
||||
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
|
||||
nodes.sort_by(|a, b| {
|
||||
a.name
|
||||
.cmp(&b.name)
|
||||
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
|
||||
});
|
||||
let reserved: HashSet<String> = nodes
|
||||
.iter()
|
||||
.map(|node| component_with_suffix(&node.name, ""))
|
||||
.collect();
|
||||
let mut used = HashSet::with_capacity(nodes.len());
|
||||
|
||||
for node in nodes {
|
||||
let original = node.name.clone();
|
||||
let basename = component_with_suffix(&original, "");
|
||||
let chosen = if used.insert(basename.clone()) {
|
||||
basename
|
||||
} else {
|
||||
let mut n = 2usize;
|
||||
loop {
|
||||
let suffix = format!(" ({n})");
|
||||
let candidate = component_with_suffix(&original, &suffix);
|
||||
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
|
||||
break candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
};
|
||||
node.name = chosen;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
|
||||
online && stale
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SharedListingPlan {
|
||||
Resident,
|
||||
Persisted,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
|
||||
if listing_needs_refresh(online, stale) {
|
||||
SharedListingPlan::Refresh
|
||||
} else if resident {
|
||||
SharedListingPlan::Resident
|
||||
} else {
|
||||
SharedListingPlan::Persisted
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
|
||||
started == current
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SharedRefreshDeadlines {
|
||||
deadlines: HashMap<String, Instant>,
|
||||
}
|
||||
|
||||
impl SharedRefreshDeadlines {
|
||||
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
|
||||
self.deadlines.retain(|_, deadline| *deadline > now);
|
||||
self.deadlines
|
||||
.get(key)
|
||||
.is_some_and(|deadline| *deadline > now)
|
||||
}
|
||||
|
||||
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
|
||||
self.deadlines.insert(key.to_string(), now + ttl);
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.deadlines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn component_with_suffix(name: &str, suffix: &str) -> String {
|
||||
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
|
||||
let mut boundary = name.len().min(keep);
|
||||
while !name.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
let mut out = String::with_capacity(boundary + suffix.len());
|
||||
out.push_str(&name[..boundary]);
|
||||
out.push_str(suffix);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(volume: &str, link: &str, name: &str) -> Node {
|
||||
let mut node = virtual_node(
|
||||
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
|
||||
name.to_string(),
|
||||
1,
|
||||
);
|
||||
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn virtual_uid_and_node_round_trip() {
|
||||
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
|
||||
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
|
||||
assert!(is_virtual_uid(&node.uid));
|
||||
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
|
||||
assert!(node.is_folder());
|
||||
let json = serde_json::to_string(&node).unwrap();
|
||||
let restored: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.uid, node.uid);
|
||||
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_collision_ladder_and_pinned_collision_policy() {
|
||||
let names = HashSet::from([
|
||||
SHARED_WITH_ME_BASE.to_string(),
|
||||
format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
|
||||
]);
|
||||
assert_eq!(
|
||||
virtual_root_name(&names, None),
|
||||
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
|
||||
);
|
||||
|
||||
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
|
||||
assert_eq!(name, SHARED_WITH_ME_BASE);
|
||||
assert!(!visible, "a real later collision suppresses the dentry");
|
||||
|
||||
let cleared = HashSet::new();
|
||||
assert_eq!(
|
||||
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
|
||||
(SHARED_WITH_ME_BASE.to_string(), true),
|
||||
"the pinned name returns when the collision clears"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
|
||||
let input = vec![
|
||||
folder("b", "2", "Photos"),
|
||||
folder("c", "3", "Photos (2)"),
|
||||
folder("a", "1", "Photos"),
|
||||
];
|
||||
let mut forward = input.clone();
|
||||
let mut reverse = input;
|
||||
reverse.reverse();
|
||||
disambiguate_shared_names(&mut forward);
|
||||
disambiguate_shared_names(&mut reverse);
|
||||
let summarize = |nodes: &[Node]| {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|node| (node.uid.to_string(), node.name.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(summarize(&forward), summarize(&reverse));
|
||||
assert_eq!(
|
||||
forward
|
||||
.iter()
|
||||
.map(|node| node.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Photos", "Photos (3)", "Photos (2)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disambiguation_respects_utf8_component_limit() {
|
||||
let long = "é".repeat(200);
|
||||
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
|
||||
disambiguate_shared_names(&mut nodes);
|
||||
assert!(nodes.iter().all(|node| node.name.len() <= 255));
|
||||
assert!(
|
||||
nodes
|
||||
.iter()
|
||||
.all(|node| node.name.is_char_boundary(node.name.len()))
|
||||
);
|
||||
assert_ne!(nodes[0].name, nodes[1].name);
|
||||
assert!(nodes[1].name.ends_with(" (2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_refresh_only_happens_online() {
|
||||
assert!(!listing_needs_refresh(false, true));
|
||||
assert!(!listing_needs_refresh(true, false));
|
||||
assert!(listing_needs_refresh(true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
|
||||
assert_eq!(
|
||||
shared_listing_plan(false, false, true),
|
||||
SharedListingPlan::Persisted
|
||||
);
|
||||
assert_eq!(
|
||||
shared_listing_plan(true, true, false),
|
||||
SharedListingPlan::Resident
|
||||
);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut deadlines = SharedRefreshDeadlines::default();
|
||||
deadlines.mark("shared", now, Duration::from_secs(60));
|
||||
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||
deadlines.clear();
|
||||
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_change_rejects_an_inflight_response() {
|
||||
assert!(refresh_generation_is_current(7, 7));
|
||||
assert!(!refresh_generation_is_current(7, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_primary_inode_space_installs_the_synthetic_root() {
|
||||
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||
assert!(is_primary_root_listing(true, &root, &root));
|
||||
assert!(!is_primary_root_listing(false, &root, &root));
|
||||
assert!(!is_primary_root_listing(
|
||||
true,
|
||||
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
|
||||
&root
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_predicate_excludes_foreign_share_volumes() {
|
||||
let own = VolumeId::from("own");
|
||||
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
|
||||
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
|
||||
assert!(is_own_or_virtual_uid(&own_uid, &own));
|
||||
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
|
||||
assert!(!is_own_or_virtual_uid(&foreign, &own));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,137 @@
|
||||
//! 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. Everything else opens through the mount too whenever the mount
|
||||
//! exposes it, and only falls back to materialize-then-open when it does not —
|
||||
//! see [`mounted_target`].
|
||||
//!
|
||||
//! Both binaries `#[path]`-include this file, so each compiles its own copy and
|
||||
//! anything only one of them calls looks dead in the other. That is what the
|
||||
//! `dead_code` allowances below are for — not code nobody uses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfs_core::control::{PhotoKind, SearchHit};
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// Where a Drive hit should be opened from, when a mount exposes it.
|
||||
///
|
||||
/// Materialised content is a *copy*: it lives in the cache under its content
|
||||
/// hash, so an editor saving into it writes somewhere Drive will never look,
|
||||
/// and the cache is free to evict it afterwards. The mounted path is the real
|
||||
/// file — reads pull 4 MiB blocks on demand and a write becomes a new revision
|
||||
/// — so it wins for every kind of file, not just streamable media.
|
||||
///
|
||||
/// [`Request::OpenFile`](pdfs_core::control::Request::OpenFile) stays the
|
||||
/// fallback for hits no mount exposes: the search index also covers nodes
|
||||
/// outside the mounted tree (a mirror folder's contents, a stale row), and
|
||||
/// those can still be read, just not edited in place.
|
||||
///
|
||||
/// The `stat` is why this returns an `Option` rather than a path: it is the
|
||||
/// only way to tell those two cases apart, and it costs one syscall on a path
|
||||
/// the caller is about to hand to an application anyway.
|
||||
#[allow(dead_code)] // prompt only
|
||||
pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathBuf> {
|
||||
present(mounted_or_relative(mountpoint, hit))
|
||||
}
|
||||
|
||||
/// [`mounted_target`] for callers holding a mountpoint-relative path rather
|
||||
/// than a search hit — the browser's listing entries, which are by definition
|
||||
/// paths in the primary mount's tree.
|
||||
#[allow(dead_code)] // browser only
|
||||
pub(crate) fn mounted_target_rel(mountpoint: &Path, relative_path: &str) -> Option<PathBuf> {
|
||||
present(mounted_path(mountpoint, relative_path))
|
||||
}
|
||||
|
||||
fn present(path: PathBuf) -> Option<PathBuf> {
|
||||
path.symlink_metadata().is_ok().then_some(path)
|
||||
}
|
||||
|
||||
/// The mount path for a hit, whether or not anything is there — for callers
|
||||
/// that have no fallback (a folder cannot be materialised) or already know the
|
||||
/// entry came from a mount listing.
|
||||
#[allow(dead_code)] // prompt only
|
||||
pub(crate) fn mounted_or_relative(mountpoint: &Path, hit: &SearchHit) -> PathBuf {
|
||||
hit.mounted_path
|
||||
.as_deref()
|
||||
.map_or_else(|| mounted_path(mountpoint, &hit.path), PathBuf::from)
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
#[path = "../activation.rs"]
|
||||
pub(crate) mod activation;
|
||||
pub(crate) mod pages;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use pages::activity::*;
|
||||
use pages::albums::*;
|
||||
use pages::browser::*;
|
||||
use pages::devices::*;
|
||||
use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
use pages::photos::*;
|
||||
@@ -11,8 +15,10 @@ use pages::shared::*;
|
||||
use pages::shared_by_me::*;
|
||||
use pages::status::*;
|
||||
use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use widgets::details::*;
|
||||
use widgets::share_dialog::*;
|
||||
use widgets::versions_dialog::*;
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
@@ -39,12 +45,15 @@ use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
|
||||
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit,
|
||||
ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
TransferDirection, TransferItem, pending_summary, send,
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
||||
send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
|
||||
use pdfs_core::service;
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||
@@ -109,6 +118,7 @@ struct Ui {
|
||||
pub(crate) shared: SharedState,
|
||||
pub(crate) shared_by_me: SharedByMeState,
|
||||
pub(crate) devices: DevicesState,
|
||||
pub(crate) locations: LocationsState,
|
||||
pub(crate) activity: ActivityState,
|
||||
}
|
||||
|
||||
@@ -130,23 +140,10 @@ impl Ui {
|
||||
}
|
||||
}
|
||||
|
||||
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
|
||||
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
|
||||
fn ratio(&self, uid: &str) -> f64 {
|
||||
self.gallery
|
||||
.photo_ratio
|
||||
.borrow()
|
||||
.get(uid)
|
||||
.copied()
|
||||
.unwrap_or(RATIO_UNKNOWN)
|
||||
}
|
||||
|
||||
/// Remember a decoded thumbnail and the aspect ratio it revealed. Returns
|
||||
/// true when the ratio is *new information* — the caller re-justifies the
|
||||
/// affected rows, since they were laid out against a guess.
|
||||
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool {
|
||||
let (w, h) = (texture.width(), texture.height());
|
||||
|
||||
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
||||
/// Scrolling back over a day then repaints from memory rather than decoding
|
||||
/// the same JPEGs off disk again.
|
||||
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
||||
let mut cache = self.gallery.photo_tex.borrow_mut();
|
||||
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
||||
if cache.insert(uid.to_string(), texture).is_none() {
|
||||
@@ -157,58 +154,6 @@ impl Ui {
|
||||
cache.remove(&old);
|
||||
}
|
||||
}
|
||||
drop((cache, order));
|
||||
|
||||
if h <= 0 {
|
||||
return false;
|
||||
}
|
||||
let ratio = f64::from(w) / f64::from(h);
|
||||
let mut ratios = self.gallery.photo_ratio.borrow_mut();
|
||||
match ratios.insert(uid.to_string(), ratio) {
|
||||
// Only a *changed* ratio invalidates a layout; re-decoding a photo we
|
||||
// already sized correctly must not trigger another pass.
|
||||
Some(prev) if (prev - ratio).abs() < f64::EPSILON => false,
|
||||
_ => {
|
||||
self.gallery.photo_ratio_dirty.set(true);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the learned aspect ratios are persisted between runs.
|
||||
fn ratio_path(&self) -> PathBuf {
|
||||
self.dirs.cache_dir().join("photo-ratios.json")
|
||||
}
|
||||
|
||||
/// Load the persisted aspect ratios. A missing or corrupt file just means the
|
||||
/// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land.
|
||||
fn load_ratios(&self) {
|
||||
let path = self.ratio_path();
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
return;
|
||||
};
|
||||
match serde_json::from_str::<HashMap<String, f64>>(&text) {
|
||||
Ok(map) => *self.gallery.photo_ratio.borrow_mut() = map,
|
||||
Err(e) => tracing::debug!("ignoring {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the learned aspect ratios, if any were learned since the last save.
|
||||
fn save_ratios(&self) {
|
||||
if !self.gallery.photo_ratio_dirty.replace(false) {
|
||||
return;
|
||||
}
|
||||
let path = self.ratio_path();
|
||||
let ratios = self.gallery.photo_ratio.borrow();
|
||||
let write = serde_json::to_vec(&*ratios)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|bytes| {
|
||||
std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?;
|
||||
std::fs::write(&path, bytes)
|
||||
});
|
||||
if let Err(e) = write {
|
||||
tracing::debug!("cannot save {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +224,23 @@ fn load_proton_theme() {
|
||||
.viewer-spinner {{ color: white; }}\n\
|
||||
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
||||
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
||||
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\
|
||||
.photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\
|
||||
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
|
||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
|
||||
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
||||
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
|
||||
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
|
||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
|
||||
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
||||
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
||||
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
||||
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
|
||||
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
|
||||
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
|
||||
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
|
||||
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
|
||||
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
|
||||
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
||||
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||
@@ -329,6 +282,7 @@ fn build_window(app: &adw::Application) {
|
||||
let (shared_page, shared_widgets) = build_shared_page();
|
||||
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
||||
let (devices_page, devices_widgets) = build_devices_page();
|
||||
let (locations_page, locations_widgets) = build_locations_page();
|
||||
let (activity_page, activity_widgets) = build_activity_page();
|
||||
let (trash_page, trash_widgets) = build_trash_page();
|
||||
stack.add_named(&login_page, Some("login"));
|
||||
@@ -338,6 +292,7 @@ fn build_window(app: &adw::Application) {
|
||||
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
||||
stack.add_named(&shared_page, Some("shared"));
|
||||
stack.add_named(&devices_page, Some("devices"));
|
||||
stack.add_named(&locations_page, Some("locations"));
|
||||
stack.add_named(&activity_page, Some("activity"));
|
||||
stack.add_named(&trash_page, Some("trash"));
|
||||
|
||||
@@ -398,6 +353,11 @@ fn build_window(app: &adw::Application) {
|
||||
transfers_inflight: Cell::new(false),
|
||||
cache_bar: main_widgets.cache_bar.clone(),
|
||||
cache_label: main_widgets.cache_label.clone(),
|
||||
quota_group: main_widgets.quota_group.clone(),
|
||||
quota_bar: main_widgets.quota_bar.clone(),
|
||||
quota_label: main_widgets.quota_label.clone(),
|
||||
quota_inflight: Cell::new(false),
|
||||
quota_checked_at: Cell::new(None),
|
||||
autostart_row: main_widgets.autostart_row.clone(),
|
||||
budget_row: main_widgets.budget_row.clone(),
|
||||
mountpoint_row: main_widgets.mountpoint_row.clone(),
|
||||
@@ -447,19 +407,31 @@ fn build_window(app: &adw::Application) {
|
||||
retry: gallery_widgets.retry.clone(),
|
||||
more: gallery_widgets.more.clone(),
|
||||
upload: gallery_widgets.upload.clone(),
|
||||
title: gallery_widgets.title.clone(),
|
||||
subtitle: gallery_widgets.subtitle.clone(),
|
||||
albums: gallery_widgets.albums.clone(),
|
||||
albums_stack: gallery_widgets.albums_stack.clone(),
|
||||
albums_status: gallery_widgets.albums_status.clone(),
|
||||
photos_btn: gallery_widgets.photos_btn.clone(),
|
||||
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||
view_switch: gallery_widgets.view_switch.clone(),
|
||||
albums_loading: Cell::new(false),
|
||||
album: RefCell::new(None),
|
||||
back: gallery_widgets.back.clone(),
|
||||
filters: gallery_widgets.filters.clone(),
|
||||
kind: Cell::new(None),
|
||||
tabs: gallery_widgets.tabs.clone(),
|
||||
counts: Cell::new(None),
|
||||
dates: gallery_widgets.dates.clone(),
|
||||
date_ranges: RefCell::new(vec![None]),
|
||||
range: Cell::new(None),
|
||||
favorites_btn: gallery_widgets.favorites_btn.clone(),
|
||||
favorites: Cell::new(false),
|
||||
date_suppress: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
width: Cell::new(0),
|
||||
photo_tex: RefCell::new(HashMap::new()),
|
||||
photo_tex_order: RefCell::new(VecDeque::new()),
|
||||
photo_ratio: RefCell::new(HashMap::new()),
|
||||
photo_ratio_dirty: Cell::new(false),
|
||||
photo_nothumb: RefCell::new(HashSet::new()),
|
||||
thumb_wanted: RefCell::new(HashMap::new()),
|
||||
thumb_queue: RefCell::new(VecDeque::new()),
|
||||
@@ -477,6 +449,7 @@ fn build_window(app: &adw::Application) {
|
||||
with_me_group: shared_widgets.shared_with_me.clone(),
|
||||
invitations_group: shared_widgets.invitations.clone(),
|
||||
bookmarks_group: shared_widgets.bookmarks.clone(),
|
||||
nav: RefCell::new(Vec::new()),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
@@ -498,6 +471,17 @@ fn build_window(app: &adw::Application) {
|
||||
rows: RefCell::new(Vec::new()),
|
||||
sync_group: devices_widgets.sync_group.clone(),
|
||||
sync_rows: RefCell::new(Vec::new()),
|
||||
rename_this: devices_widgets.rename_this.clone(),
|
||||
this_device: RefCell::new(None),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
locations: LocationsState {
|
||||
content: locations_widgets.content.clone(),
|
||||
status: locations_widgets.status.clone(),
|
||||
retry: locations_widgets.retry.clone(),
|
||||
group: locations_widgets.group.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
@@ -511,8 +495,6 @@ fn build_window(app: &adw::Application) {
|
||||
key: RefCell::new(None),
|
||||
},
|
||||
});
|
||||
ui.load_ratios();
|
||||
|
||||
wire_login(&ui);
|
||||
wire_logout(&ui, &main_widgets.logout_button);
|
||||
wire_settings(
|
||||
@@ -531,10 +513,12 @@ fn build_window(app: &adw::Application) {
|
||||
wire_details(&ui);
|
||||
wire_search(&ui);
|
||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||
wire_albums(&ui);
|
||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.add_folder);
|
||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||
wire_activity(&ui, &activity_widgets.retry);
|
||||
wire_refresh(
|
||||
&ui,
|
||||
@@ -545,6 +529,7 @@ fn build_window(app: &adw::Application) {
|
||||
&shared_widgets.refresh,
|
||||
&shared_by_me_widgets.refresh,
|
||||
&devices_widgets.refresh,
|
||||
&locations_widgets.refresh,
|
||||
&activity_widgets.refresh,
|
||||
],
|
||||
);
|
||||
@@ -567,6 +552,8 @@ fn build_window(app: &adw::Application) {
|
||||
Some("shared") => load_shared(&ui_nav),
|
||||
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
||||
Some("devices") => load_devices(&ui_nav),
|
||||
Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {}
|
||||
Some("locations") => load_locations(&ui_nav),
|
||||
// Activity is intentionally not TTL-cached: it changes out from under
|
||||
// the page as background uploads and edits complete, so it reloads on
|
||||
// every visit to stay live.
|
||||
@@ -595,12 +582,10 @@ fn build_window(app: &adw::Application) {
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
let cell = RefCell::new(Some(source));
|
||||
let ui_close = ui.clone();
|
||||
window.connect_close_request(move |_| {
|
||||
if let Some(id) = cell.borrow_mut().take() {
|
||||
id.remove();
|
||||
}
|
||||
ui_close.save_ratios();
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
@@ -609,12 +594,13 @@ fn build_window(app: &adw::Application) {
|
||||
|
||||
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||
/// and each entry is `(stack page name, label, icon)`.
|
||||
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
||||
const DESTINATIONS: [(&str, &str, &str); 9] = [
|
||||
("browser", "My files", "folder-symbolic"),
|
||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||
("shared", "Shared with me", "system-users-symbolic"),
|
||||
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||
("devices", "Computers", "computer-symbolic"),
|
||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
||||
("gallery", "Gallery", "image-x-generic-symbolic"),
|
||||
("activity", "Activity", "document-open-recent-symbolic"),
|
||||
("trash", "Trash", "user-trash-symbolic"),
|
||||
("main", "Settings", "emblem-system-symbolic"),
|
||||
@@ -717,7 +703,15 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||
}
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)),
|
||||
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||
// the two — album grid or timeline/album — is actually on screen.
|
||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
} else {
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
}),
|
||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||
Some("shared") => {
|
||||
ui.shared.loaded_at.set(None);
|
||||
@@ -731,6 +725,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
||||
ui.devices.loaded_at.set(None);
|
||||
load_devices(ui);
|
||||
}
|
||||
Some("locations") => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
load_locations(ui);
|
||||
}
|
||||
Some("activity") => load_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
@@ -998,11 +996,18 @@ fn capitalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a local path with the user's default handler.
|
||||
/// Open a local path with the handler the user configured — `xdg-open` unless
|
||||
/// `open_with` in `config.json` overrides it for this kind of file.
|
||||
fn open_path(path: &str) {
|
||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
||||
tracing::error!("xdg-open {path} failed: {e}");
|
||||
}
|
||||
let path = Path::new(path);
|
||||
pdfs_core::opener::open_default(path, path.is_dir());
|
||||
}
|
||||
|
||||
/// [`open_path`] for a file the daemon materialised into the content cache,
|
||||
/// where the on-disk name is a content hash and carries no extension for the
|
||||
/// rules (or `xdg-open`) to key off. `name` is the Drive name it was opened as.
|
||||
pub(crate) fn open_named_path(path: &str, name: &str) {
|
||||
pdfs_core::opener::open_default_named(Path::new(path), name, false);
|
||||
}
|
||||
|
||||
/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`).
|
||||
@@ -1028,81 +1033,54 @@ fn human_bytes(bytes: u64) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A justified row must fill the content width exactly: tile widths plus the
|
||||
/// gaps between them add up to the width, with nothing left ragged.
|
||||
fn row_width(row: &[(i32, i32)]) -> i32 {
|
||||
let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32;
|
||||
row.iter().map(|(width, _)| *width).sum::<i32>() + gaps
|
||||
/// The full width one row of `columns` tiles occupies, gaps included.
|
||||
fn row_width(columns: usize, edge: i32) -> i32 {
|
||||
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_rows_fill_the_width_exactly() {
|
||||
let ratios = [
|
||||
1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6,
|
||||
];
|
||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
||||
|
||||
assert!(
|
||||
rows.len() > 1,
|
||||
"12 photos at 180px should need several rows"
|
||||
);
|
||||
// Every row but the trailing one is justified.
|
||||
for row in &rows[..rows.len() - 1] {
|
||||
assert_eq!(row_width(row), 1000);
|
||||
fn the_grid_spans_the_content_width() {
|
||||
// Whatever the width, the columns plus their gaps land on it (bar the
|
||||
// few px integer division cannot divide), so no ragged right margin.
|
||||
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
|
||||
let used = row_width(columns, edge);
|
||||
assert!(
|
||||
used <= width && width - used < columns as i32,
|
||||
"{columns} x {edge}px = {used}px does not span {width}px"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_photo_lands_in_exactly_one_row() {
|
||||
let ratios: Vec<f64> = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect();
|
||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
||||
let tiles: usize = rows.iter().map(Vec::len).sum();
|
||||
assert_eq!(tiles, ratios.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_keep_their_aspect_ratio() {
|
||||
let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0);
|
||||
// All but the last tile of a justified row are sized straight from the
|
||||
// ratio, so w/h is the photo's own — no cropping needed to place it.
|
||||
for row in &rows {
|
||||
for (width, height) in &row[..row.len() - 1] {
|
||||
let ratio = f64::from(*width) / f64::from(*height);
|
||||
assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_row_is_not_stretched() {
|
||||
// Two photos can't fill a 1000px row at 180px tall, so they must keep the
|
||||
// target height rather than being blown up to a screen-wide row.
|
||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].iter().all(|(_, height)| *height == 180));
|
||||
assert!(row_width(&rows[0]) < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extreme_panorama_cannot_squash_its_row() {
|
||||
// Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall.
|
||||
// Clamped at RATIO_MAX it stays a wide tile in a row of normal height.
|
||||
let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0);
|
||||
let (width, height) = rows[0][0];
|
||||
assert!(height > 120, "row squashed to {height}px");
|
||||
assert!(width <= 1000, "tile overflows the row at {width}px");
|
||||
assert_eq!(row_width(&rows[0]), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_widths_and_no_photos_are_survivable() {
|
||||
assert!(plan_rows(&[], 180.0, 1000.0).is_empty());
|
||||
// Width below the floor is clamped rather than producing zero-px tiles.
|
||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0);
|
||||
fn tiles_stay_near_the_target_size() {
|
||||
// The column count rounds and the edge absorbs the remainder, so a tile
|
||||
// is never more than one gap-and-a-bit away from what was asked for.
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
|
||||
assert!(columns >= 7, "1920px should hold several 220px tiles");
|
||||
assert!(
|
||||
rows.iter()
|
||||
.flatten()
|
||||
.all(|(width, height)| *width > 0 && *height > 0)
|
||||
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
|
||||
"{edge}px is not near the {TILE_DEFAULT}px target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_in_widens_the_tiles_and_drops_columns() {
|
||||
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
|
||||
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
|
||||
assert!(wide_columns > few_columns);
|
||||
assert!(big_edge > small_edge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_narrower_than_one_tile_still_gets_a_column() {
|
||||
// A single column at whatever fits, rather than zero columns (which would
|
||||
// divide by zero) or a zero-px tile.
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
|
||||
assert_eq!(columns, 1);
|
||||
assert!(edge > 0);
|
||||
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
|
||||
assert_eq!(columns, 1);
|
||||
assert!(edge > 0);
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,7 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
ActivityKind::Share => "Shared",
|
||||
ActivityKind::PublicLink => "Linked",
|
||||
ActivityKind::Unshare => "Unshared",
|
||||
ActivityKind::Conflict => "Conflict",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +300,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
ActivityKind::Restore => "edit-undo-symbolic",
|
||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||
ActivityKind::Conflict => "dialog-warning-symbolic",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The Albums view of the Photos page: a grid of album covers, and the album
|
||||
//! that opens when one is clicked.
|
||||
//!
|
||||
//! An open album reuses the gallery wholesale — same square grid, same
|
||||
//! on-demand thumbnails, same lightbox — by parking the album in
|
||||
//! [`GalleryState::album`] and letting [`load_gallery`] page
|
||||
//! [`Request::AlbumPhotos`] instead of the timeline. Only the header and the
|
||||
//! filter bar differ, because an album is served whole rather than filtered.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Edge length in px of an album cover in the grid. Bigger than a timeline tile:
|
||||
/// there are far fewer albums than photos, and the cover is the only thing
|
||||
/// identifying one at a glance.
|
||||
const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
load_albums(ui);
|
||||
}
|
||||
|
||||
/// Ask the daemon for the album listing and rebuild the grid.
|
||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
if ui.gallery.albums_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.albums_loading.set(true);
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
);
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.gallery.albums_loading.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Albums {
|
||||
available: false, ..
|
||||
})) => albums_status(
|
||||
&ui,
|
||||
"image-missing-symbolic",
|
||||
"No photo library",
|
||||
"This Proton account doesn't have Photos enabled.",
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||
&ui,
|
||||
"view-grid-symbolic",
|
||||
"No albums",
|
||||
"Albums you create in Proton Photos appear here.",
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
fill_albums(&ui, &items);
|
||||
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||
albums_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't load albums",
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||
/// (newest activity first).
|
||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||
while let Some(child) = ui.gallery.albums.first_child() {
|
||||
ui.gallery.albums.remove(&child);
|
||||
}
|
||||
for album in albums {
|
||||
ui.gallery.albums.append(&album_card(ui, album));
|
||||
}
|
||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||
// batch that fills them is the gallery's own.
|
||||
schedule_thumbs(ui);
|
||||
}
|
||||
|
||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||
/// holds. An album shared with us says so — it lives on someone else's volume,
|
||||
/// and that is worth knowing before opening it.
|
||||
fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// Fills and crops the cover square, like a timeline tile.
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("view-grid-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
let cover = gtk4::Overlay::new();
|
||||
cover.set_child(Some(&placeholder));
|
||||
cover.add_overlay(&picture);
|
||||
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||
cover.add_css_class("album-cover");
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(&album.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("heading");
|
||||
|
||||
let count = gtk4::Label::builder()
|
||||
.label(album_subtitle(album))
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
count.add_css_class("dim-label");
|
||||
count.add_css_class("caption");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.append(&cover);
|
||||
card.append(&name);
|
||||
card.append(&count);
|
||||
|
||||
let button = gtk4::Button::builder().child(&card).build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("album-card");
|
||||
button.set_tooltip_text(Some(&album.name));
|
||||
|
||||
if let Some(uid) = album.cover_uid.clone() {
|
||||
want_cover(ui, uid, &picture);
|
||||
}
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let album = album.clone();
|
||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// "12 photos", plus where the album came from when it isn't ours.
|
||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
let photos = match album.photo_count {
|
||||
1 => "1 photo".to_string(),
|
||||
n => format!("{n} photos"),
|
||||
};
|
||||
if album.shared {
|
||||
format!("{photos} · shared with me")
|
||||
} else {
|
||||
photos
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
}
|
||||
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||
return;
|
||||
}
|
||||
ui.gallery
|
||||
.thumb_wanted
|
||||
.borrow_mut()
|
||||
.insert(uid.clone(), picture.clone());
|
||||
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||
if !queue.contains(&uid) {
|
||||
queue.push_back(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one album in the gallery: the timeline view, paged from the album
|
||||
/// instead, with the filters that don't apply to it out of the way.
|
||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||
ui.gallery.title.set_label(&album.name);
|
||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
||||
ui.gallery.subtitle.set_visible(true);
|
||||
*ui.gallery.album.borrow_mut() = Some(album);
|
||||
|
||||
// An album page carries no kind or date filter, and Upload targets the
|
||||
// timeline rather than an album — hide those rather than offer controls that
|
||||
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||
// album is a level below it, and back is the way out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
// A stale kind/date filter would otherwise be sent with the album request.
|
||||
ui.gallery.kind.set(None);
|
||||
ui.gallery.range.set(None);
|
||||
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
|
||||
/// Leave an open album, restoring the timeline's header and filters. A no-op
|
||||
/// when no album is open, so callers can use it as "make sure we're not in one".
|
||||
pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_label("Gallery");
|
||||
ui.gallery.filters.set_visible(true);
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
|
||||
/// Show the album grid's own status page (loading, empty, or an error).
|
||||
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.albums_status.set_icon_name(Some(icon));
|
||||
ui.gallery.albums_status.set_title(title);
|
||||
ui.gallery.albums_status.set_description(Some(description));
|
||||
ui.gallery.albums_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Wire the Photos/Albums switcher and the back button. The switcher swaps the
|
||||
/// whole content area; back leaves an open album for the grid it came from.
|
||||
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
let ui_albums = ui.clone();
|
||||
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
show_albums(&ui_albums);
|
||||
}
|
||||
});
|
||||
|
||||
// Only the button being switched *on* acts: the group fires `toggled` for
|
||||
// the one going off too, and acting on both would load twice.
|
||||
let ui_photos = ui.clone();
|
||||
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
||||
if !btn.is_active() {
|
||||
return;
|
||||
}
|
||||
// The timeline is reloaded rather than restored, because an open album
|
||||
// left the model holding its own photos.
|
||||
close_album(&ui_photos);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(&ui_photos, false);
|
||||
});
|
||||
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
show_albums(&ui_back);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel};
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct BrowserState {
|
||||
@@ -493,9 +494,9 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Bo
|
||||
popover.set_parent(anchor);
|
||||
popover.connect_closed(|p| p.unparent());
|
||||
|
||||
// Videos lead with Play (stream from the mount, no download); the plain Open
|
||||
// Media leads with Play (stream from the mount, no download); the plain Open
|
||||
// below still downloads a local copy for anyone who wants one.
|
||||
if is_video_entry(entry) {
|
||||
if is_streamable_media_entry(entry) {
|
||||
let play = menu_item("Play (stream)", "media-playback-start-symbolic");
|
||||
let ui_play = ui.clone();
|
||||
let entry_play = entry.clone();
|
||||
@@ -514,8 +515,8 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Bo
|
||||
open.connect_clicked(move |_| {
|
||||
pop.popdown();
|
||||
// Open always means "download a local copy and hand off", even for a
|
||||
// video — `activate_entry` would otherwise stream it.
|
||||
if is_video_entry(&entry_open) {
|
||||
// media file — `activate_entry` would otherwise stream it.
|
||||
if is_streamable_media_entry(&entry_open) {
|
||||
download_and_open(&ui_open, &entry_open);
|
||||
} else {
|
||||
activate_entry(&ui_open, &entry_open);
|
||||
@@ -610,14 +611,12 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// Whether an entry's name marks it as a video, reusing the same classifier the
|
||||
/// Photos page splits on — so the file browser and the gallery agree on what a
|
||||
/// video is.
|
||||
pub(crate) fn is_video_entry(entry: &DirEntry) -> bool {
|
||||
!entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video
|
||||
/// Whether an entry can be opened through FUSE instead of fully materialized.
|
||||
pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
|
||||
drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
|
||||
}
|
||||
|
||||
/// Stream a video straight from the mount, no download. Drive folders *are* part
|
||||
/// Stream media straight from the mount, no download. Drive folders *are* part
|
||||
/// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
|
||||
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it
|
||||
/// seeks and buffers — instead of waiting for the whole file to land like
|
||||
@@ -626,11 +625,11 @@ pub(crate) fn is_video_entry(entry: &DirEntry) -> bool {
|
||||
pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let abs = mountpoint.join(&rel);
|
||||
let abs = mounted_path(&mountpoint, &rel);
|
||||
let Some(path) = abs.to_str() else {
|
||||
toast_error(
|
||||
ui,
|
||||
"Couldn't play video",
|
||||
"Couldn't play media",
|
||||
"The file path isn't valid UTF-8.",
|
||||
);
|
||||
return;
|
||||
@@ -639,7 +638,7 @@ pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
play_external(path);
|
||||
}
|
||||
|
||||
/// Open an entry the Nautilus way: folders descend, videos stream from the mount,
|
||||
/// Open an entry the Nautilus way: folders descend, media streams from the mount,
|
||||
/// other files download-and-open.
|
||||
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -651,8 +650,8 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
}
|
||||
*ui.browser.path.borrow_mut() = rel;
|
||||
load_browser(ui);
|
||||
} else if is_video_entry(entry) {
|
||||
// A video streams rather than downloads: that is exactly the "play it,
|
||||
} else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
|
||||
// Media streams rather than downloads: that is exactly the "play it,
|
||||
// don't fetch the whole thing" behaviour this is for.
|
||||
stream_entry(ui, entry);
|
||||
} else {
|
||||
@@ -660,21 +659,35 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a file's full content into the cache and hand it to the user's
|
||||
/// default application. The download-and-open path behind both a plain
|
||||
/// double-click and the context menu's "Open" (including for a video, when the
|
||||
/// user explicitly wants a local copy rather than to stream it).
|
||||
/// Hand a file to the user's default application: through the mount when it is
|
||||
/// there, otherwise downloaded into the cache first. The open path behind both
|
||||
/// a plain double-click and the context menu's "Open" (including for a video,
|
||||
/// when the user wants their default application rather than the player).
|
||||
///
|
||||
/// The mount comes first because the cache blob is a *copy* keyed by content
|
||||
/// hash — an application saving into it writes where Drive will never look, and
|
||||
/// the cache may evict it afterwards. See
|
||||
/// [`mounted_target`](crate::activation::mounted_target).
|
||||
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
if let Some(path) = mounted_target_rel(&mountpoint, &rel) {
|
||||
open_path(&path.to_string_lossy());
|
||||
return;
|
||||
}
|
||||
// Ignore a repeat activation of a file already downloading, so an impatient
|
||||
// double-click doesn't kick off a second round-trip.
|
||||
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let name = entry.name.clone();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenFile { path: rel.clone() },
|
||||
Request::OpenFile {
|
||||
path: rel.clone(),
|
||||
uid: Some(entry.uid.clone()),
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -682,7 +695,9 @@ pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.busy_end();
|
||||
ui.opening.borrow_mut().remove(&rel);
|
||||
match result {
|
||||
Ok(Ok(Response::FilePath { path })) => open_path(&path),
|
||||
// A cache blob is named by content hash: match the open rules
|
||||
// against the Drive name the user clicked, not that path.
|
||||
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't open file", &message, kind)
|
||||
}
|
||||
@@ -1443,6 +1458,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
cached: false,
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
role: String::new(),
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|a, b| {
|
||||
|
||||
@@ -9,7 +9,16 @@ pub(crate) struct DevicesState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) sync_group: adw::PreferencesGroup,
|
||||
/// The single "This computer" summary row. The per-folder rows moved to the
|
||||
/// Locations page, which owns every local path.
|
||||
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>>,
|
||||
}
|
||||
@@ -18,13 +27,18 @@ pub(crate) struct DevicesState {
|
||||
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.
|
||||
/// "This computer" — this machine's device identity and a pointer to its
|
||||
/// folders on the Locations page.
|
||||
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,
|
||||
}
|
||||
@@ -42,29 +56,38 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
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();
|
||||
add_folder.add_css_class("flat");
|
||||
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(&add_folder);
|
||||
header.append(&restore);
|
||||
|
||||
// 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.
|
||||
// This page is about device *identity* — which computer this is, which other
|
||||
// computers back up to the account, and how to adopt one. The folders
|
||||
// themselves are local paths, so they live on the Locations page; keeping a
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
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.",
|
||||
)
|
||||
.description("The device this machine backs up to.")
|
||||
.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.")
|
||||
@@ -113,23 +136,30 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, 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: >k4::Button, add_folder: >k4::Button) {
|
||||
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::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.
|
||||
@@ -179,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Daemon reachable: show the list and paint the synced-folders section.
|
||||
// Daemon reachable: show the list and paint the "This computer" section.
|
||||
ui.devices.content.set_visible_child_name("list");
|
||||
repaint_sync_folders(&ui, &folders);
|
||||
repaint_this_computer(&ui, &folders);
|
||||
|
||||
let devices = match devices_reply {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
@@ -194,35 +224,6 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -264,6 +265,22 @@ 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()
|
||||
@@ -294,6 +311,21 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
.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();
|
||||
@@ -322,137 +354,53 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the "This computer" section from a fresh synced-folders listing.
|
||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||
/// backs up to, and how many folders it carries.
|
||||
///
|
||||
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
|
||||
/// page. They are local paths, and Locations is the one page that owns those;
|
||||
/// two lists of the same folders would be two places to flip the same switch.
|
||||
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||
ui.devices.sync_group.remove(&row);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("This computer")
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_go = ui.clone();
|
||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||
row.add_suffix(&manage);
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
}
|
||||
|
||||
/// How many folders this machine backs up, and whether any of them needs
|
||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
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(>k4::Image::from_icon_name("folder-symbolic"));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
return "No folders backed up yet — add one under Locations.".to_string();
|
||||
}
|
||||
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(>k4::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());
|
||||
let count = match folders.len() {
|
||||
1 => "1 folder backed up".to_string(),
|
||||
n => format!("{n} folders backed up"),
|
||||
};
|
||||
let attention = folders
|
||||
.iter()
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
0 => format!("{count} · manage them under Locations"),
|
||||
1 => format!("{count} · 1 needs attention"),
|
||||
n => format!("{count} · {n} need attention"),
|
||||
}
|
||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column.
|
||||
@@ -516,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
// 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
|
||||
// `refresh_locations` tick pick the row up whenever it
|
||||
// actually lands, which is what keeps a running pass live too.
|
||||
toast(&ui, "Syncing folder…");
|
||||
}
|
||||
@@ -533,6 +481,110 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -698,6 +750,46 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
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>,
|
||||
@@ -722,3 +814,47 @@ pub(crate) fn run_devices_mutation(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn folder(state: &str) -> SyncFolderInfo {
|
||||
SyncFolderInfo {
|
||||
id: 1,
|
||||
local_path: "/home/u/Documents".into(),
|
||||
remote_uid: "vol~link".into(),
|
||||
mode: "mirror".into(),
|
||||
state: state.into(),
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[]),
|
||||
"No folders backed up yet — add one under Locations."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_needing_attention_are_counted_not_hidden() {
|
||||
// The per-folder rows moved to Locations, so this row is the only place
|
||||
// the Computers page can surface that something is wrong.
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||
"2 folders backed up · manage them under Locations"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||
"2 folders backed up · 1 needs attention"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("conflict"), folder("error")]),
|
||||
"2 folders backed up · 2 need attention"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! The Locations page: every local place Proton Drive occupies on this machine.
|
||||
//!
|
||||
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
||||
//!
|
||||
//! The device rows drive the same control requests the Computers page used to:
|
||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||
//! listed alongside them, with the mountpoint chooser that used to live in
|
||||
//! Settings.
|
||||
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct LocationsState {
|
||||
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 Locations page's load/repaint touch.
|
||||
pub(crate) struct LocationsWidgets {
|
||||
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,
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
}
|
||||
|
||||
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Locations")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
|
||||
let add_folder = gtk4::Button::builder()
|
||||
.label("Add Folder")
|
||||
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_folder);
|
||||
// Clamped to the same width as the rows below, or the page title floats off
|
||||
// to the left of the list it names.
|
||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||
|
||||
// Same warning the Computers page carried, for the same reason: the
|
||||
// on-demand switch removes the local copy, which is not a thing to discover
|
||||
// after the fact.
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("On this computer")
|
||||
.description(
|
||||
"Where Proton Drive lives on this machine. 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();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
// Never scroll sideways: a location's title is a full path, and letting the
|
||||
// row grow to fit one pushes the controls at its end off screen. Constrained,
|
||||
// the path ellipsizes and the switch/remove/open stay reachable.
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.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("drive-harddisk-symbolic")
|
||||
.title("Loading…")
|
||||
.child(&retry)
|
||||
.build();
|
||||
// A bare `StatusPage` asks for more width than a half-screen window has, and
|
||||
// a `Stack` is as wide as its widest child — so an unwrapped status page
|
||||
// silently widens the *list* page too, pushing each row's controls off the
|
||||
// right edge. Scrolling it (never horizontally) caps that demand.
|
||||
let status_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&status)
|
||||
.build();
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status_scroll, Some("status"));
|
||||
|
||||
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
page.set_margin_top(18);
|
||||
page.set_margin_bottom(18);
|
||||
page.set_margin_start(18);
|
||||
page.set_margin_end(18);
|
||||
page.append(&header_clamp);
|
||||
page.append(&content);
|
||||
|
||||
let widgets = LocationsWidgets {
|
||||
content: content.clone(),
|
||||
status: status.clone(),
|
||||
group: group.clone(),
|
||||
retry: retry.clone(),
|
||||
refresh,
|
||||
add_folder,
|
||||
};
|
||||
(page.upcast(), widgets)
|
||||
}
|
||||
|
||||
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
ui_retry.locations.loaded_at.set(None);
|
||||
load_locations(&ui_retry);
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
}
|
||||
|
||||
/// Show a status page in place of the locations list.
|
||||
pub(crate) fn locations_status(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
title: &str,
|
||||
description: &str,
|
||||
retry: bool,
|
||||
) {
|
||||
ui.locations.status.set_icon_name(Some(icon));
|
||||
ui.locations.status.set_title(title);
|
||||
ui.locations.status.set_description(Some(description));
|
||||
ui.locations.retry.set_visible(retry);
|
||||
ui.locations.content.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Fetch every local location and repaint the list.
|
||||
pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
if ui.locations.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.inflight.set(true);
|
||||
locations_status(
|
||||
ui,
|
||||
"drive-harddisk-symbolic",
|
||||
"Loading…",
|
||||
"Reading this computer's Proton Drive locations.",
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.locations.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::Locations { items })) => {
|
||||
ui.locations.content.set_visible_child_name("list");
|
||||
repaint_locations(&ui, &items);
|
||||
ui.locations.loaded_at.set(Some(Instant::now()));
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
locations_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Unavailable",
|
||||
&message,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
ui.locations.loaded_at.set(None);
|
||||
locations_unreachable(&ui);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
|
||||
/// a sync pass's progress is only live if something re-reads it.
|
||||
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
|
||||
if ui.locations.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.locations.inflight.set(false);
|
||||
let Ok(Ok(Response::Locations { 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("locations")
|
||||
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
|
||||
{
|
||||
repaint_locations(&ui, &items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The daemon didn't answer the Locations page.
|
||||
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
||||
if service::is_failed() || !service::is_active() {
|
||||
locations_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
locations_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("locations") {
|
||||
load_locations(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||
for row in ui.locations.rows.borrow_mut().drain(..) {
|
||||
ui.locations.group.remove(&row);
|
||||
}
|
||||
if items.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No locations")
|
||||
.subtitle("The mount service hasn't reported a mountpoint yet.")
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||
ui.locations.group.add(&row);
|
||||
*ui.locations.rows.borrow_mut() = vec![row.upcast()];
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for spec in items {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&spec.local_path)
|
||||
.subtitle(location_subtitle(spec))
|
||||
// One line each: a wrapped path would reflow the whole list every
|
||||
// time a sync state changed under it.
|
||||
.title_lines(1)
|
||||
.subtitle_lines(1)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(location_icon(&spec.kind)));
|
||||
|
||||
// A read-only location cannot be written through even where the files are
|
||||
// visible; saying so on the row is cheaper than letting the user find out
|
||||
// from a save dialog.
|
||||
if spec.access == MountAccess::Ro {
|
||||
let badge = gtk4::Label::new(Some("Read-only"));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
|
||||
// Same rule as the Computers page: a first pass has no estimate to draw
|
||||
// against, so the bar appears only once real counts exist.
|
||||
if let Some(p) = &spec.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);
|
||||
}
|
||||
|
||||
match &spec.kind {
|
||||
MountKind::MyFiles => add_my_files_controls(ui, &row),
|
||||
MountKind::Device { sync_folder_id } => {
|
||||
add_device_controls(ui, &row, spec, *sync_folder_id)
|
||||
}
|
||||
// A standalone shared mount has no local mode to switch and is not
|
||||
// this device's to remove.
|
||||
MountKind::Shared { .. } => {}
|
||||
}
|
||||
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("folder-open-symbolic")
|
||||
.tooltip_text("Open this folder")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let path = spec.local_path.clone();
|
||||
open.connect_clicked(move |_| open_path(&path));
|
||||
row.add_suffix(&open);
|
||||
|
||||
ui.locations.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*ui.locations.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||
/// and offers a service restart, which is why it stays a single prompt shared
|
||||
/// with the (now removed) Settings row.
|
||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||
let change = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
change.add_css_class("flat");
|
||||
let ui_mp = ui.clone();
|
||||
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
row.add_suffix(&change);
|
||||
}
|
||||
|
||||
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
||||
/// controls, and the same handlers, the Computers page used to carry.
|
||||
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||
if spec.mode != MountMode::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 |_| sync_folder_now(&ui_sync, id));
|
||||
row.add_suffix(&sync_now);
|
||||
}
|
||||
|
||||
// A queued switch paints as already flipped: the daemon accepted it and will
|
||||
// act on it, so snapping back would read as "it didn't take". The state is
|
||||
// set before the handler is wired so painting the current mode doesn't fire
|
||||
// a spurious request.
|
||||
let target = spec.pending_mode.unwrap_or(spec.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 == MountMode::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 = spec.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 is_ondemand = spec.mode == MountMode::OnDemand;
|
||||
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
|
||||
row.add_suffix(&remove);
|
||||
}
|
||||
|
||||
/// Ask the daemon for an immediate pass over one folder.
|
||||
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't sync", &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
||||
match kind {
|
||||
MountKind::MyFiles => "folder-remote-symbolic",
|
||||
MountKind::Device { .. } => "folder-symbolic",
|
||||
MountKind::Shared { .. } => "system-users-symbolic",
|
||||
}
|
||||
}
|
||||
|
||||
/// One line describing what a location *is* and what it is doing.
|
||||
///
|
||||
/// Ordered by how likely it is to be what the user came to check: a queued mode
|
||||
/// switch leads (they just asked for it), then the resting mode, then the sync
|
||||
/// state, then — only when it is surprising — the fact that no session owns the
|
||||
/// path. A mirror folder is a plain directory with no FUSE session, so "not
|
||||
/// mounted" is its normal state and saying it would be noise.
|
||||
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
match &spec.kind {
|
||||
MountKind::MyFiles => parts.push("My files".to_string()),
|
||||
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
|
||||
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
|
||||
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
|
||||
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
|
||||
parts.push("On-demand".to_string())
|
||||
}
|
||||
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()),
|
||||
},
|
||||
}
|
||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
||||
parts.push(match &spec.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None => sync_state_label(&spec.state).to_string(),
|
||||
});
|
||||
}
|
||||
// Only the locations that are supposed to have a session report its absence:
|
||||
// a mirror folder never has one.
|
||||
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
||||
|| spec.mode == MountMode::OnDemand
|
||||
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||
if expects_session && !spec.mounted {
|
||||
parts.push("not mounted".to_string());
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
|
||||
MountSpec {
|
||||
id: 1,
|
||||
kind,
|
||||
local_path: "/home/u/ProtonDrive".into(),
|
||||
root_uid: "vol~link".into(),
|
||||
root_share_id: "share".into(),
|
||||
mode,
|
||||
access: MountAccess::Rw,
|
||||
state: "idle".into(),
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
mounted,
|
||||
progress: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
||||
// It is a plain local directory: no FUSE session is expected, so saying
|
||||
// "not mounted" would describe every healthy mirror folder as broken.
|
||||
let spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
false,
|
||||
);
|
||||
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_on_demand_folder_without_a_session_says_so() {
|
||||
let spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::OnDemand,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
location_subtitle(&spec),
|
||||
"On-demand · up to date · not mounted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_switch_leads_the_subtitle() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
true,
|
||||
);
|
||||
spec.pending_mode = Some(MountMode::OnDemand);
|
||||
assert!(location_subtitle(&spec).starts_with("Going on-demand"));
|
||||
spec.mode = MountMode::OnDemand;
|
||||
spec.pending_mode = Some(MountMode::Mirror);
|
||||
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
|
||||
}
|
||||
|
||||
/// An unrecognised persisted mode must not silently read as "Synced" *and*
|
||||
/// must not lose the rest of the line.
|
||||
#[test]
|
||||
fn the_primary_mount_reports_only_its_own_facts() {
|
||||
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
|
||||
assert_eq!(location_subtitle(&spec), "My files");
|
||||
spec.mounted = false;
|
||||
assert_eq!(location_subtitle(&spec), "My files · not mounted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_reaches_the_subtitle() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 2 },
|
||||
MountMode::Mirror,
|
||||
true,
|
||||
);
|
||||
spec.state = "conflict".into();
|
||||
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -104,18 +105,35 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
|
||||
ui.login.login_button.set_sensitive(false);
|
||||
ui.login.login_status.set_text("Signing in…");
|
||||
let (rx, totp_req_rx) = spawn_login(username, password);
|
||||
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 {
|
||||
if let Ok(code_tx) = totp_req_rx.recv().await {
|
||||
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
|
||||
@@ -155,9 +173,13 @@ pub(crate) fn spawn_login(
|
||||
) -> (
|
||||
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);
|
||||
let (totp_req_tx, totp_req_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()
|
||||
@@ -170,24 +192,42 @@ pub(crate) fn spawn_login(
|
||||
}
|
||||
};
|
||||
let result = rt.block_on(async move {
|
||||
auth::login(&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()))
|
||||
})
|
||||
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)
|
||||
(rx, totp_req_rx, hv_req_rx)
|
||||
}
|
||||
|
||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
@@ -8,3 +10,4 @@ pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod trash;
|
||||
pub(crate) mod verify;
|
||||
@@ -19,6 +19,11 @@ pub(crate) struct Viewer {
|
||||
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)>>,
|
||||
/// The favourite toggle in the top bar, and whether it is being set from
|
||||
/// the model rather than by the user — the same suppression the details pane
|
||||
/// uses for its pin switch, so painting a photo doesn't fire a round-trip.
|
||||
pub(crate) favorite: gtk4::ToggleButton,
|
||||
pub(crate) favorite_suppress: Cell<bool>,
|
||||
/// 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.
|
||||
@@ -41,6 +46,38 @@ pub(crate) struct ExifInfo {
|
||||
pub(crate) coords: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// Paint the favourite toggle without firing its handler.
|
||||
pub(crate) fn show_favorite(viewer: &Rc<Viewer>, favorite: bool) {
|
||||
viewer.favorite_suppress.set(true);
|
||||
viewer.favorite.set_active(favorite);
|
||||
set_favorite_icon(&viewer.favorite, favorite);
|
||||
viewer.favorite_suppress.set(false);
|
||||
}
|
||||
|
||||
/// Filled star for a favourite, outline for the rest.
|
||||
fn set_favorite_icon(button: >k4::ToggleButton, favorite: bool) {
|
||||
button.set_icon_name(if favorite {
|
||||
"starred-symbolic"
|
||||
} else {
|
||||
"non-starred-symbolic"
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a favourite change back into the loaded gallery page, so returning to
|
||||
/// the grid (or reopening the photo) shows what was just set without a reload.
|
||||
fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
|
||||
let Some(idx) = find_photo_index(&ui.gallery.model, uid) else {
|
||||
return;
|
||||
};
|
||||
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let mut item = boxed.borrow::<PhotoItem>().clone();
|
||||
item.favorite = favorite;
|
||||
ui.gallery.model.remove(idx);
|
||||
ui.gallery.model.insert(idx, &BoxedAnyObject::new(item));
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -52,6 +89,14 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
*viewer.path.borrow_mut() = None;
|
||||
clear_info(viewer);
|
||||
|
||||
// The favourite state comes from the timeline page the gallery already has,
|
||||
// so the button is right on the first frame instead of after a round-trip.
|
||||
let favorite = find_photo_index(&ui.gallery.model, &uid)
|
||||
.and_then(|idx| ui.gallery.model.item(idx))
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
|
||||
show_favorite(viewer, favorite);
|
||||
|
||||
// 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) {
|
||||
@@ -450,6 +495,14 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_toggle.add_css_class("flat");
|
||||
info_toggle.add_css_class("viewer-action-btn");
|
||||
|
||||
let favorite_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("non-starred-symbolic")
|
||||
.tooltip_text("Favourite")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
favorite_btn.add_css_class("flat");
|
||||
favorite_btn.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)");
|
||||
@@ -460,6 +513,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
top_bar.set_valign(gtk4::Align::Start);
|
||||
top_bar.set_hexpand(true);
|
||||
top_bar.append(&titles);
|
||||
top_bar.append(&favorite_btn);
|
||||
top_bar.append(&info_toggle);
|
||||
top_bar.append(&download_btn);
|
||||
top_bar.append(&open_ext_btn);
|
||||
@@ -558,6 +612,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_revealer: info_revealer.clone(),
|
||||
info_rows,
|
||||
info_map: info_map.clone(),
|
||||
favorite: favorite_btn.clone(),
|
||||
favorite_suppress: Cell::new(false),
|
||||
coords: RefCell::new(None),
|
||||
uid: RefCell::new(initial_uid.clone()),
|
||||
path: RefCell::new(None),
|
||||
@@ -601,6 +657,44 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let ui_fav = ui.clone();
|
||||
let viewer_fav = viewer.clone();
|
||||
favorite_btn.connect_toggled(move |btn| {
|
||||
if viewer_fav.favorite_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let favorite = btn.is_active();
|
||||
set_favorite_icon(btn, favorite);
|
||||
let uid = viewer_fav.uid.borrow().clone();
|
||||
let rx = spawn_request(
|
||||
ui_fav.dirs.control_socket(),
|
||||
Request::SetPhotoFavorite {
|
||||
uid: uid.clone(),
|
||||
favorite,
|
||||
},
|
||||
);
|
||||
let ui_result = ui_fav.clone();
|
||||
let viewer_result = viewer_fav.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
set_gallery_favorite(&ui_result, &uid, favorite);
|
||||
None
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => Some(message),
|
||||
_ => Some("The mount service didn't respond.".to_string()),
|
||||
};
|
||||
if let Some(detail) = failed {
|
||||
toast_error(&ui_result, "Couldn't change the favourite", &detail);
|
||||
// The server refused, so the button must go back to describing
|
||||
// what is actually stored — without firing this handler again.
|
||||
if *viewer_result.uid.borrow() == uid {
|
||||
show_favorite(&viewer_result, !favorite);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
download_btn.connect_clicked(move |_| {
|
||||
|
||||
@@ -8,24 +8,53 @@ pub(crate) struct GalleryState {
|
||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||
/// [`Self::model`] by [`repaint_gallery`].
|
||||
pub(crate) groups: gio::ListStore,
|
||||
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are
|
||||
/// justified to the content width around this height.
|
||||
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
||||
/// as the content width holds.
|
||||
pub(crate) tile: Cell<i32>,
|
||||
/// Swaps the Photos content area between the timeline and its status page.
|
||||
/// Swaps the Photos content area between the timeline, its status page, and
|
||||
/// the Albums grid.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) more: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
/// "Photos", or the album's name while one is open.
|
||||
pub(crate) title: gtk4::Label,
|
||||
/// "1,204 photos" under the page title.
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
/// The album grid, and the stack that swaps it for its own status page.
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
/// The Photos/Albums switcher, and the box holding it — hidden while an
|
||||
/// album is open, where the back button leads instead.
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||
/// view can't stack requests.
|
||||
pub(crate) albums_loading: Cell<bool>,
|
||||
/// The album currently open, or `None` when the timeline is showing. Set by
|
||||
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
||||
/// of the timeline while it is set.
|
||||
pub(crate) album: RefCell<Option<AlbumInfo>>,
|
||||
/// Leaves an open album for the grid it was opened from. Visible only while
|
||||
/// an album is open.
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and the date jump, hidden while an album is open — an
|
||||
/// album page is served whole, not filtered.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
||||
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
||||
pub(crate) kind: Cell<Option<PhotoKind>>,
|
||||
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
||||
/// labels can carry live per-kind counts.
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
|
||||
/// carried them, so the subtitle can say how big the library *is* rather
|
||||
/// than how much of it has been paged in.
|
||||
pub(crate) counts: Cell<Option<(usize, usize, usize)>>,
|
||||
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
|
||||
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
|
||||
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
||||
@@ -34,27 +63,24 @@ pub(crate) struct GalleryState {
|
||||
/// The capture-time window the timeline is currently filtered to, or `None`
|
||||
/// for the whole span. Read by [`load_gallery`], set by the date dropdown.
|
||||
pub(crate) range: Cell<Option<(i64, i64)>>,
|
||||
/// The favourites toggle, and whether it is on. When on, the timeline is
|
||||
/// restricted to photos carrying Proton's `Favorite` tag.
|
||||
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||
pub(crate) favorites: Cell<bool>,
|
||||
/// Set while the date dropdown is being repopulated, so resetting its model
|
||||
/// doesn't fire the selection handler and kick off a spurious reload.
|
||||
pub(crate) date_suppress: Cell<bool>,
|
||||
/// True while a timeline page is in flight, so the scroll-to-the-end paging
|
||||
/// can't fire a second request for the page already coming.
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Content width the sections are currently justified to. Updated when the
|
||||
/// ListView is resized, which re-justifies the visible sections.
|
||||
/// Content width the grid is currently laid out to. Updated when the
|
||||
/// ListView is resized, which re-flows the visible sections.
|
||||
pub(crate) width: Cell<i32>,
|
||||
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
||||
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
||||
/// repaints from memory instead of re-decoding from disk.
|
||||
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
||||
/// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and
|
||||
/// persisted to disk, so a relaunch justifies its rows correctly on the first
|
||||
/// frame instead of reflowing as thumbnails arrive.
|
||||
pub(crate) photo_ratio: RefCell<HashMap<String, f64>>,
|
||||
/// Ratios learned since the last save, so the ratio file is only rewritten
|
||||
/// when it actually changed.
|
||||
pub(crate) photo_ratio_dirty: Cell<bool>,
|
||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||
/// can never be filled isn't requested again on every scroll past it.
|
||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||
@@ -75,46 +101,33 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
||||
pub(crate) decode_idle: Cell<bool>,
|
||||
/// Pending debounce timers for the thumbnail queue flush and the section
|
||||
/// re-justify, replaced on each new trigger so only the last one fires.
|
||||
/// re-flow, replaced on each new trigger so only the last one fires.
|
||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||
/// The day sections currently realised by the ListView, by their index in
|
||||
/// [`Self::groups`]. A learned ratio or a resize re-justifies these
|
||||
/// in place — rebuilding the ListStore instead would reset the scroll
|
||||
/// position out from under the user.
|
||||
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
||||
/// rebuilding the ListStore instead would reset the scroll position out from
|
||||
/// under the user.
|
||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
||||
}
|
||||
|
||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
||||
|
||||
/// Gallery row height in px: the zoom range, its default, and the step one
|
||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full
|
||||
/// content width, so this is the *target* height a row lands near, not an exact
|
||||
/// tile size (see [`justify_rows`]).
|
||||
/// Gallery tile edge in px: the zoom range, its default, and the step one
|
||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
|
||||
/// evenly, so this is the *target* a tile lands near rather than its exact size
|
||||
/// (see [`plan_grid`]).
|
||||
pub(crate) const TILE_MIN: i32 = 90;
|
||||
|
||||
pub(crate) const TILE_MAX: i32 = 340;
|
||||
|
||||
pub(crate) const TILE_DEFAULT: i32 = 220;
|
||||
pub(crate) const TILE_DEFAULT: i32 = 180;
|
||||
|
||||
pub(crate) const TILE_STEP: i32 = 30;
|
||||
|
||||
/// Gap between tiles, horizontally and vertically.
|
||||
pub(crate) const TILE_GAP: i32 = 6;
|
||||
|
||||
/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded
|
||||
/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the
|
||||
/// commonest camera/phone ratio; once the thumbnail lands the real ratio
|
||||
/// replaces it and the section re-justifies in place.
|
||||
pub(crate) const RATIO_UNKNOWN: f64 = 1.5;
|
||||
|
||||
/// Aspect ratios are clamped to this range before a row is packed: one absurd
|
||||
/// panorama, or a sliver of a portrait, must not be able to squash the row it
|
||||
/// lands in down to nothing. The tile still shows the whole image.
|
||||
pub(crate) const RATIO_MIN: f64 = 0.4;
|
||||
|
||||
pub(crate) const RATIO_MAX: f64 = 3.5;
|
||||
pub(crate) const TILE_GAP: i32 = 8;
|
||||
|
||||
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
||||
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
||||
@@ -134,7 +147,7 @@ pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
||||
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
||||
|
||||
/// Pause after a resize/zoom before the visible sections are re-justified.
|
||||
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
||||
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
|
||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||
@@ -153,9 +166,11 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) model: gio::ListStore,
|
||||
/// Day sections rendered by the ListView, derived from `model`.
|
||||
pub(crate) groups: gio::ListStore,
|
||||
/// Swaps between the timeline and the empty/loading/error status page.
|
||||
/// Swaps between the timeline, the empty/loading/error status page, and the
|
||||
/// Albums grid.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) title: gtk4::Label,
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) more: gtk4::Button,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
@@ -163,9 +178,21 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) upload: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
/// The Albums grid, its own status page and the stack between them, plus the
|
||||
/// Photos/Albums switcher and the back button out of an album.
|
||||
pub(crate) albums: gtk4::FlowBox,
|
||||
pub(crate) albums_stack: gtk4::Stack,
|
||||
pub(crate) albums_status: adw::StatusPage,
|
||||
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||
pub(crate) view_switch: gtk4::Box,
|
||||
pub(crate) back: gtk4::Button,
|
||||
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||
pub(crate) filters: gtk4::Box,
|
||||
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||
/// to [`kind_for_tab`]).
|
||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||
pub(crate) favorites_btn: gtk4::ToggleButton,
|
||||
/// The date-jump dropdown, populated with the timeline's months.
|
||||
pub(crate) dates: gtk4::DropDown,
|
||||
}
|
||||
@@ -217,7 +244,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
more.add_css_class("pill");
|
||||
more.set_visible(false);
|
||||
|
||||
// Horizontal scrolling is never wanted: rows are justified to the viewport
|
||||
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
||||
// width, and a stray hscrollbar would fight the layout.
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
@@ -226,7 +253,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
.build();
|
||||
|
||||
let title_label = gtk4::Label::builder()
|
||||
.label("Photos")
|
||||
.label("Gallery")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title_label.add_css_class("title-2");
|
||||
@@ -243,6 +270,17 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
titles.append(&title_label);
|
||||
titles.append(&subtitle);
|
||||
|
||||
// Leaves an open album for the grid it came from. Only an open album shows
|
||||
// it — the Albums toggle is what leaves the grid itself.
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back to albums")
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
back.add_css_class("flat");
|
||||
back.add_css_class("circular");
|
||||
|
||||
let upload = gtk4::Button::builder()
|
||||
.label("Upload")
|
||||
.icon_name("list-add-symbolic")
|
||||
@@ -253,6 +291,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header_box.append(&back);
|
||||
header_box.append(&titles);
|
||||
header_box.append(&refresh);
|
||||
header_box.append(&upload);
|
||||
@@ -280,6 +319,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
tab_group.append(btn);
|
||||
}
|
||||
|
||||
// Favourites: a filter, not a tab — it cuts across Photos / Videos / Raw, so
|
||||
// it stays outside the segmented control rather than becoming a fifth option
|
||||
// that would silently drop the kind the user picked.
|
||||
let favorites_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("starred-symbolic")
|
||||
.tooltip_text("Show only favourites")
|
||||
.build();
|
||||
favorites_btn.add_css_class("pill");
|
||||
|
||||
// Date jump: "All dates" plus a row per month, filled in once the timeline's
|
||||
// months are known (see [`refresh_photo_months`]). Pushed to the far end of
|
||||
// the filter row, opposite the kind toggles.
|
||||
@@ -287,23 +335,74 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
dates.add_css_class("pill");
|
||||
dates.set_tooltip_text(Some("Jump to a month"));
|
||||
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&tab_group);
|
||||
// The kind toggles and the date jump travel together: they filter the
|
||||
// timeline, and neither applies to the album grid or to an open album.
|
||||
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filters.set_hexpand(true);
|
||||
filters.append(&tab_group);
|
||||
filters.append(&favorites_btn);
|
||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
spacer.set_hexpand(true);
|
||||
filter_bar.append(&spacer);
|
||||
filter_bar.append(&dates);
|
||||
filters.append(&spacer);
|
||||
filters.append(&dates);
|
||||
|
||||
// The page's two views, as one segmented control under the title: the
|
||||
// timeline, and the albums. This is navigation, not a filter — which is why
|
||||
// it sits above the filter row rather than beside the kind toggles.
|
||||
let photos_btn = gtk4::ToggleButton::builder()
|
||||
.label("Photos")
|
||||
.active(true)
|
||||
.build();
|
||||
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
||||
albums_btn.set_group(Some(&photos_btn));
|
||||
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
view_switch.add_css_class("linked");
|
||||
view_switch.add_css_class("view-switch");
|
||||
view_switch.set_halign(gtk4::Align::Start);
|
||||
for btn in [&photos_btn, &albums_btn] {
|
||||
btn.add_css_class("pill");
|
||||
view_switch.append(btn);
|
||||
}
|
||||
|
||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
filter_bar.append(&filters);
|
||||
|
||||
// The timeline (plus its pager) or the status page, never both.
|
||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
timeline.append(&scroll);
|
||||
timeline.append(&more);
|
||||
|
||||
// The album grid: cover-first cards that flow to the width they are given.
|
||||
let albums = gtk4::FlowBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.homogeneous(true)
|
||||
.row_spacing(TILE_GAP as u32 * 2)
|
||||
.column_spacing(TILE_GAP as u32 * 2)
|
||||
.min_children_per_line(2)
|
||||
.max_children_per_line(8)
|
||||
.valign(gtk4::Align::Start)
|
||||
.build();
|
||||
let albums_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&albums)
|
||||
.build();
|
||||
let albums_status = adw::StatusPage::builder()
|
||||
.icon_name("view-grid-symbolic")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
albums_status.add_css_class("compact");
|
||||
let albums_stack = gtk4::Stack::new();
|
||||
albums_stack.set_vexpand(true);
|
||||
albums_stack.add_named(&albums_scroll, Some("grid"));
|
||||
albums_stack.add_named(&albums_status, Some("status"));
|
||||
|
||||
let content = gtk4::Stack::new();
|
||||
content.set_vexpand(true);
|
||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||
content.add_named(&timeline, Some("timeline"));
|
||||
content.add_named(&status, Some("status"));
|
||||
content.add_named(&albums_stack, Some("albums"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
@@ -311,6 +410,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header_box);
|
||||
inner.append(&view_switch);
|
||||
inner.append(&filter_bar);
|
||||
inner.append(&content);
|
||||
|
||||
@@ -321,6 +421,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
groups,
|
||||
content,
|
||||
status,
|
||||
title: title_label,
|
||||
subtitle,
|
||||
more,
|
||||
list,
|
||||
@@ -329,7 +430,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
upload,
|
||||
refresh,
|
||||
tabs,
|
||||
favorites_btn,
|
||||
dates,
|
||||
albums,
|
||||
albums_stack,
|
||||
albums_status,
|
||||
photos_btn,
|
||||
albums_btn,
|
||||
view_switch,
|
||||
back,
|
||||
filters,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -416,16 +526,31 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
||||
/// behind it is disabled — you can't filter to an empty set — but the currently
|
||||
/// selected one stays clickable so you can always switch back off it.
|
||||
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
||||
ui.gallery.counts.set(Some(counts));
|
||||
let (photos, videos, raw) = counts;
|
||||
let totals = [photos + videos + raw, photos, videos, raw];
|
||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||
let name = ["All", "Photos", "Videos", "Raw"][index];
|
||||
let n = totals[index];
|
||||
tab.set_label(&format!("{name} {n}"));
|
||||
tab.set_label(&format!("{name} {}", thousands(n)));
|
||||
tab.set_sensitive(n > 0 || tab.is_active());
|
||||
}
|
||||
}
|
||||
|
||||
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
|
||||
/// of digits in a tab label is unreadable at a glance.
|
||||
pub(crate) fn thousands(n: usize) -> String {
|
||||
let digits = n.to_string();
|
||||
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (i, c) in digits.chars().enumerate() {
|
||||
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
||||
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||
/// in the in-app lightbox.
|
||||
@@ -485,8 +610,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
// Rows are justified to the content width, so a resize re-justifies whatever
|
||||
// is on screen (offscreen sections pick the new width up when they bind).
|
||||
// The grid divides the content width, so a resize re-flows whatever is on
|
||||
// screen (offscreen sections pick the new width up when they bind).
|
||||
let ui_width = ui.clone();
|
||||
list.connect_notify_local(Some("width"), move |list, _| {
|
||||
let width = list.width();
|
||||
@@ -570,6 +695,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
}
|
||||
|
||||
// Favourites: reload the timeline restricted to favourites (or back to all).
|
||||
// Independent of the kind tabs and the date jump, both of which keep their
|
||||
// current value across the toggle.
|
||||
let ui_fav = ui.clone();
|
||||
ui.gallery.favorites_btn.connect_toggled(move |btn| {
|
||||
let on = btn.is_active();
|
||||
if ui_fav.gallery.favorites.get() == on {
|
||||
return;
|
||||
}
|
||||
ui_fav.gallery.favorites.set(on);
|
||||
load_gallery(&ui_fav, false);
|
||||
});
|
||||
|
||||
// Date jump: selecting a month loads that window; "All dates" (row 0) clears
|
||||
// it. Skipped while the model is being repopulated (see `gallery_date_suppress`).
|
||||
let ui_dates = ui.clone();
|
||||
@@ -673,114 +811,55 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
||||
});
|
||||
}
|
||||
|
||||
/// One tile in a justified row: the photo, and the pixel size it was justified to.
|
||||
/// One tile of the grid: the photo, and the square edge it was sized to.
|
||||
pub(crate) struct Tile {
|
||||
pub(crate) photo: PhotoItem,
|
||||
pub(crate) width: i32,
|
||||
pub(crate) height: i32,
|
||||
pub(crate) edge: i32,
|
||||
}
|
||||
|
||||
/// Break one day's photos into rows that each fill `width` exactly, at heights
|
||||
/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and
|
||||
/// the reason nothing here has to be cropped: each tile keeps its own aspect
|
||||
/// ratio, and it is the row *height* that flexes to make the widths add up.
|
||||
/// Break one day's photos into rows of equal square tiles that span `width` —
|
||||
/// the layout a phone gallery uses, and the reason a day holding two photos
|
||||
/// looks like every other day rather than like a mistake.
|
||||
///
|
||||
/// Greedy, one pass: keep adding photos to the row: the more photos share it, the
|
||||
/// shorter it has to be to fit. The moment that height drops to the target, the
|
||||
/// row is as full as it should be and is emitted. The trailing row keeps the
|
||||
/// target height instead of being stretched, so a day with three photos doesn't
|
||||
/// blow them up to fill a screen-wide row.
|
||||
///
|
||||
/// Photos whose thumbnail hasn't been decoded yet are laid out at
|
||||
/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands
|
||||
/// and the section is re-justified in place.
|
||||
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||
let ratios: Vec<f64> = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect();
|
||||
let target = f64::from(ui.gallery.tile.get());
|
||||
let plan = plan_rows(&ratios, target, f64::from(width));
|
||||
|
||||
let mut photos = photos.iter();
|
||||
plan.into_iter()
|
||||
/// Each tile is centre-cropped to its square (the full frame is one click away
|
||||
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
|
||||
/// and a row never has to be re-flowed when a thumbnail finally lands.
|
||||
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
|
||||
photos
|
||||
.chunks(columns)
|
||||
.map(|row| {
|
||||
row.into_iter()
|
||||
.filter_map(|(width, height)| {
|
||||
photos.next().map(|photo| Tile {
|
||||
photo: photo.clone(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
row.iter()
|
||||
.map(|photo| Tile {
|
||||
photo: photo.clone(),
|
||||
edge,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios:
|
||||
/// takes each photo's ratio (w/h) in order and returns the pixel `(width,
|
||||
/// height)` of every tile, grouped into rows.
|
||||
pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32, i32)>> {
|
||||
let avail = width.max(f64::from(TILE_MIN));
|
||||
let gap = f64::from(TILE_GAP);
|
||||
|
||||
// The height a row of `n` photos with `sum_ratio` total ratio must take for
|
||||
// its widths (plus gaps) to add up to exactly `avail`.
|
||||
let row_height = |sum_ratio: f64, n: usize| {
|
||||
let gaps = gap * (n.saturating_sub(1)) as f64;
|
||||
(avail - gaps) / sum_ratio
|
||||
};
|
||||
|
||||
let mut rows: Vec<Vec<(i32, i32)>> = Vec::new();
|
||||
let mut row: Vec<f64> = Vec::new();
|
||||
let mut sum_ratio = 0.0;
|
||||
|
||||
for ratio in ratios {
|
||||
let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX);
|
||||
row.push(ratio);
|
||||
sum_ratio += ratio;
|
||||
|
||||
let height = row_height(sum_ratio, row.len());
|
||||
if height <= target {
|
||||
rows.push(size_row(&row, height, avail, true));
|
||||
row.clear();
|
||||
sum_ratio = 0.0;
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
let height = row_height(sum_ratio, row.len()).min(target);
|
||||
rows.push(size_row(&row, height, avail, false));
|
||||
}
|
||||
rows
|
||||
/// The grid math: how many columns fit in `width` at roughly `target` px per
|
||||
/// tile, and the exact square edge that divides the width between them.
|
||||
///
|
||||
/// The column count is what rounds — the edge then absorbs the remainder, so the
|
||||
/// grid spans the full width at every window size instead of leaving a ragged
|
||||
/// margin. Never fewer than one column, however narrow the window gets.
|
||||
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
||||
let avail = width.max(TILE_MIN);
|
||||
let target = target.clamp(TILE_MIN, TILE_MAX);
|
||||
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
|
||||
// fit while n*(target + gap) - gap <= avail.
|
||||
let columns = ((avail + TILE_GAP) / (target + TILE_GAP)).max(1) as usize;
|
||||
let gaps = TILE_GAP * (columns as i32 - 1);
|
||||
let edge = ((avail - gaps) / columns as i32).max(1);
|
||||
(columns, edge)
|
||||
}
|
||||
|
||||
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
|
||||
/// plus gaps hit `avail` exactly — rounding each width independently leaves a
|
||||
/// few px of ragged right edge, so the last tile absorbs the remainder.
|
||||
pub(crate) fn size_row(
|
||||
ratios: &[f64],
|
||||
height: f64,
|
||||
avail: f64,
|
||||
justified: bool,
|
||||
) -> Vec<(i32, i32)> {
|
||||
let height = height.round().max(1.0);
|
||||
let mut sizes: Vec<(i32, i32)> = ratios
|
||||
.iter()
|
||||
.map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32))
|
||||
.collect();
|
||||
|
||||
if justified {
|
||||
let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32;
|
||||
let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum();
|
||||
if let Some(last) = sizes.last_mut() {
|
||||
last.0 = (avail as i32 - gaps - used).max(1);
|
||||
}
|
||||
}
|
||||
sizes
|
||||
}
|
||||
|
||||
/// (Re)build a bound day-section's tiles: justify this day's photos to the
|
||||
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
|
||||
/// current content width and hand each tile whatever thumbnail is already in
|
||||
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
|
||||
/// heading — so a re-justify never touches the ListView's model or scroll.
|
||||
/// heading — so a re-flow never touches the ListView's model or scroll.
|
||||
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
||||
let Some(heading) = section.first_child() else {
|
||||
return;
|
||||
@@ -791,8 +870,11 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
||||
|
||||
let width = gallery_width(ui);
|
||||
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
||||
for row in justify_rows(ui, photos, width) {
|
||||
for row in grid_rows(ui, photos, width) {
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||
// A short last row stays left-aligned at the same tile size rather than
|
||||
// stretching: an even grid is the whole point of the square layout.
|
||||
row_box.set_halign(gtk4::Align::Start);
|
||||
for tile in row {
|
||||
row_box.append(&photo_tile(ui, tile));
|
||||
}
|
||||
@@ -802,8 +884,8 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
||||
schedule_thumbs(ui);
|
||||
}
|
||||
|
||||
/// The width justified rows are laid out to: the ListView's own width, less a
|
||||
/// couple of px so a rounding error can't push a row into a horizontal overflow.
|
||||
/// The width the grid is laid out to: the ListView's own width, less a couple of
|
||||
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||
/// Falls back to a sane guess before the first allocation.
|
||||
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
match ui.gallery.width.get() {
|
||||
@@ -822,12 +904,17 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||
/// one keeps an image glyph instead of an empty rectangle.
|
||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
|
||||
// it and crops nothing; it only bites during the brief window where the
|
||||
// ratio is still a guess, and the re-justify then fixes the tile.
|
||||
// Cover fills the square and crops the overflow, so a portrait photo
|
||||
// sits flush in its tile instead of floating in letterbox bars. The
|
||||
// expands are what make the picture take the whole overlay: without
|
||||
// them it is allocated its natural size and the crop never happens.
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
// Zoomed on hover by the stylesheet; the tile clips the overflow.
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
@@ -871,8 +958,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||
|
||||
let button = gtk4::Button::builder()
|
||||
.child(&overlay)
|
||||
.width_request(tile.width)
|
||||
.height_request(tile.height)
|
||||
.width_request(tile.edge)
|
||||
.height_request(tile.edge)
|
||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||
.build();
|
||||
button.add_css_class("photo-tile");
|
||||
@@ -1052,7 +1139,6 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
||||
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
||||
(0..4).filter_map(|_| queue.pop_front()).collect()
|
||||
};
|
||||
let mut relayout = false;
|
||||
for (uid, path) in batch {
|
||||
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Ok(texture) => texture,
|
||||
@@ -1062,28 +1148,23 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// A ratio we hadn't seen means the tile was sized against a guess.
|
||||
relayout |= ui.store_texture(&uid, texture.clone());
|
||||
ui.store_texture(&uid, texture.clone());
|
||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||
picture.set_paintable(Some(&texture));
|
||||
}
|
||||
}
|
||||
if relayout {
|
||||
schedule_relayout(&ui);
|
||||
}
|
||||
|
||||
if ui.gallery.decode_queue.borrow().is_empty() {
|
||||
ui.gallery.decode_idle.set(false);
|
||||
ui.save_ratios();
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-justify the sections on screen shortly. Debounced, because the triggers
|
||||
/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in
|
||||
/// floods and only the final state matters.
|
||||
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
|
||||
/// window resize, a zoom step) arrive in floods and only the final state
|
||||
/// matters.
|
||||
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
||||
id.remove();
|
||||
@@ -1097,9 +1178,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
||||
/// width, zoom and set of known aspect ratios. Sections that are *not* realised
|
||||
/// need no work: they justify themselves against the current state when the
|
||||
/// ListView binds them.
|
||||
/// width and zoom. Sections that are *not* realised need no work: they lay
|
||||
/// themselves out against the current state when the ListView binds them.
|
||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||
.gallery
|
||||
@@ -1120,12 +1200,12 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the row height by `delta` px and re-justify, clamped to the zoom range.
|
||||
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
|
||||
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
||||
}
|
||||
|
||||
/// Set the row height (clamped) and re-justify the visible sections at it.
|
||||
/// Set the tile size (clamped) and re-flow the visible sections at it.
|
||||
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
||||
if tile == ui.gallery.tile.get() {
|
||||
@@ -1175,17 +1255,35 @@ pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
||||
}
|
||||
|
||||
let count = ui.gallery.model.n_items();
|
||||
ui.gallery.subtitle.set_visible(count > 0);
|
||||
let loaded = ui.gallery.model.n_items() as usize;
|
||||
ui.gallery.subtitle.set_visible(loaded > 0);
|
||||
// An album counts what the server says it holds, not how much of it has been
|
||||
// paged in — the subtitle would otherwise climb as the user scrolls.
|
||||
if ui.gallery.album.borrow().is_some() {
|
||||
return;
|
||||
}
|
||||
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
||||
let (one, many) = match ui.gallery.kind.get() {
|
||||
let kind = ui.gallery.kind.get();
|
||||
let (one, many) = match kind {
|
||||
Some(PhotoKind::Video) => ("video", "videos"),
|
||||
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
||||
_ => ("photo", "photos"),
|
||||
};
|
||||
ui.gallery.subtitle.set_label(&match count {
|
||||
// The whole library for this filter, not the page count — the subtitle sits
|
||||
// next to tabs carrying the same totals, and the two disagreeing reads as a
|
||||
// bug. A date jump is the exception: there the window is the subject.
|
||||
let total = match (ui.gallery.range.get(), ui.gallery.counts.get()) {
|
||||
(None, Some((photos, videos, raw))) => match kind {
|
||||
Some(PhotoKind::Photo) => photos,
|
||||
Some(PhotoKind::Video) => videos,
|
||||
Some(PhotoKind::Raw) => raw,
|
||||
None => photos + videos + raw,
|
||||
},
|
||||
_ => loaded,
|
||||
};
|
||||
ui.gallery.subtitle.set_label(&match total {
|
||||
1 => format!("1 {one}"),
|
||||
n => format!("{n} {many}"),
|
||||
n => format!("{} {many}", thousands(n)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1272,6 +1370,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
if ui.gallery.loading.get() {
|
||||
return;
|
||||
}
|
||||
// An open album pages itself instead of the timeline; everything downstream —
|
||||
// the model, the sections, the thumbnails, the lightbox — is the same.
|
||||
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
||||
if !append {
|
||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||
ui.gallery.model.remove_all();
|
||||
@@ -1284,8 +1385,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
);
|
||||
// Rebuild the date jump for the current kind, but only for a full-span
|
||||
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||
// dropdown then would fight the selection the user just made.
|
||||
if ui.gallery.range.get().is_none() {
|
||||
// dropdown then would fight the selection the user just made. An album
|
||||
// has no date jump at all.
|
||||
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||
refresh_photo_months(ui);
|
||||
}
|
||||
}
|
||||
@@ -1294,15 +1396,21 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
ui.gallery.more.set_sensitive(false);
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::PhotosTimeline {
|
||||
let request = match album {
|
||||
Some(uid) => Request::AlbumPhotos {
|
||||
uid,
|
||||
offset,
|
||||
limit: PHOTOS_PAGE,
|
||||
},
|
||||
None => Request::PhotosTimeline {
|
||||
offset,
|
||||
limit: PHOTOS_PAGE,
|
||||
kind: ui.gallery.kind.get(),
|
||||
range: ui.gallery.range.get(),
|
||||
favorites: ui.gallery.favorites.get(),
|
||||
},
|
||||
);
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
@@ -1329,20 +1437,13 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
if let Some(counts) = counts {
|
||||
update_gallery_tabs(&ui, counts);
|
||||
}
|
||||
// Take the daemon's word on ratios and thumbnail verdicts before
|
||||
// anything is laid out: it persists both, so the very first frame
|
||||
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
|
||||
// and reflowing as the images land.
|
||||
// Take the daemon's word on which photos can never have a
|
||||
// thumbnail, so their tiles show a placeholder from the first
|
||||
// frame instead of queueing a request that can only fail.
|
||||
{
|
||||
let mut ratios = ui.gallery.photo_ratio.borrow_mut();
|
||||
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
||||
for item in &items {
|
||||
if let Some(ratio) = item.ratio {
|
||||
ratios.insert(item.uid.clone(), ratio);
|
||||
}
|
||||
if item.no_thumb {
|
||||
nothumb.insert(item.uid.clone());
|
||||
}
|
||||
for item in items.iter().filter(|item| item.no_thumb) {
|
||||
nothumb.insert(item.uid.clone());
|
||||
}
|
||||
}
|
||||
for item in &items {
|
||||
@@ -1350,13 +1451,15 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
}
|
||||
repaint_gallery(&ui);
|
||||
if ui.gallery.model.n_items() == 0 {
|
||||
gallery_status(
|
||||
&ui,
|
||||
"image-x-generic-symbolic",
|
||||
"No photos yet",
|
||||
"Photos you upload to Proton Drive appear here.",
|
||||
false,
|
||||
);
|
||||
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
||||
("Empty album", "This album has no photos in it.")
|
||||
} else {
|
||||
(
|
||||
"No photos yet",
|
||||
"Photos you upload to Proton Drive appear here.",
|
||||
)
|
||||
};
|
||||
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
||||
return;
|
||||
}
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
|
||||
@@ -10,6 +10,10 @@ pub(crate) struct SharedState {
|
||||
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>,
|
||||
@@ -139,10 +143,19 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
|
||||
|
||||
/// 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,
|
||||
@@ -192,6 +205,210 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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(>k4::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();
|
||||
let subtitle = shared_row_subtitle(entry);
|
||||
if !subtitle.is_empty() {
|
||||
row.set_subtitle(&subtitle);
|
||||
}
|
||||
// What I am allowed to do with someone else's file is the thing this page
|
||||
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||
// that is only visible if it is said.
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
row.add_prefix(>k4::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
|
||||
}
|
||||
|
||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
||||
/// pointing at a directory that is not there.
|
||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !entry.is_dir {
|
||||
parts.push(human_bytes(entry.size));
|
||||
}
|
||||
if !entry.path.is_empty() {
|
||||
parts.push(format!("in {}", entry.path));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
/// The display name for a wire role, or `None` when there is nothing to show:
|
||||
/// owned content carries no role, and a role the API did not report is not
|
||||
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
|
||||
/// unrecognised mask rather than degrading it to viewer).
|
||||
pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
||||
match role {
|
||||
"viewer" => Some("Viewer"),
|
||||
"editor" => Some("Editor"),
|
||||
"admin" => Some("Admin"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dim role pill for a share I did not create.
|
||||
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
||||
let badge = gtk4::Label::new(Some(role_label(role)?));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
Some(badge)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let name = name.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.opening.borrow_mut().remove(&uid);
|
||||
match result {
|
||||
// A cache blob is named by content hash, so the open rules key off
|
||||
// the shared node's name instead.
|
||||
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||
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>) {
|
||||
@@ -233,6 +450,9 @@ pub(crate) fn repaint_shared(
|
||||
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.
|
||||
@@ -242,12 +462,7 @@ pub(crate) fn repaint_shared(
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in shared {
|
||||
let row = adw::ActionRow::builder().title(&entry.name).build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let row = shared_entry_row(ui, entry);
|
||||
let leave = gtk4::Button::builder()
|
||||
.label("Leave")
|
||||
.valign(gtk4::Align::Center)
|
||||
@@ -495,3 +710,52 @@ pub(crate) fn run_shared_mutation(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry {
|
||||
DirEntry {
|
||||
name: "Budget.ods".into(),
|
||||
is_dir,
|
||||
size: 2048,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
path: path.into(),
|
||||
role: role.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||
// Before the synthetic directory has been listed, the daemon cannot
|
||||
// resolve a path — pointing at one that isn't there would be worse than
|
||||
// silence.
|
||||
assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), "");
|
||||
assert_eq!(
|
||||
shared_row_subtitle(&entry(false, "", "viewer")),
|
||||
human_bytes(2048)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resident_share_reports_where_it_can_be_reached() {
|
||||
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
|
||||
assert_eq!(subtitle, "in Shared with me/Team Budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_known_role_becomes_a_badge() {
|
||||
// "" is owned content (no role applies) and an unrecognised mask is
|
||||
// deliberately not guessed at — `from_permissions_exact` returns None
|
||||
// rather than degrading to viewer, and this must not undo that.
|
||||
for role in ["viewer", "editor", "admin"] {
|
||||
assert!(role_label(role).is_some(), "{role} should have a label");
|
||||
}
|
||||
assert!(role_label("").is_none());
|
||||
assert!(role_label("inherited").is_none());
|
||||
}
|
||||
}
|
||||
@@ -236,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
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);
|
||||
}
|
||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||
// pathless node by uid, so every shared item can be managed from here.
|
||||
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());
|
||||
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||
role: String::new(),
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,22 @@ pub(crate) struct StatusState {
|
||||
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.
|
||||
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||
/// Locations page, which owns every local path.
|
||||
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.
|
||||
@@ -78,6 +86,10 @@ pub(crate) struct MainWidgets {
|
||||
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,
|
||||
@@ -88,7 +100,8 @@ pub(crate) struct MainWidgets {
|
||||
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.
|
||||
/// Shows the active mountpoint; its suffix button opens the Locations page,
|
||||
/// which is where the folder is changed.
|
||||
pub(crate) mountpoint_row: adw::ActionRow,
|
||||
pub(crate) mountpoint_button: gtk4::Button,
|
||||
}
|
||||
@@ -111,6 +124,29 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
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("a_bar);
|
||||
quota_box.append("a_label);
|
||||
let quota_row = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child("a_box)
|
||||
.build();
|
||||
quota_group.add("a_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();
|
||||
@@ -180,12 +216,17 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||
.build();
|
||||
system_group.add(&autostart_row);
|
||||
// The mountpoint is a *location*, and every other local path this client
|
||||
// owns is managed on the Locations page. Keeping a second chooser here would
|
||||
// be a second source of truth for the same setting, so this row reports the
|
||||
// path and hands the change over.
|
||||
let mountpoint_row = adw::ActionRow::builder()
|
||||
.title("Mountpoint")
|
||||
.subtitle("—")
|
||||
.build();
|
||||
let mountpoint_button = gtk4::Button::builder()
|
||||
.label("Change")
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
mountpoint_button.add_css_class("flat");
|
||||
@@ -219,6 +260,7 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&account_group);
|
||||
inner.append("a_group);
|
||||
inner.append(&mount_group);
|
||||
inner.append(&transfers_group);
|
||||
inner.append(&storage_group);
|
||||
@@ -238,6 +280,9 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
||||
account_row,
|
||||
mount_row,
|
||||
transfers_group,
|
||||
quota_group,
|
||||
quota_bar,
|
||||
quota_label,
|
||||
cache_bar,
|
||||
cache_label,
|
||||
pins_group,
|
||||
@@ -333,10 +378,10 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
|
||||
// the change takes effect (the daemon reads the path on mount).
|
||||
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||
// local path; this button is the way there.
|
||||
let ui_mp = ui.clone();
|
||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||
}
|
||||
|
||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||
@@ -384,6 +429,12 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||
// The Locations page shows the same path as a row title; its cached rows
|
||||
// are now stale whether or not it is the visible page.
|
||||
ui.locations.loaded_at.set(None);
|
||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||
load_locations(&ui);
|
||||
}
|
||||
|
||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||
let confirm = adw::AlertDialog::builder()
|
||||
@@ -463,12 +514,65 @@ pub(crate) fn refresh(ui: &Rc<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("devices") => refresh_sync_folders(ui),
|
||||
Some("main") => refresh_quota(ui),
|
||||
Some("locations") => refresh_locations(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.
|
||||
///
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub(crate) struct DetailsWidgets {
|
||||
pub(crate) pin_row: adw::SwitchRow,
|
||||
pub(crate) open_button: gtk4::Button,
|
||||
pub(crate) rename_button: gtk4::Button,
|
||||
/// Files only — folders have no revision history.
|
||||
pub(crate) versions_button: gtk4::Button,
|
||||
pub(crate) trash_button: gtk4::Button,
|
||||
pub(crate) close_button: gtk4::Button,
|
||||
}
|
||||
@@ -96,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
open_button.add_css_class("pill");
|
||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||
rename_button.add_css_class("pill");
|
||||
let versions_button = gtk4::Button::builder().label("Versions").build();
|
||||
versions_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");
|
||||
@@ -104,6 +108,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
actions.set_margin_top(6);
|
||||
actions.append(&open_button);
|
||||
actions.append(&rename_button);
|
||||
actions.append(&versions_button);
|
||||
actions.append(&trash_button);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -137,6 +142,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
pin_row,
|
||||
open_button,
|
||||
rename_button,
|
||||
versions_button,
|
||||
trash_button,
|
||||
close_button,
|
||||
},
|
||||
@@ -190,6 +196,16 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
prompt_rename(&ui_rename, &entry);
|
||||
}
|
||||
});
|
||||
let ui_versions = ui.clone();
|
||||
ui.details
|
||||
.details
|
||||
.versions_button
|
||||
.connect_clicked(move |_| {
|
||||
let entry = ui_versions.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
open_versions_dialog(&ui_versions, &entry);
|
||||
}
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||
@@ -246,6 +262,9 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
// Only files have revisions, and only a connected daemon can fetch them.
|
||||
d.versions_button.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(*ui.mounted.borrow());
|
||||
ui.details.details_suppress.set(false);
|
||||
|
||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -21,13 +21,117 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||
/// own inode space, or on someone else's volume, neither of which the primary
|
||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
||||
///
|
||||
/// The two are separate request variants rather than one request with an
|
||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||
/// the empty path to the *mount root* and act on the wrong node.
|
||||
pub(crate) enum ShareTarget {
|
||||
Path(String),
|
||||
Uid(String),
|
||||
}
|
||||
|
||||
impl ShareTarget {
|
||||
fn list(&self) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::ListShare { path: path.clone() },
|
||||
ShareTarget::Uid(uid) => Request::ListShareByUid { uid: uid.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn invite(&self, emails: Vec<String>, role: String, message: Option<String>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::ShareNode {
|
||||
path: path.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::ShareNodeByUid {
|
||||
uid: uid.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update_role(&self, id: String, kind: ShareEntryKind, role: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::UpdateShareRole {
|
||||
path: path.clone(),
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::UpdateShareRoleByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
kind,
|
||||
role,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_entry(&self, id: String, kind: ShareEntryKind) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemoveShareEntry {
|
||||
path: path.clone(),
|
||||
id,
|
||||
kind,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::RemoveShareEntryByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
kind,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_link(&self, role: String, password: Option<String>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||
path: path.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_link(&self, id: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||
path: path.clone(),
|
||||
id,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::RemovePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// How every request from this dialog addresses the node.
|
||||
pub(crate) target: ShareTarget,
|
||||
pub(crate) people: adw::PreferencesGroup,
|
||||
pub(crate) link_group: adw::PreferencesGroup,
|
||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
||||
@@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||
return;
|
||||
}
|
||||
let rel = entry_rel(ui, entry);
|
||||
// A node with no path is not a bug here: Shared and Shared-with-me list
|
||||
// nodes that the primary mount never interned. Address those by uid.
|
||||
let target = if entry.path.is_empty() {
|
||||
ShareTarget::Uid(entry.uid.clone())
|
||||
} else {
|
||||
ShareTarget::Path(entry_rel(ui, entry))
|
||||
};
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
@@ -110,7 +220,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
let state = Rc::new(ShareDialog {
|
||||
ui: ui.clone(),
|
||||
rel,
|
||||
target,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
@@ -144,12 +254,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let msg_clear = message_row.clone();
|
||||
share_dialog_op(
|
||||
&state_inv,
|
||||
Request::ShareNode {
|
||||
path: state_inv.rel.clone(),
|
||||
emails,
|
||||
role,
|
||||
message,
|
||||
},
|
||||
state_inv.target.invite(emails, role, message),
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
Some(Box::new(move || {
|
||||
@@ -165,12 +270,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
/// 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 rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
@@ -232,12 +332,11 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
drop.connect_selected_notify(move |d| {
|
||||
share_dialog_op(
|
||||
&state_role,
|
||||
Request::UpdateShareRole {
|
||||
path: state_role.rel.clone(),
|
||||
id: id.clone(),
|
||||
state_role.target.update_role(
|
||||
id.clone(),
|
||||
kind,
|
||||
role: role_index_to_wire(d.selected()).to_string(),
|
||||
},
|
||||
role_index_to_wire(d.selected()).to_string(),
|
||||
),
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
None,
|
||||
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemoveShareEntry {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
kind,
|
||||
},
|
||||
state_rm.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
None,
|
||||
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
Request::RemovePublicLink {
|
||||
path: state_rm.rel.clone(),
|
||||
id: id.clone(),
|
||||
},
|
||||
state_rm.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
None,
|
||||
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
|
||||
) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
Request::CreatePublicLink {
|
||||
path: state.rel.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
},
|
||||
state.target.create_link(role, password),
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! The per-file **Versions** dialog: what Proton Drive still holds of a file's
|
||||
//! earlier content, and the three things a user can do with one.
|
||||
//!
|
||||
//! Every row is one revision the server kept. Restoring re-points the file at an
|
||||
//! old revision (server-side, so nothing is uploaded and the mount may take a
|
||||
//! moment to catch up); saving writes an old revision out to a local file
|
||||
//! without touching the live one; deleting is permanent, and the active revision
|
||||
//! cannot be deleted at all — that row simply has no delete button.
|
||||
|
||||
use crate::*;
|
||||
|
||||
use pdfs_core::control::RevisionInfo;
|
||||
|
||||
/// How an open Versions dialog addresses its file — the same split as
|
||||
/// [`ShareTarget`](super::share_dialog::ShareTarget), for the same reason: a
|
||||
/// node reached from Shared or a device folder may have no path in the primary
|
||||
/// mount's namespace, and an older daemon must reject the request rather than
|
||||
/// resolve an empty path to the mount root.
|
||||
pub(crate) enum VersionTarget {
|
||||
Path(String),
|
||||
Uid(String),
|
||||
}
|
||||
|
||||
impl VersionTarget {
|
||||
fn list(&self) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::ListRevisions { path: path.clone() },
|
||||
VersionTarget::Uid(uid) => Request::ListRevisionsByUid { uid: uid.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&self, revision_id: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::RestoreRevision {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::RestoreRevisionByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, revision_id: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::DeleteRevision {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::DeleteRevisionByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn save_as(&self, revision_id: String, dest: String) -> Request {
|
||||
match self {
|
||||
VersionTarget::Path(path) => Request::SaveRevisionAs {
|
||||
path: path.clone(),
|
||||
revision_id,
|
||||
dest,
|
||||
},
|
||||
VersionTarget::Uid(uid) => Request::SaveRevisionAsByUid {
|
||||
uid: uid.clone(),
|
||||
revision_id,
|
||||
dest,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The state behind an open Versions dialog, so an action can rebuild the list
|
||||
/// in place instead of tearing the dialog down.
|
||||
pub(crate) struct VersionsDialog {
|
||||
pub(crate) ui: Rc<Ui>,
|
||||
pub(crate) target: VersionTarget,
|
||||
/// The file's name, for the save dialog's suggested filename and for toasts.
|
||||
pub(crate) name: String,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
}
|
||||
|
||||
/// Open the Versions dialog for a file.
|
||||
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
|
||||
return;
|
||||
}
|
||||
if entry.is_dir {
|
||||
toast_error(ui, "No versions", "Folders don't have versions.");
|
||||
return;
|
||||
}
|
||||
let target = if entry.path.is_empty() {
|
||||
VersionTarget::Uid(entry.uid.clone())
|
||||
} else {
|
||||
VersionTarget::Path(entry_rel(ui, entry))
|
||||
};
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Version history")
|
||||
.description("Proton Drive keeps earlier versions of a file until you delete them.")
|
||||
.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(&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("Versions")
|
||||
.content_width(520)
|
||||
.content_height(520)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
|
||||
let state = Rc::new(VersionsDialog {
|
||||
ui: ui.clone(),
|
||||
target,
|
||||
name: entry.name.clone(),
|
||||
group,
|
||||
rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
versions_dialog_reload(&state);
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Re-fetch the file's revisions and rebuild the rows.
|
||||
pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't load versions", &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't load versions",
|
||||
"The mount service didn't respond.",
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Rebuild the version rows from a fresh listing.
|
||||
fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
for row in state.rows.borrow_mut().drain(..) {
|
||||
state.group.remove(&row);
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
if items.is_empty() {
|
||||
let row = dim_row("No earlier versions.");
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.rows.borrow_mut() = rows;
|
||||
return;
|
||||
}
|
||||
|
||||
for item in items {
|
||||
// The uploader's claimed plaintext size is the one that matches what the
|
||||
// user sees in a listing; the storage figure counts ciphertext.
|
||||
let size = item
|
||||
.claimed_size
|
||||
.filter(|size| *size >= 0)
|
||||
.unwrap_or(item.size_on_storage)
|
||||
.max(0) as u64;
|
||||
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
||||
if let Some(email) = &item.signed_by {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(email);
|
||||
}
|
||||
let title = if item.is_active {
|
||||
"Current version".to_string()
|
||||
} else {
|
||||
format!("Version {}", item.id)
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
|
||||
let save = gtk4::Button::builder()
|
||||
.icon_name("document-save-symbolic")
|
||||
.tooltip_text("Save a copy…")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
save.add_css_class("flat");
|
||||
let state_save = state.clone();
|
||||
let id = item.id.clone();
|
||||
save.connect_clicked(move |_| prompt_save_version(&state_save, &id));
|
||||
row.add_suffix(&save);
|
||||
|
||||
if !item.is_active {
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore this version")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let state_restore = state.clone();
|
||||
let id = item.id.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_version(&state_restore, &id));
|
||||
row.add_suffix(&restore);
|
||||
|
||||
let delete = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete this version permanently")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
delete.add_css_class("flat");
|
||||
let state_delete = state.clone();
|
||||
let id = item.id.clone();
|
||||
delete.connect_clicked(move |_| prompt_delete_version(&state_delete, &id));
|
||||
row.add_suffix(&delete);
|
||||
}
|
||||
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
*state.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Confirm, then restore. Confirmed because it replaces the file's content for
|
||||
/// every device on the account, not only this one.
|
||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore version")
|
||||
.body(format!(
|
||||
"Make this version the current content of “{}”? The version it replaces stays in the history.",
|
||||
state.name
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let window = ui_window(&state.ui);
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "restore" {
|
||||
return;
|
||||
}
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.restore(revision_id.clone()),
|
||||
// The server applies the swap in the background, so the wording
|
||||
// promises the request, not the result.
|
||||
"Restoring that version",
|
||||
"Couldn't restore that version",
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete one revision.
|
||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete version")
|
||||
.body("Delete this version permanently? Its content can't be recovered.")
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete");
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let window = ui_window(&state.ui);
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "delete" {
|
||||
return;
|
||||
}
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.delete(revision_id.clone()),
|
||||
"Version deleted",
|
||||
"Couldn't delete that version",
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
}
|
||||
|
||||
/// Ask where to write an old version, then have the daemon download it there.
|
||||
///
|
||||
/// The daemon writes the file, so the chosen location has to be one it can see:
|
||||
/// a path from the portal-free `FileDialog` is a real filesystem path, which it
|
||||
/// can.
|
||||
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let window = ui_window(&state.ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save version")
|
||||
.initial_name(&state.name)
|
||||
.build();
|
||||
let state = state.clone();
|
||||
let revision_id = revision_id.to_string();
|
||||
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
let Ok(file) = res else { return };
|
||||
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||
toast_error(
|
||||
&state.ui,
|
||||
"Couldn't save that version",
|
||||
"That location isn't a local file.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.save_as(revision_id.clone(), dest),
|
||||
"Version saved",
|
||||
"Couldn't save that version",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Send one version request, toast the outcome, and refresh the list.
|
||||
fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str, failed: &str) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||
let state = state.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&state.ui, &done);
|
||||
versions_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
|
||||
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
||||
//!
|
||||
//! Everything here is blocking and GTK-free — the process exists for one
|
||||
//! round-trip and then exits, so there is no main loop to keep off. It reuses
|
||||
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
|
||||
//! activation policy, so the two front ends cannot drift on what "open" means.
|
||||
//!
|
||||
//! Launchers filter a static list; they cannot call back for a new one per
|
||||
//! keystroke. So the interaction is a loop: the first menu shows pinned files
|
||||
//! and accepts free text, and any text that matches no entry becomes the next
|
||||
//! search. Escape (an empty selection) ends it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||
};
|
||||
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||
|
||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||
/// without it a scripted (non-interactive) menu could spin forever.
|
||||
const MAX_ROUNDS: usize = 64;
|
||||
|
||||
pub(crate) struct Options {
|
||||
/// Launcher argv from `--menu`, overriding the configured/detected one.
|
||||
pub menu: Option<Vec<String>>,
|
||||
/// Skip the pinned-files round and search for this immediately.
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the launcher flow. Returns an error only for conditions the user must
|
||||
/// act on (no daemon, no launcher); a plain cancel is `Ok`.
|
||||
pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||
let config = dirs.load_config();
|
||||
let prompt: PromptConfig = config.resolved_prompt();
|
||||
let policy: OpenWith = config.resolved_open_with();
|
||||
let socket = dirs.control_socket();
|
||||
|
||||
let menu_argv =
|
||||
menu::resolve_menu(options.menu.as_ref().or(prompt.menu.as_ref())).ok_or_else(|| {
|
||||
"no launcher found — install fuzzel/rofi/wofi, or set prompt.menu in config.json"
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
let mountpoint = match request(&socket, Request::Status)? {
|
||||
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
|
||||
_ => dirs.default_mountpoint(),
|
||||
};
|
||||
|
||||
let mut query = options
|
||||
.query
|
||||
.map(|q| q.trim().to_string())
|
||||
.filter(|q| !q.is_empty());
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let hits = match &query {
|
||||
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
|
||||
None => pins(&socket)?,
|
||||
};
|
||||
let items: Vec<MenuItem> = if hits.is_empty() {
|
||||
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
||||
} else {
|
||||
label_all(&hits)
|
||||
};
|
||||
|
||||
match menu::run(&menu_argv, &title(query.as_deref()), &items).map_err(|e| e.to_string())? {
|
||||
MenuChoice::Item(index) => {
|
||||
let Some(hit) = hits.get(index) else {
|
||||
// The placeholder row. Selecting it is not a cancel — the
|
||||
// user pressed Enter on "type to search", so start over
|
||||
// with the pinned list rather than quitting on them.
|
||||
query = None;
|
||||
continue;
|
||||
};
|
||||
return open(&socket, &mountpoint, &policy, hit, query.is_none());
|
||||
}
|
||||
// No entry matched what was typed: treat it as the next query.
|
||||
MenuChoice::Custom(text) => query = Some(text),
|
||||
MenuChoice::Cancelled => return Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The launcher's prompt string.
|
||||
///
|
||||
/// A launcher renders this immediately left of the input, so it is the only
|
||||
/// place to say what typing here does. "Drive" alone read as a label and left
|
||||
/// the two-step nature of the flow — type, Enter, *then* results — invisible;
|
||||
/// the verb and the chevron make it a search box, and echoing the current query
|
||||
/// shows which round you are in.
|
||||
fn title(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("Drive: {text} › "),
|
||||
None => "Search Drive › ".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for the one row shown when there is nothing to list.
|
||||
///
|
||||
/// A launcher handed an empty list shows a bare prompt, and some refuse to
|
||||
/// return anything at all from an empty menu — which would strand the user on
|
||||
/// the very first round, since a fresh account has no pins. A single row keeps
|
||||
/// the menu non-empty and says why it is empty.
|
||||
///
|
||||
/// A fuzzy launcher can match this row against what the user types, and the
|
||||
/// selection comes back as a label with no way to tell the two apart — so the
|
||||
/// wording is parenthesised and avoids words that read like a filename query.
|
||||
fn placeholder(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("(no matches for “{text}”)"),
|
||||
None => "(no pinned files — type to search)".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
||||
/// with, and the only listing the daemon offers without a query.
|
||||
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(pins
|
||||
.into_iter()
|
||||
.map(|pin| {
|
||||
Hit::Drive(SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
||||
let reply = request(
|
||||
socket,
|
||||
Request::SearchV2 {
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
filters: SearchFilters {
|
||||
sources: vec![SearchSource::Drive, SearchSource::Local],
|
||||
kind: SearchKind::All,
|
||||
},
|
||||
},
|
||||
)?;
|
||||
let Response::SearchResultsV2 {
|
||||
drive_hits,
|
||||
local_hits,
|
||||
..
|
||||
} = reply
|
||||
else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
||||
hits.extend(local_hits.into_iter().map(Hit::Local));
|
||||
rank_hits(&mut hits);
|
||||
hits.truncate(limit);
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
/// One launcher line per hit. Labels must be unique: they are how the selection
|
||||
/// is matched back, and two files of the same name in the same folder listing
|
||||
/// would otherwise be indistinguishable.
|
||||
fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
|
||||
let mut items: Vec<MenuItem> = Vec::with_capacity(hits.len());
|
||||
for hit in hits {
|
||||
let mut label = format!("{} · {}", hit.name(), hit.location());
|
||||
let mut suffix = 2;
|
||||
while items.iter().any(|item| item.label == label) {
|
||||
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
||||
suffix += 1;
|
||||
}
|
||||
items.push(MenuItem::new(label, Some(icon_name(hit))));
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Icon theme name for a hit.
|
||||
///
|
||||
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
||||
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
||||
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
||||
/// names below exist in every icon theme.
|
||||
fn icon_name(hit: &Hit) -> String {
|
||||
if hit.is_dir() {
|
||||
return "folder".to_string();
|
||||
}
|
||||
let name = hit.name();
|
||||
if is_image(name) {
|
||||
"image-x-generic"
|
||||
} else if is_media(name) {
|
||||
"video-x-generic"
|
||||
} else if is_document(name) {
|
||||
"text-x-generic"
|
||||
} else {
|
||||
"application-x-generic"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
||||
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
||||
/// else is materialised by the daemon first.
|
||||
fn open(
|
||||
socket: &Path,
|
||||
mountpoint: &Path,
|
||||
policy: &OpenWith,
|
||||
hit: &Hit,
|
||||
from_pins: bool,
|
||||
) -> Result<(), String> {
|
||||
match hit {
|
||||
Hit::Local(local) => {
|
||||
opener::open(policy, Path::new(&local.path), local.is_dir);
|
||||
Ok(())
|
||||
}
|
||||
Hit::Drive(drive) => {
|
||||
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
||||
// policy rather than node kind, so both go to the mount unchecked.
|
||||
let unconditional = from_pins
|
||||
|| matches!(
|
||||
drive_activation(&drive.name, drive.is_dir),
|
||||
DriveActivation::Folder | DriveActivation::MountedMedia
|
||||
);
|
||||
if unconditional {
|
||||
opener::open(
|
||||
policy,
|
||||
&mounted_or_relative(mountpoint, drive),
|
||||
drive.is_dir,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
// Anything else the mount exposes opens there too, so an editor
|
||||
// saves back into Drive instead of into a cache blob.
|
||||
if let Some(path) = mounted_target(mountpoint, drive) {
|
||||
opener::open(policy, &path, false);
|
||||
return Ok(());
|
||||
}
|
||||
match request(
|
||||
socket,
|
||||
Request::OpenFile {
|
||||
path: drive.path.clone(),
|
||||
uid: Some(drive.uid.clone()),
|
||||
},
|
||||
)? {
|
||||
Response::FilePath { path } => {
|
||||
// The reply is a content-hash blob; the rules key off the
|
||||
// Drive name, which is what the user actually opened.
|
||||
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
||||
Ok(())
|
||||
}
|
||||
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
||||
_ => Err("unexpected reply from the daemon".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
||||
/// terminal rather than a status page.
|
||||
fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::LocalHit;
|
||||
|
||||
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
||||
Hit::Drive(SearchHit {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
is_dir,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_unique_so_a_selection_maps_back_to_one_hit() {
|
||||
let hits = vec![
|
||||
drive("notes.md", "Docs/notes.md", false),
|
||||
Hit::Local(LocalHit {
|
||||
name: "notes.md".into(),
|
||||
path: "/home/me/Docs/notes.md".into(),
|
||||
is_dir: false,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
score: 0,
|
||||
}),
|
||||
drive("notes.md", "Docs/notes.md", false),
|
||||
];
|
||||
let items = label_all(&hits);
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_ne!(items[0].label, items[2].label);
|
||||
let unique: std::collections::HashSet<_> =
|
||||
items.iter().map(|item| item.label.clone()).collect();
|
||||
assert_eq!(unique.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prompt_says_a_search_is_what_enter_does() {
|
||||
let first = title(None);
|
||||
assert!(first.to_lowercase().contains("search"), "{first}");
|
||||
// The launcher renders the input immediately after the prompt, so the
|
||||
// prompt has to carry its own separator or it runs into what is typed.
|
||||
assert!(first.ends_with(' '), "{first:?}");
|
||||
|
||||
let refined = title(Some("md"));
|
||||
assert!(refined.contains("md"), "{refined}");
|
||||
assert!(refined.ends_with(' '), "{refined:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_round_still_has_a_row_explaining_itself() {
|
||||
assert!(placeholder(None).contains("type to search"));
|
||||
assert!(placeholder(Some("md")).contains("md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_are_theme_names_the_launcher_can_look_up() {
|
||||
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
|
||||
assert_eq!(
|
||||
icon_name(&drive("a.png", "a.png", false)),
|
||||
"image-x-generic"
|
||||
);
|
||||
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
|
||||
assert_eq!(
|
||||
icon_name(&drive("a.bin", "a.bin", false)),
|
||||
"application-x-generic"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,9 +134,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
}
|
||||
|
||||
fn open_folder(mountpoint: &Path) {
|
||||
if let Err(e) = Command::new("xdg-open").arg(mountpoint).spawn() {
|
||||
tracing::error!("failed to xdg-open {}: {e}", mountpoint.display());
|
||||
}
|
||||
pdfs_core::opener::open_default(mountpoint, true);
|
||||
}
|
||||
|
||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! `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};
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -17,7 +16,15 @@ 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,
|
||||
};
|
||||
use pdfs_core::menu::PromptMode;
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
mod activation;
|
||||
mod dmenu;
|
||||
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||
|
||||
@@ -60,6 +67,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 {
|
||||
@@ -130,29 +147,26 @@ impl Hit {
|
||||
}
|
||||
|
||||
/// The dimmed second line: the containing folder, as the user thinks of it.
|
||||
///
|
||||
/// A Drive hit is only "My files" when it really sits in the primary mount.
|
||||
/// A node inside a device folder resolves to a path on this machine
|
||||
/// (`~/Downloads/…`) — the daemon has already worked that out in
|
||||
/// `mounted_path`, and saying "My files / Downloads" for it names a folder
|
||||
/// the user cannot find and does not match where activating the row opens.
|
||||
fn location(&self) -> String {
|
||||
match self {
|
||||
Hit::Drive(h) => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
format!("My files / {}", parent.replace('/', " / "))
|
||||
Hit::Drive(h) => match h.mounted_path.as_deref() {
|
||||
Some(mounted) => home_relative(&parent_of(mounted)),
|
||||
None => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
format!("My files / {}", parent.replace('/', " / "))
|
||||
}
|
||||
}
|
||||
}
|
||||
Hit::Local(h) => {
|
||||
let parent = parent_of(&h.path);
|
||||
match dirs_home().and_then(|home| {
|
||||
Path::new(&parent)
|
||||
.strip_prefix(&home)
|
||||
.ok()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
None => parent,
|
||||
}
|
||||
}
|
||||
},
|
||||
Hit::Local(h) => home_relative(&parent_of(&h.path)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +188,13 @@ impl Hit {
|
||||
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) {
|
||||
@@ -184,17 +205,31 @@ impl Hit {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// callbacks below capture a single `Rc` instead of a dozen clones each.
|
||||
struct Ui {
|
||||
socket: PathBuf,
|
||||
mountpoint: RefCell<PathBuf>,
|
||||
/// How a chosen file is launched. Read once at window build: the prompt is
|
||||
/// resident, but a config edit is rare enough that a restart is a fair
|
||||
/// price for not re-reading the file on every Enter.
|
||||
opener: OpenWith,
|
||||
|
||||
entry: gtk4::Entry,
|
||||
spinner: gtk4::Spinner,
|
||||
stack: gtk4::Stack,
|
||||
scroller: gtk4::ScrolledWindow,
|
||||
results: gtk4::Box,
|
||||
search_section: Section,
|
||||
drive_section: Section,
|
||||
local_section: Section,
|
||||
placeholder: adw::StatusPage,
|
||||
@@ -217,8 +252,7 @@ struct Ui {
|
||||
/// 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.
|
||||
@@ -229,6 +263,10 @@ struct Ui {
|
||||
/// 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>>,
|
||||
}
|
||||
@@ -293,6 +331,71 @@ impl Section {
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
pdfs-prompt — search Proton Drive and this computer
|
||||
|
||||
Usage: pdfs-prompt [OPTIONS]
|
||||
|
||||
Options:
|
||||
--dmenu Present the results in an external launcher (fuzzel,
|
||||
rofi, …) instead of the built-in GTK window. Also
|
||||
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
||||
in config.json.
|
||||
--gtk Force the built-in window, overriding that setting.
|
||||
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||
\"prompt\": { \"menu\": [...] }.
|
||||
--query <TEXT> Search for TEXT immediately instead of opening on the
|
||||
pinned-files list (--dmenu only).
|
||||
-h, --help Show this help.
|
||||
";
|
||||
|
||||
/// What the command line asked for, before any GTK setup happens: the launcher
|
||||
/// path must not pay for — or fail on — a display connection it never uses.
|
||||
struct Args {
|
||||
mode: Option<PromptMode>,
|
||||
menu: Option<Vec<String>>,
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||
let mut args = Args {
|
||||
mode: None,
|
||||
menu: None,
|
||||
query: None,
|
||||
};
|
||||
let mut argv = argv.peekable();
|
||||
while let Some(arg) = argv.next() {
|
||||
match arg.as_str() {
|
||||
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
||||
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
||||
// A launcher command is one shell-ish string so it can live in a
|
||||
// keybinding; splitting on whitespace is enough for flags, and a
|
||||
// launcher argument needing spaces belongs in config.json.
|
||||
"--menu" => {
|
||||
let value = argv
|
||||
.next()
|
||||
.ok_or_else(|| "--menu needs a command".to_string())?;
|
||||
let parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
|
||||
if parts.is_empty() {
|
||||
return Err("--menu needs a command".to_string());
|
||||
}
|
||||
args.menu = Some(parts);
|
||||
// A launcher was named explicitly; that only makes sense here.
|
||||
args.mode.get_or_insert(PromptMode::Dmenu);
|
||||
}
|
||||
"--query" => {
|
||||
args.query = Some(
|
||||
argv.next()
|
||||
.ok_or_else(|| "--query needs a search term".to_string())?,
|
||||
);
|
||||
}
|
||||
other => return Err(format!("unrecognised argument: {other}")),
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
@@ -300,18 +403,66 @@ fn main() -> glib::ExitCode {
|
||||
)
|
||||
.init();
|
||||
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
|
||||
print!("{USAGE}");
|
||||
return glib::ExitCode::SUCCESS;
|
||||
}
|
||||
let args = match parse_args(raw.into_iter()) {
|
||||
Ok(args) => args,
|
||||
Err(message) => {
|
||||
eprintln!("pdfs-prompt: {message}\n\n{USAGE}");
|
||||
return glib::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
// The stored mode makes an existing keybinding switch front ends without
|
||||
// being re-bound; an explicit flag still wins.
|
||||
let stored = AppDirs::new()
|
||||
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
||||
.unwrap_or_default();
|
||||
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
|
||||
return match dmenu::run(dmenu::Options {
|
||||
menu: args.menu,
|
||||
query: args.query,
|
||||
}) {
|
||||
Ok(()) => glib::ExitCode::SUCCESS,
|
||||
Err(message) => {
|
||||
eprintln!("pdfs-prompt: {message}");
|
||||
glib::ExitCode::FAILURE
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
app.run()
|
||||
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();
|
||||
});
|
||||
// GTK must not try to parse our own flags; they were consumed above.
|
||||
app.run_with_args::<&str>(&[])
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -362,8 +513,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);
|
||||
|
||||
@@ -425,11 +578,13 @@ fn build_window(app: &adw::Application) {
|
||||
let ui = Rc::new(Ui {
|
||||
socket: dirs.control_socket(),
|
||||
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
||||
opener: dirs.load_config().resolved_open_with(),
|
||||
entry: entry.clone(),
|
||||
spinner,
|
||||
stack,
|
||||
scroller,
|
||||
results,
|
||||
search_section,
|
||||
drive_section,
|
||||
local_section,
|
||||
placeholder,
|
||||
@@ -446,6 +601,7 @@ fn build_window(app: &adw::Application) {
|
||||
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());
|
||||
@@ -477,15 +633,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() {
|
||||
@@ -496,7 +654,7 @@ fn build_window(app: &adw::Application) {
|
||||
// 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() {
|
||||
window_key.close();
|
||||
ui_key.dismiss();
|
||||
} else {
|
||||
ui_key.entry.set_text("");
|
||||
}
|
||||
@@ -515,12 +673,10 @@ fn build_window(app: &adw::Application) {
|
||||
ui_key.cycle_filter(back);
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter => {
|
||||
// Fall back to the top row when nothing is explicitly selected
|
||||
// (e.g. results haven't rendered yet); `open` guards the rest.
|
||||
ui_key.open(ui_key.cursor.get().unwrap_or(0), &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,
|
||||
}
|
||||
});
|
||||
@@ -528,9 +684,47 @@ 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);
|
||||
@@ -550,22 +744,73 @@ 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();
|
||||
}
|
||||
|
||||
/// Hand a resolved path to the configured opener — `xdg-open` unless
|
||||
/// `open_with` in `config.json` says otherwise (a terminal editor for text
|
||||
/// files, say).
|
||||
fn open_path(&self, path: &Path, is_dir: bool) {
|
||||
opener::open(&self.opener, path, is_dir);
|
||||
}
|
||||
|
||||
/// Open a file the daemon just materialised into the content cache. The
|
||||
/// blob is named after its content hash, so the open rules have to be
|
||||
/// matched against the Drive name instead.
|
||||
fn open_materialized(&self, path: &Path, name: &str) {
|
||||
opener::open_named(&self.opener, path, name, false);
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
@@ -621,11 +866,13 @@ impl Ui {
|
||||
.map(|pin| SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.recursive,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
@@ -636,32 +883,41 @@ 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;
|
||||
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
|
||||
@@ -669,32 +925,6 @@ impl Ui {
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
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;
|
||||
ui.render();
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
||||
_ => {
|
||||
ui.local_hits.borrow_mut().clear();
|
||||
ui.render();
|
||||
}
|
||||
@@ -737,7 +967,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) {
|
||||
@@ -762,9 +997,8 @@ 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();
|
||||
|
||||
@@ -794,17 +1028,24 @@ impl Ui {
|
||||
|
||||
let query = self.entry.text().trim().to_string();
|
||||
let searching = !query.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 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));
|
||||
@@ -840,8 +1081,8 @@ impl Ui {
|
||||
// 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), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) {
|
||||
self.open(index, &window);
|
||||
if let Some(index) = self.cursor.get() {
|
||||
self.open(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,6 +1119,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 {
|
||||
(
|
||||
@@ -924,7 +1178,7 @@ 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;
|
||||
}
|
||||
@@ -942,16 +1196,40 @@ 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.open_path(Path::new(&local.path), local.is_dir);
|
||||
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 = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||
self.open_path(&path, drive.is_dir);
|
||||
self.dismiss();
|
||||
return;
|
||||
}
|
||||
match drive_activation(&drive.name, drive.is_dir) {
|
||||
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
||||
let path = mounted_or_relative(&self.mountpoint.borrow(), &drive);
|
||||
self.open_path(&path, drive.is_dir);
|
||||
self.dismiss();
|
||||
return;
|
||||
}
|
||||
// The mount holds the real file; the cache holds a copy an
|
||||
// editor would save into for nothing. Prefer the mount, and
|
||||
// materialise only what it does not expose.
|
||||
DriveActivation::Materialize => {
|
||||
if let Some(path) = mounted_target(&self.mountpoint.borrow(), &drive) {
|
||||
self.open_path(&path, false);
|
||||
self.dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.opening.set(true);
|
||||
self.spinner.set_visible(true);
|
||||
self.spinner.start();
|
||||
@@ -961,10 +1239,10 @@ impl Ui {
|
||||
self.socket.clone(),
|
||||
Request::OpenFile {
|
||||
path: drive.path.clone(),
|
||||
uid: Some(drive.uid.clone()),
|
||||
},
|
||||
);
|
||||
let ui = self.clone();
|
||||
let window = window.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.spinner.stop();
|
||||
@@ -972,8 +1250,8 @@ impl Ui {
|
||||
ui.opening.set(false);
|
||||
match reply {
|
||||
Ok(Ok(Response::FilePath { path })) => {
|
||||
xdg_open(Path::new(&path));
|
||||
window.close();
|
||||
ui.open_materialized(Path::new(&path), &drive.name);
|
||||
ui.dismiss();
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
ui.hint.set_label(&format!("Could not open: {message}"));
|
||||
@@ -1065,13 +1343,6 @@ fn spawn_request(
|
||||
rx
|
||||
}
|
||||
|
||||
fn xdg_open(path: &Path) {
|
||||
tracing::info!(path = %path.display(), "opening");
|
||||
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
||||
tracing::error!("xdg-open failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn dirs_home() -> Option<PathBuf> {
|
||||
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
|
||||
}
|
||||
@@ -1084,6 +1355,21 @@ fn file_name(path: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// An absolute directory path as "Home / …", falling back to the path itself
|
||||
/// for anything outside the home directory.
|
||||
fn home_relative(parent: &str) -> String {
|
||||
match dirs_home().and_then(|home| {
|
||||
Path::new(parent)
|
||||
.strip_prefix(&home)
|
||||
.ok()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
None => parent.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.parent()
|
||||
@@ -1274,3 +1560,75 @@ 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"));
|
||||
}
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Args, String> {
|
||||
parse_args(args.iter().map(|a| (*a).to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_arguments_defer_the_mode_to_config() {
|
||||
let args = parse(&[]).unwrap();
|
||||
assert_eq!(args.mode, None);
|
||||
assert_eq!(args.menu, None);
|
||||
assert_eq!(args.query, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn naming_a_launcher_implies_dmenu_but_an_explicit_mode_still_wins() {
|
||||
let args = parse(&["--menu", "fuzzel --dmenu"]).unwrap();
|
||||
assert_eq!(args.mode, Some(PromptMode::Dmenu));
|
||||
assert_eq!(
|
||||
args.menu,
|
||||
Some(vec!["fuzzel".to_string(), "--dmenu".to_string()])
|
||||
);
|
||||
|
||||
let args = parse(&["--gtk", "--menu", "rofi -dmenu"]).unwrap();
|
||||
assert_eq!(args.mode, Some(PromptMode::Gtk));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_without_its_value_is_an_error_not_a_default() {
|
||||
assert!(parse(&["--menu"]).is_err());
|
||||
assert!(parse(&["--query"]).is_err());
|
||||
assert!(parse(&["--nonsense"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_is_taken_verbatim_including_spaces() {
|
||||
let args = parse(&["--dmenu", "--query", "tax return 2024"]).unwrap();
|
||||
assert_eq!(args.query.as_deref(), Some("tax return 2024"));
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,10 @@ graph TD
|
||||
|
||||
| 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 (Files, Photos, Shares, Status). | Stateless; polls daemon for status and lists timelines via IPC socket. |
|
||||
| [`pdfs-core`](../crates/pdfs-core) | Core Infrastructure & Services | Cache bookkeeping, database migrations/schemas, IPC protocol payloads, and shared search relevance scoring. | Holds the unified SQLite DB (`Db`) connection and the on-disk cache metadata (`ContentCache`). |
|
||||
| [`pdfs-fuse`](../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`](../crates/pdfs-cli) | Command Line Interface | Command routing, daemon launcher, IPC client wrapper. | Stateless; communicates with daemon over IPC control socket. |
|
||||
| [`pdfs-gui`](../crates/pdfs-gui) | Graphical Interface | GTK pages, tray, and the resident quick-search prompt. | Keeps UI state only; all durable state and Drive access remain behind the IPC socket. |
|
||||
|
||||
---
|
||||
|
||||
@@ -76,6 +76,31 @@ sequenceDiagram
|
||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
||||
```
|
||||
|
||||
### Access Classification and Enforcement
|
||||
|
||||
Every `Entry` carries an `Access` (`Owner | Editor | Viewer | Unknown`), inherited from its parent at intern time rather than resolved per node — a child is always interned from its parent's listing, so one edge lookup answers it. A known share root takes its access from the persisted `share_access` table instead, which is also what makes the classification correct offline.
|
||||
|
||||
Two rules decide the cases inheritance cannot:
|
||||
|
||||
* **Not under a share, no role → `Owner` (fail open).** My Files and device folders are owned content; regressing this denies ordinary writes.
|
||||
* **Under a share with no usable role → `Viewer` (fail closed).** An unrecognised permission mask is never degraded into a guess.
|
||||
|
||||
A node whose parent is not resident is the awkward case: a device folder's parent is the device root, which is never persisted as a node, so the whole subtree hydrates parentless. It resolves the way the persisted authority (`Db::effective_node_access`) does — a recorded share row above it wins, otherwise fail open on the mount's own volume and closed on a foreign one (`docs/BUGS.md` B79).
|
||||
|
||||
Enforcement is three layers, and only the third closes B34:
|
||||
|
||||
1. **Mode bits.** `attr()` returns `0o555`/`0o444` for a non-writable entry, and both mount paths set `MountOption::DefaultPermissions`, so the *kernel* refuses `open(O_WRONLY)`, `access(W_OK)` and namespace operations for any non-root process.
|
||||
2. **Handler gates.** `EACCES` in `create`/`mkdir`/`unlink`/`rmdir`/`rename` (parent-writable; both parents for a rename) and in `open`-for-write/`write`/`setattr`/`fallocate`. Covers root and stale attribute TTLs.
|
||||
3. **Queue guards.** `Core::require_uid_writable` admits a mutation only when the persisted authority *and* every live inode space agree the uid is writable. Nothing reaches `pending_op` otherwise, so the perpetual failing drain that B34 describes cannot occur even if a handler check is missed. The intersection is across mounts because a uid may be resident in more than one inode space.
|
||||
|
||||
`EACCES`, not `EROFS`, for a read-only subtree inside a read-write mount: `EROFS` means "read-only filesystem" and misleads the heuristics in `cp`, `rsync` and `git`.
|
||||
|
||||
### Local Locations
|
||||
|
||||
`mount` is a presentation table: one row per local place this client occupies — the primary My Files session, and each device folder in mirror or on-demand mode. `sync_folder` remains the sync engine's own table (`sync_entry` is FK'd to it) and `MountKind::Device { sync_folder_id }` is the join. `Request::ListLocations` serves the table to `pdfs locations` and the GUI's Locations page.
|
||||
|
||||
A mirror folder is a plain local directory with no FUSE session, so a row can legitimately describe a location that is not mounted — which is why the page is called *Locations* rather than *Mounts*. The primary mountpoint stays in `AppConfig`, with its `mount` row written at daemon start as a projection of it, so there are not two sources of truth for that path.
|
||||
|
||||
---
|
||||
|
||||
## 3. Read Path & Block Caching Pipeline
|
||||
@@ -127,9 +152,10 @@ Because Proton Drive does not support partial byte writes, modified files must b
|
||||
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`).
|
||||
3. **Async Drain Thread (`run_pending_drain`):** The background drain worker picks up the database operations queue, handles revisions uploads, resolves conflicts, and cleans up staging files.
|
||||
- Durably publishes the scratch data and authored-range sidecar into `staging` under a `{uid}-{millis}-{counter}` name. Temporary data and metadata are synced before atomic rename, and their directory is synced before the source is removed.
|
||||
- Transactionally queues or supersedes a pending database operation (`PendingOp`), so insertion failure cannot erase the previously acknowledged upload. For `OP_REVISION` ops, execution is debounced to give rapid follow-up writes (e.g. `aria2c` preallocation followed by writing) time to supersede the staged blob before network transmission. The debounce is adaptive (`Core::revision_debounce`): it starts at `DRAIN_REVISION_DEBOUNCE = 2s` and, once a node has been uploaded, widens toward how long that upload actually took, bounded by `DRAIN_REVISION_DEBOUNCE_MAX = 60s`. A file saved faster than it can be sent therefore supersedes in the queue rather than mid-upload.
|
||||
- Signals `Core::cancel_upload` for the node before touching the queue. A drain worker may already be reading the blob this write supersedes; the flag is read by the upload's `CountingReader`, which refuses the SDK's next block. `queue_trash` and `discard_queued_ops` do the same, because they unlink the blob outright.
|
||||
3. **Async Drain Threads (`run_pending_drain`):** `DRAIN_WORKERS = 3` background workers share the operations queue. They pick work up through `Db::claim_next_due_op`, which marks the row `claimed_at` in the same transaction that selects it and excludes any op whose uid another worker already holds — ordering only has to hold *per node*, and that exclusion is what guarantees it. Worker 0 additionally runs the queue's idle chores (LRU touch flush, `recover_fsynced_writes`). A claim is process-local state: the single-writer `flock` means one found at open belongs to a crashed run, and `Db::clear_op_claims` drops the lot so those ops are not invisible forever. Each worker 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
|
||||
@@ -170,7 +196,7 @@ sequenceDiagram
|
||||
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.
|
||||
1. **Walk Local:** Walks the local directory tree recursively, scanning sizes and modification times while carrying a completeness result. A `readdir`, metadata, permission, or transient I/O failure makes the pass non-destructive instead of turning omitted paths into deletions.
|
||||
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:
|
||||
@@ -192,7 +218,7 @@ graph TD
|
||||
```
|
||||
|
||||
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. **Safety Gate and Settle:** Destructive plans are rejected when the scan is incomplete. Total-wipe protection applies to every non-empty baseline, including a single-entry folder. On success, baseline entries are upserted, timestamps updated, and any pending mode switches (e.g. going on-demand) are evaluated. Failed local deletion or conflict preservation retains the previous baseline and prevents remote content from overwriting the local source.
|
||||
|
||||
---
|
||||
|
||||
@@ -202,11 +228,18 @@ The CLI and GUI front-ends do not access database files or make network calls di
|
||||
|
||||
* **Transport:** IPC over Unix Stream Socket.
|
||||
* **Framing:** Line-delimited JSON payloads.
|
||||
* **Resource limits:** Requests are limited to 1 MiB, must end with a newline, and must arrive within 10 seconds. At most 64 connection handlers are active at once.
|
||||
* **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).
|
||||
|
||||
### Unified Search
|
||||
|
||||
`SearchV2` is the shared search boundary for the resident prompt. One request carries the query, result limit, requested sources (`Drive`, `Local`), and content kind. The daemon queries Drive metadata and the local home-directory index, then applies [`pdfs_core::search::relevance_score`](../crates/pdfs-core/src/search.rs) to both result sets. Exact and prefix matches rank above substring, abbreviation, and bounded typo matches; all query terms must match either the basename or parent path. Returning scores on one scale lets the GUI merge both sources into a deterministic **Best matches** list.
|
||||
|
||||
The prompt is a single-instance GTK application. It retains and hides its window between activations, resets its query state when summoned, and ignores stale asynchronous replies. Folder and streamable audio/video results open through the FUSE mount so applications can issue range reads; ordinary Drive files use the daemon's materialize-then-open path.
|
||||
|
||||
---
|
||||
|
||||
## 7. Subsystem Interaction & Thread Map
|
||||
@@ -215,9 +248,10 @@ 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.
|
||||
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for 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.
|
||||
6. **Conflict Sweep Thread (`pdfs-conflict-sweep`, optional):** Reconciles leftover `(sync-conflict …)` copies — 30 s warmup, then one pass every 5 minutes. A copy proven identical to its live sibling (equal size **and** equal `content_sha1`) is redundant; anything it cannot prove identical is surfaced to the activity feed and left alone. Governed by `AppConfig.conflict_sweep` / `PDFS_CONFLICT_SWEEP` (`SweepMode`): **report-only by default**, `off` skips the thread entirely, and only `enforce` lets it trash. Because it deletes, the enforcing path re-verifies revision id, size, digest, name, parent, queued ops and open handles immediately before acting rather than trusting its own pass-start snapshot. See `docs/BUGS.md` B69 and B71.
|
||||
|
||||
---
|
||||
|
||||
@@ -261,13 +295,13 @@ Two exposures this client does **not** currently mitigate, stated plainly rather
|
||||
- **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
|
||||
### 8.5 Enforced ownership and file modes
|
||||
|
||||
**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.
|
||||
On every start, `AppDirs::ensure` verifies that the state, cache, and configuration paths are real directories owned by the effective user, rejects symlink substitution or the wrong owner, and enforces mode `0700`. Both Unix sockets are changed to `0600` immediately after binding; failure is fatal rather than falling back to an unguarded daemon.
|
||||
|
||||
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:
|
||||
These checks protect the artifacts even when the surrounding home or XDG parent is traversable:
|
||||
|
||||
| Artifact | Exposure if the parent is traversable |
|
||||
| Artifact | Why access is restricted |
|
||||
|---|---|
|
||||
| `content/` | Another local user can read cached plaintext file content |
|
||||
| `cache.db` | Another local user can read the full decrypted name/structure index |
|
||||
@@ -275,12 +309,71 @@ What protects them today is the mode of the XDG parents (`~/.cache`, `~/.local/s
|
||||
|
||||
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.
|
||||
Configuration publication follows the same fail-closed model: a restricted temporary file is written and synced, atomically renamed, and followed by a directory sync. A malformed existing configuration is reported and preserved rather than overwritten with defaults.
|
||||
|
||||
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.
|
||||
- Restored or manually copied profiles must remain owned by the user. The daemon refuses to start against wrong-owner or symlinked sensitive directories.
|
||||
- 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.
|
||||
@@ -0,0 +1,292 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Linux client since the 1.0.0 release. Format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/1.1.0/); versions follow semver, and every
|
||||
release is tagged `vX.Y.Z` with `Cargo.toml` and `packaging/PKGBUILD` in agreement.
|
||||
|
||||
`Schema:` lines record the SQLite `SCHEMA_VERSION` (`pdfs-core/src/db/migrations.rs`) a
|
||||
release ships. Migrations are forward-only — a database written by a newer client is a hard
|
||||
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [1.8.0] — 2026-08-16
|
||||
|
||||
A queued write whose node had left the local tree was retried every five seconds forever and
|
||||
counted as an ordinary pending upload — 28 of them, holding 18.4 GiB, had been doing that for
|
||||
30 days on the machine that found it (`docs/BUGS.md` B83). Schema: 27 — migration V27 adds
|
||||
`pending_op.access_deferred_since`.
|
||||
|
||||
### Fixed
|
||||
- The drain no longer reads "I have never heard of this node" as "you may not write this
|
||||
node". A missing authority is now resolved against the remote: the node is re-interned if
|
||||
it is still there, and reported as a real failure if it is gone. Ops that had been stuck
|
||||
invisibly for weeks now say what is wrong with them on the first pass.
|
||||
- A create or mkdir resolves its *parent* as the authority, so the refetch asks about the
|
||||
folder rather than the node it has not made yet.
|
||||
|
||||
### Added
|
||||
- Access deferrals are bounded. A deferral still costs no attempt and records no error for
|
||||
the first five minutes — the case it exists for is a share downgraded mid-queue and undone
|
||||
a moment later — but past that window each recheck records a failure, so the op enters the
|
||||
ordinary backoff and appears in `failing_ops` and the `pdfs status` error text like any
|
||||
other stuck operation. The first deferral of a run is logged at `warn` rather than `debug`.
|
||||
|
||||
### Notes
|
||||
- No staged data is touched by any of this. A reported failure keeps its `pending_op` row and
|
||||
its staged blob exactly as before; the change is in what the user is told, and in the queue
|
||||
no longer waiting on a permission change that was never coming.
|
||||
|
||||
## [1.7.1] — 2026-08-16
|
||||
|
||||
Write-back throughput follow-up to 1.7.0, same audit
|
||||
(`docs/plans/audit-2026-08-15-perf-locks.md`). Schema: 26 — migration V26 adds
|
||||
`pending_op.claimed_at`.
|
||||
|
||||
### Added
|
||||
- Parallel write-back drain: three workers share the queue through a `pending_op.claimed_at`
|
||||
claim column (migration V26), so a large upload no longer holds every queued rename, trash
|
||||
and small write behind it. Ordering is preserved per node — the claim query never offers
|
||||
two workers ops for the same uid — and claims left by a crashed run are cleared at open.
|
||||
- An upload already on the wire is cancelled when a newer write, a trash or a discard
|
||||
supersedes it, instead of spending the uplink on a revision the next operation replaces.
|
||||
|
||||
### Changed
|
||||
- The revision debounce is adaptive: it widens toward how long that node's last upload
|
||||
actually took (bounded to 2–60 s), so a file saved faster than it can be sent supersedes
|
||||
in the queue rather than mid-upload.
|
||||
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.6.0.
|
||||
|
||||
## [1.7.0] — 2026-08-16
|
||||
|
||||
Performance and robustness pass over the mount, from the architecture audit in
|
||||
`docs/plans/audit-2026-08-15-perf-locks.md`. Schema: 25.
|
||||
|
||||
### Added
|
||||
- Sequential-read prefetch (`Core::prefetch_ahead`): per-file sequential detection, depth
|
||||
doubling 2→8, reset to 0 after a seek, skipping blocks already cached, and bounded by its
|
||||
own permit budget so it can never queue ahead of a demand read.
|
||||
- Single-flight block fetching (`BlockFlight`) keyed on `(uid, mtime, size, block)`, so a
|
||||
demand read, kernel read-ahead and prefetch of the same block share one download+decrypt.
|
||||
- In-memory block LRU (`BlockRing`) in front of *both* block paths; a block read off disk is
|
||||
promoted into it, so the following ~31 kernel reads of that 4 MiB block cost nothing.
|
||||
- Read-only SQLite connection pool (`ReadPool` / `Db::read`) with every `SELECT`-only method
|
||||
routed at it, so a read no longer waits on the write connection.
|
||||
- `nodes.path` persisted and backfilled, replacing the recursive `path_of` walk.
|
||||
- Single-writer `flock` on the database; a `SQLITE_CORRUPT`/`NOTADB` file is quarantined as
|
||||
`cache.db.corrupt-<ts>` (WAL sidecars too) and rebuilt rather than failing the mount.
|
||||
- `VACUUM` and the deep `integrity_check` now ack immediately and run as background jobs;
|
||||
`pdfs doctor` polls for the verdict.
|
||||
- Staging reconcile at mount: staged blobs no queued operation refers to are re-queued, or
|
||||
named in the log when they cannot be addressed.
|
||||
- Queue/staging visibility — `Status` gained `parked_uploads`, `failing_ops`,
|
||||
`failing_error`, `staged_bytes`, `staged_oldest_secs`; `pdfs status` prints them when
|
||||
non-zero.
|
||||
- `statfs` implemented against the account quota; FUSE `init` negotiates `max_readahead`,
|
||||
`max_background`, `PARALLEL_DIROPS` and `CACHE_SYMLINKS`; `blksize` raised to 1 MiB.
|
||||
|
||||
### Changed
|
||||
- `State` no longer writes to SQLite under the inode lock — mutations queue into an outbox
|
||||
applied after the lock is released, so interning a 5000-child listing no longer commits a
|
||||
transaction with the whole mount frozen.
|
||||
- Re-listing an unchanged folder no longer touches its subtree; when a subtree really moved,
|
||||
one recursive walk carries both the new path and the inherited trashed flag.
|
||||
- `release(2)` no longer gap-fills from the network: it stages an incomplete blob from local
|
||||
caches only and the drain thread finishes it off the dispatch loop.
|
||||
- A partial write over an undrained one is merged instead of refused.
|
||||
- Size-upgrade waiters park on a queue instead of occupying a worker thread, capped at 8
|
||||
folders in flight; past the cap `stat` answers provisionally.
|
||||
- Block-cache disk writes moved off the read's critical path; no more fsync per cached block.
|
||||
- Cache LRU touches buffered in memory and flushed in one transaction.
|
||||
- Search: sync-folder list and pin lookups hoisted out of the per-hit loop.
|
||||
- SQLite tuned (`cache_size`, `mmap_size`, `temp_store=MEMORY`, `wal_autocheckpoint`) and
|
||||
three new indexes added.
|
||||
- Mount registry lock released before FUSE teardown/join; kernel notifications batched under
|
||||
the state lock and flushed after it.
|
||||
|
||||
### Fixed
|
||||
- **Data loss:** `ENOSPC` is now propagated from `write`/`fsync`/staging instead of being
|
||||
swallowed, with emergency eviction wired up and scratch marked durable before any staging
|
||||
error; a failed `record_pending_write` fails `release` rather than dropping the write.
|
||||
- **Data loss:** the conflict sweep no longer discards queued operations after the trash
|
||||
round trip.
|
||||
- **Data loss:** `rescue_scratch` moves a blob with an unreadable sidecar to `recovery/`
|
||||
unaccompanied instead of fabricating a sidecar for it.
|
||||
- Own-sealed revisions are persisted (7-day TTL), so a restart no longer reopens the
|
||||
transient-download fork window fixed in 1.1.0.
|
||||
- Depth-capped `is_pinned` (cycle-safe), daemon write timeout, and every client-supplied
|
||||
`limit` clamped.
|
||||
- `recover_fsynced_writes` no longer scans the recovery directory on every idle pass.
|
||||
|
||||
## [1.6.0] — 2026-08-12
|
||||
|
||||
Search-index correctness and ranking (`docs/plans/search-index.md`). Schema: 21 —
|
||||
migration V21 rebuilds `nodes_fts`.
|
||||
|
||||
### Fixed
|
||||
- **B80** — 29% of the account was missing from the search index. `node_is_indexable_tx`
|
||||
required a null-parent ancestor, which only the My Files root has, so every node under a
|
||||
device folder was dropped from `nodes_fts` on its first upsert after the v16 backfill
|
||||
(2,705 of 9,252 nodes on the account this was diagnosed against). The rule now matches the
|
||||
backfill, migration V21 replays it, nodes under a trashed folder stay out, and the walk is
|
||||
depth-capped.
|
||||
- **B81** — the prompt labelled every Drive hit "My files", including device folders; pin
|
||||
rows also took `is_dir` from `Pin::recursive`.
|
||||
- **B82** — a `parent_uid` cycle hung the daemon on any short search: the recursive CTEs in
|
||||
`path_of` and the pin walk use `UNION ALL`, which does not deduplicate. `path_of` is now
|
||||
depth-capped at 256 and returns the truncated path.
|
||||
|
||||
### Changed
|
||||
- Local search ranking: whole-query prefix matches get their own lane (newest first) ahead
|
||||
of trigram rank, which on a home directory full of dependency trees was burying the
|
||||
obvious answer — 73% of the 500 best-ranked candidates for "test" came from one Go module
|
||||
cache. The single-character lane, which recovers a typo that destroys every trigram, stays
|
||||
behind it.
|
||||
|
||||
## [1.5.0] — 2026-08-11
|
||||
|
||||
Schema: 20.
|
||||
|
||||
### Added
|
||||
- **`pdfs-prompt --dmenu`** — the same search through an external dmenu-style launcher
|
||||
(fuzzel, rofi, wofi, tofi, bemenu, dmenu) instead of the built-in GTK HUD, so the prompt
|
||||
matches a tiling-WM desktop. A launcher filters a fixed list rather than re-querying per
|
||||
keystroke, so searching is a two-step loop; `--query` skips the first step, fuzzel and rofi
|
||||
get file-type icons. Configurable as `"prompt": { "mode": "dmenu", "menu": [...] }`, with
|
||||
`--gtk` overriding for one invocation.
|
||||
- **Configurable open-with rules** (`pdfs-core/src/opener.rs`): an ordered list of name/class
|
||||
patterns in `config.json` under `open_with`, each mapped to argv and optionally wrapped in
|
||||
a terminal, first match wins, falling back to `xdg-open`. Lets "open a text file" mean
|
||||
`nvim` in a terminal without breaking every graphical caller. Absent config reproduces the
|
||||
old behaviour exactly.
|
||||
|
||||
### Fixed
|
||||
- The prompt and dmenu openers hand applications the cached file while matching rules
|
||||
against the *original* Drive filename, so e.g. `.md` files stop being routed by their
|
||||
cache-path extension.
|
||||
|
||||
## [1.4.0] — 2026-08-10
|
||||
|
||||
Schema: 20.
|
||||
|
||||
### Added
|
||||
- **File version history** (`pdfs-fuse/src/revisions.rs` + a GTK versions dialog): list a
|
||||
file's revisions, restore one, delete one, or write an old one out to a local path. A
|
||||
restore re-points the file server-side, so no content crosses the wire and nothing enters
|
||||
the drain queue; the daemon drops its cached content and repopulates on the next read.
|
||||
- **Persistent SDK entity cache** (`pdfs-core/src/sdkcache.rs`): the SDK's decrypted node
|
||||
metadata now survives a daemon restart instead of re-walking the tree. It lives in its own
|
||||
`sdk_cache.db`, deliberately off the daemon's hot `cache.db` mutex, holds no key material,
|
||||
needs no migrations, and is cleared on logout.
|
||||
- Per-node outcomes for batch operations (`pdfs-core/src/batch.rs`): `trash`/`restore`/
|
||||
`delete`/`move` report one result per node, so a node the server rejects no longer fails
|
||||
the whole batch.
|
||||
- Photo viewer page in the GUI and richer photo metadata in the details pane.
|
||||
|
||||
### Changed
|
||||
- SDK telemetry routed into `tracing`, so its transfer/block-storage/request spans land in
|
||||
the daemon log instead of a no-op sink.
|
||||
- A file that fits in one block uploads as a single atomic request rather than the
|
||||
draft/block/commit sequence.
|
||||
|
||||
## [1.3.0] — 2026-07-31
|
||||
|
||||
Schema: 19.
|
||||
|
||||
### Added
|
||||
- **Photo albums** — `PhotoAlbums` / `AlbumPhotos` control requests, daemon-side `albums.rs`
|
||||
and a GUI albums page (the former gallery view, renamed). Albums other people share with
|
||||
you are included; their photos are persisted per album because a shared album's photos
|
||||
never appear in your own timeline. Served stale-while-refreshing like the timeline.
|
||||
- **GUI "Locations" page** — one row per mount spec: the primary `~/ProtonDrive` mount
|
||||
alongside every folder this computer backs up, mirrored or on-demand. Called *Locations*
|
||||
rather than *Mounts* because a mirror folder is a plain directory with no FUSE session.
|
||||
It absorbs the old Computers page's actions and the mountpoint chooser from Settings.
|
||||
- `role` on shared-node metadata (`viewer`/`editor`/`admin`), defaulted for wire-compat with
|
||||
older clients and daemons.
|
||||
- FUSE acceptance coverage for the permission-based mounts.
|
||||
|
||||
## [1.2.1] — 2026-07-31
|
||||
|
||||
Schema: 18.
|
||||
|
||||
### Fixed
|
||||
- Migration bug that could leave the `ProtonDrive` mount unwritable after upgrade.
|
||||
- Permission error on on-demand mounts.
|
||||
- B78.
|
||||
|
||||
### Changed
|
||||
- Release pipeline: `.deb` signing added and `.rpm` signing fixed.
|
||||
|
||||
## [1.2.0] — 2026-07-29
|
||||
|
||||
Sharing and multi-mount release. Schema: 18.
|
||||
|
||||
### Added
|
||||
- **Shared with me**: shares others sent you are listed, verified and mountable, including on
|
||||
the primary mount.
|
||||
They appear under a synthetic `Shared with me` directory on a virtual volume
|
||||
(`pdfs-fuse/src/virtual.rs`), with name collisions and over-long components resolved by
|
||||
its own naming rules.
|
||||
- Per-share access model (`db/share_access.rs`): the effective role of each shared root is
|
||||
persisted and kept resident in mount state, so interning an inode never costs a query. The
|
||||
database stays the restart/offline authority — every role change is written through before
|
||||
the cache updates. A share you cannot write is presented POSIX read-only.
|
||||
- Unified location model (`pdfs-core/src/mounts.rs`, `db/mounts.rs`): one presentation shape
|
||||
for every local Proton Drive location, joining device sync state with a new `mount` table.
|
||||
`MountMode` is `Mirror` (full local copy, reconciled both ways) or `OnDemand` (FUSE session
|
||||
fetching on access), with an `Unknown` fallback so a mode written by a newer client does
|
||||
not break an older one.
|
||||
|
||||
## [1.1.1] — 2026-07-27
|
||||
|
||||
Schema: 16.
|
||||
|
||||
### Fixed
|
||||
- Trash refresh did not pull updates.
|
||||
- Packaging: `PKGBUILD` was left at 1.1.0 by the 1.1.1 version bump, failing the release
|
||||
workflow's tag/manifest/PKGBUILD agreement check.
|
||||
|
||||
## [1.1.0] — 2026-07-26
|
||||
|
||||
Conflict-handling and concurrency release. Schema: 16.
|
||||
|
||||
### Added
|
||||
- Conflict sweep (`pdfs-fuse/src/sweep.rs`, 5-minute cadence): removes conflict copies proven
|
||||
identical by size + `content_sha1`, surfaces divergent ones once as an activity entry, and
|
||||
never removes what it cannot prove.
|
||||
- Sweep mode and `.pdfsignore`-driven conflict handling; ignore rules are rebuilt per sync
|
||||
pass so edits take effect without a restart.
|
||||
- Substantially extended FUSE acceptance suite.
|
||||
|
||||
### Fixed
|
||||
- **B69** — spurious `(sync-conflict)` copies. Revision identity now uses the server's
|
||||
`revision_id` rather than `(mtime, size)`, which the server re-stamps on seal; the planner
|
||||
gained `AdoptBaseline` so an equal-size no-baseline pair is recorded instead of forked on
|
||||
first reconcile.
|
||||
- **B70** — browser downloads into an on-demand mount had their in-flight temporaries sealed
|
||||
as canonical revisions. Transient names are recognised and held until the name is finalized.
|
||||
- Assorted race conditions and concurrency bugs on the write/drain paths.
|
||||
|
||||
### Changed
|
||||
- Workspace deps bumped to `proton-sdk` / `proton-drive-rs` 0.2.2.
|
||||
|
||||
## [1.0.0] — 2026-07-22
|
||||
|
||||
First stable release: FUSE files-on-demand mount, sync daemon under `proton-drive.service`,
|
||||
`pdfs` CLI and the GTK4 app, on top of the pure-Rust Proton Drive SDK. Schema: 16.
|
||||
|
||||
### Fixed
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[1.7.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.1
|
||||
[1.7.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.7.0
|
||||
[1.6.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.6.0
|
||||
[1.5.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.5.0
|
||||
[1.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.4.0
|
||||
[1.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.3.0
|
||||
[1.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.2.0
|
||||
[1.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.1.0
|
||||
[1.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.0.0
|
||||
@@ -0,0 +1,190 @@
|
||||
# Development and Verification Status for 1.0
|
||||
|
||||
This document summarizes the work still pending after the 1.0.0 release branch and records the most important recently completed changes. [`BUGS.md`](BUGS.md) is the authoritative issue ledger; [`TESTING.md`](TESTING.md) defines the acceptance commands.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Feature Roadmap (Horizon Tasks)
|
||||
Consolidated from the historical feature roadmap; current bug status lives in [`BUGS.md`](BUGS.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. (The SDK entity cache in `sdk_cache.db` is already encrypted at rest — see *Persistent SDK Entity Cache* below — but `cache.db` and the content cache are not.)
|
||||
* **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)**: Support creating and editing Proton Photo Albums (album *writes* are unported in the SDK too) and create an auto-upload Pictures folder pipeline. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance, Correctness & Robustness Items
|
||||
Consolidated from the performance and media-streaming audit notes.
|
||||
|
||||
* **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.
|
||||
* **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: [`BUGS.md`](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.
|
||||
* **B48 — stale listing after unlink**: Session tombstones and authoritative empty listings are implemented; run focused live verification against eventually consistent remote enumeration.
|
||||
* **B49–B55 — data-safety fixes**: Conflict preservation, orphan/staging durability, transactional supersession, one-entry wipe protection, and incomplete-scan handling have regression coverage but still require the fault, power-loss, and live mode-matrix cases recorded in `BUGS.md`.
|
||||
* **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains.
|
||||
* **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart.
|
||||
* **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain.
|
||||
* **B66–B67 — lifecycle and recovery**: Add queue flush/shutdown protection and explicit device adoption during fresh-state restore.
|
||||
* **B68/B78 — profile backup**: the destination is fixed and verified live — `profile.json` now lives in `.proton-drive-linux/` under the device root, since the API refuses file creation at the root itself. What remains from B68 is making backup *health* visible (today a failure is a `WARN` in the log and nothing in the UI) and re-running the replacement-machine restore end to end.
|
||||
* **B79 — cross-mount permission classification**: fixed and verified live; the standing guard is the `regression B79` acceptance case. The second-account cases it neighbours (`regression B34`) still cannot run — see below.
|
||||
|
||||
---
|
||||
|
||||
## 4. Testing, Verification & Disaster Recovery Drills
|
||||
Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](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.
|
||||
* **Second-account sharing run (mount-architecture.md P7)**: The only outstanding verification for the mount/sharing work. One account covers `context_share_id`, the shared-with-me listing, sharing from device folders and the Locations page — all confirmed. A **second account** is required for the shared-tree rendering under a foreign volume, `accept_invitation`, `update_member_role`, the `regression B34` viewer cases (which skip cleanly today), and above all an **editor-role write into a foreign share**, the only thing that validates `membership_address_for`.
|
||||
* **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions.
|
||||
* **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fixed & Resolved Bugs
|
||||
Derived from: [`BUGS.md`](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.
|
||||
* **B45 — truncate over a queued rewrite**: Path-based and handle-based truncate now compose over the complete pending blob; shrink, growth, and sparse I/O are live-verified.
|
||||
* **B46 — combined cross-directory move and rename**: The desired end state is durably queued and reconciles partially completed remote operations; replacement and combined cases are live-verified.
|
||||
* **B47 — overlong/invalid names**: All name-taking callbacks share a validator for `NAME_MAX`, reserved components, and UTF-8; managed-live verification passed.
|
||||
|
||||
### Implemented in 1.0 (Further Fault Verification Pending)
|
||||
|
||||
* **Unified search and resident prompt**: `SearchV2` combines Drive and local lookup with shared relevance scoring and content/source filters. The prompt reuses its window and opens streamable media through FUSE.
|
||||
* **Durable write publication**: Scratch sidecars and staged blobs use synced temporary files, atomic renames, and directory synchronization; orphan rescue retains its source on failure.
|
||||
* **Transactional pending operations**: Superseding a queued operation cannot delete the old durable record unless the replacement commits.
|
||||
* **Safer mirror reconciliation**: Conflict-copy failure aborts publication, incomplete scans are non-destructive, and total-wipe protection includes one-entry baselines.
|
||||
* **Bounded IPC**: Control requests are limited to 1 MiB and 10 seconds, with at most 64 active handlers.
|
||||
* **Private local state**: Sensitive directories require correct ownership, real-directory identity, and `0700`; sockets use `0600`; config saves are atomic and malformed input is preserved.
|
||||
* **Release gates**: Tag/workspace/package versions are checked and formatting, lint, tests, and offline FUSE acceptance run before packaging.
|
||||
|
||||
### 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
|
||||
|
||||
### File Version History (Implemented)
|
||||
**Files**: [`revisions.rs`](../crates/pdfs-fuse/src/revisions.rs), [`control.rs`](../crates/pdfs-core/src/control.rs), [`versions_dialog.rs`](../crates/pdfs-gui/src/app/widgets/versions_dialog.rs)
|
||||
|
||||
Proton Drive keeps every revision a client committed; the daemon only ever addressed the active one, so a file overwritten by a sync pass could be recovered only from whatever the local `recovery/` directory happened to hold.
|
||||
|
||||
The control protocol gained `ListRevisions` / `RestoreRevision` / `DeleteRevision` / `SaveRevisionAs` (each with a `…ByUid` twin for nodes the primary mount cannot name), the CLI gained `pdfs versions list|restore|save|rm`, and the browser's details pane gained a **Versions** button opening a per-file dialog.
|
||||
|
||||
Three properties that shape the code:
|
||||
|
||||
- **A restore is server-side and asynchronous.** No content crosses the wire and nothing enters the drain queue; the server answers 202 and swaps the active revision in the background, so the daemon evicts the file's cached blocks and open readers rather than describing the new state, and the UI wording promises the request, not the result.
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
|
||||
### Photo Favourites (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
|
||||
### Persistent SDK Entity Cache (Implemented)
|
||||
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
|
||||
|
||||
The SDK's Drive entity cache (decrypted node metadata: name, size, parent, signing share) defaulted to memory, so every daemon restart re-fetched and re-decrypted the tree the previous run had already walked. It is now backed by SQLite.
|
||||
|
||||
- **Its own file** (`sdk_cache.db` in the state directory), not `cache.db`: the daemon's `Db` is one `Mutex<Connection>` shared by every FUSE thread and the control socket, and this traffic is frequent, small and entirely reconstructible. A separate file also means no schema migration — the store can be deleted at any time.
|
||||
- **Encrypted at rest** by wrapping it in the SDK's `EncryptedCacheRepository`, keyed by the mailbox password. A password change reads as a cold cache (the SDK treats an undecryptable entry as a miss and clears the store), not as an error.
|
||||
- **Staleness** is closed by the event loop, which already replays from a persisted cursor and calls `invalidate_caches_for_event` for every event — including those raised while the daemon was down. The one case with no trail is a *seeded* cursor (first-ever mount, or a lost cursor), where the seed path now clears the store instead of trusting it. `auth::logout` removes the file.
|
||||
|
||||
Needed a small additive SDK change (`ProtonDriveClient::with_entity_repository`, 0.5.1): `with_entity_cache` is a constructor and so could not be combined with `with_key_salts`, which the daemon requires.
|
||||
|
||||
### Per-node Batch Outcomes (Implemented)
|
||||
**File**: [`batch.rs`](../crates/pdfs-core/src/batch.rs)
|
||||
|
||||
SDK 0.5.0 made `trash_nodes` / `restore_nodes` / `delete_nodes` report one outcome per node instead of failing the whole call (mirroring upstream's streamed `NodeActionResult`). `pdfs_core::batch::into_unit` collapses the single-node calls back to a `Result`; `batch::split` handles a collected batch.
|
||||
|
||||
The trash view's restore and permanent-delete use the SDK's **streaming** variants (`restore_nodes_streaming` / `delete_nodes_streaming`) and apply local state per node as each batch lands rather than after the last one. For a permanent delete — which is irreversible — that means a daemon interrupted mid-batch has forgotten exactly the nodes the server destroyed, no more and no fewer. Both report per-node failures and count only what actually succeeded; only a batch where nothing succeeded is an error.
|
||||
|
||||
|
||||
### Open-for-Write Deferral for Mirror Sync (Implemented)
|
||||
**File**: [`sync.rs`](../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 |
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Filesystem API and live FUSE acceptance suite
|
||||
|
||||
The account-free suite exercises the filesystem syscall contract against a
|
||||
temporary local directory. This validates the runner and covers creation/open
|
||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
||||
and real application workloads. It never reads application state or contacts
|
||||
Proton:
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --offline-only
|
||||
```
|
||||
|
||||
Kernel/FUSE behavior and remote convergence need a real mount. Live testing is
|
||||
explicitly opt-in and always runs the account-free contract first. It 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 --live /mnt/on-demand-testmount
|
||||
```
|
||||
|
||||
### The mount is diffed against an ordinary filesystem
|
||||
|
||||
The local reference run is not only a self-test of the runner. Every case
|
||||
records the facts it established, and a live run is compared against those
|
||||
recordings, so a case fails when the mount *differs* from an ordinary
|
||||
filesystem — not only when it raises. The report names the exact key:
|
||||
|
||||
```
|
||||
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
|
||||
! creation and open flags.truncated.size: live=6 reference=0
|
||||
```
|
||||
|
||||
Facts are recorded in one of two ways, chosen in the test body:
|
||||
|
||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||
reported mode bits, and which optional syscalls exist at all. Reported under
|
||||
`[capabilities]`, never a failure.
|
||||
|
||||
Because the choice is made at each call site, the set of accepted divergences
|
||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||
|
||||
### Operations the filesystem does not implement
|
||||
|
||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||
one that is dirty breaks software that never asked for the feature.
|
||||
|
||||
### Regressions from the bug ledger
|
||||
|
||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||
renamed after close must keep its content) need a real mount and a reachable
|
||||
daemon; they are skipped, not failed, without one.
|
||||
|
||||
B74 is currently **failing** — it is a live data-loss defect, not a regression
|
||||
guard, and it was found by exactly this mechanism: the B70 case failed, and
|
||||
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||
red run against a real mount until it is fixed.
|
||||
|
||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
||||
intersects what every live inode space says about a uid, so a fail-closed
|
||||
classification in one mount denies writes in another.
|
||||
|
||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
||||
accepted share at that role — B34 needs a **second account** to have shared a
|
||||
folder read-only, which is the one part of the sharing suite that cannot be
|
||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
||||
someone else's document.
|
||||
|
||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
||||
the actual harm — a background drain failing 403 forever.
|
||||
|
||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
||||
the directory is absent (an account with no accepted shares).
|
||||
|
||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||
queue to drain before reading. Reading straight back through the mount proves
|
||||
nothing: the local attribute and content caches will happily return the bytes
|
||||
that were just written even when nothing reached Drive, which is precisely how
|
||||
B74 stayed invisible.
|
||||
|
||||
### Timeouts, reports, and hung mounts
|
||||
|
||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
||||
That backstop is the only way to learn *where* a mount hung. A timeout ends the
|
||||
run: later cases against a wedged mount produce noise, not information.
|
||||
|
||||
```console
|
||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||
--timeout 300 --report-junit results.xml --budget 30
|
||||
```
|
||||
|
||||
`--report-json` also writes every recorded observation, which is what to attach
|
||||
to a bug report. `--budget SECONDS` flags cases that pass but got slower —
|
||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
||||
stops at the first failure.
|
||||
|
||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||
it; that has happened, and it was caught only by reading the journal by hand.
|
||||
|
||||
Abandoned `pdfs-acceptance-*` roots from a crashed run are removed at startup,
|
||||
but only if they match the exact generated name and are over an hour old
|
||||
(`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never disturbed.
|
||||
|
||||
### Automated mode matrix
|
||||
|
||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
||||
registers both as new sync folders, waits for initial synchronization, then
|
||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
||||
on-demand folder must actually appear as a mount before its contract runs.
|
||||
|
||||
```console
|
||||
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
|
||||
```
|
||||
|
||||
This creates remote folders and permanently deletes those test remotes during
|
||||
cleanup. It refuses non-empty paths, existing mounts, and paths already present
|
||||
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||
example `target/debug/pdfs`.
|
||||
|
||||
### Durability drill
|
||||
|
||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
||||
only copy of a file whose upload has not finished, so this is the case that
|
||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
||||
|
||||
### Selecting cases
|
||||
|
||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||
release acceptance run must leave it unset so the complete contract executes.
|
||||
|
||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||
in the account-free reference run, which is not an error — that target simply has
|
||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
||||
|
||||
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 --live /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 --live /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.
|
||||
|
||||
Normally the live suite applies the same API contract independently to each
|
||||
supplied mount. In convergence mode it exercises the primary once and verifies
|
||||
that the resulting bytes appear through every secondary view. 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.
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 52 KiB |