mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
Compare commits
159
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b7535696 | ||
|
|
ba414c4446 | ||
|
|
5b7f17bdaf | ||
|
|
73068f5868 | ||
|
|
b093010b64 | ||
|
|
ab8fe0e61d | ||
|
|
b8295373f7 | ||
|
|
5bc0b5c58a | ||
|
|
73af39ee65 | ||
|
|
48aaad23ce | ||
|
|
53543c91f9 | ||
|
|
fccb3d9561 | ||
|
|
6f2b4bb80a | ||
|
|
a617b2b410 | ||
|
|
bdbfc0bfd4 | ||
|
|
e9577f953f | ||
|
|
437d14b0f5 | ||
|
|
b40ca797d9 | ||
|
|
c64bb23e3c | ||
|
|
8d4a005aa2 | ||
|
|
9460c93b50 | ||
|
|
bd50ba5959 | ||
|
|
27201914ee | ||
|
|
2775f0681d | ||
|
|
97277d41dd | ||
|
|
d49738bf0b | ||
|
|
48945b9c97 | ||
|
|
b860ac3b49 | ||
|
|
42f3a18177 | ||
|
|
6f504d2e83 | ||
|
|
8c1c37a928 | ||
|
|
e4d2340bbf | ||
|
|
c1b72e455e | ||
|
|
2e3b7748e6 | ||
|
|
7f4f11ef18 | ||
|
|
cc9f7c6d8a | ||
|
|
9add185c1f | ||
|
|
0e7ffe1261 | ||
|
|
a5acae41b3 | ||
|
|
ea10a43216 | ||
|
|
917e7268f9 | ||
|
|
9089c8d13c | ||
|
|
071a11ef65 | ||
|
|
4edcff3f87 | ||
|
|
ee59e338b6 | ||
|
|
c1d9538669 | ||
|
|
a73ed34421 | ||
|
|
9c30ff8276 | ||
|
|
d1d4d23dd5 | ||
|
|
f717a692ff | ||
|
|
e065586d7e | ||
|
|
fd80c34152 | ||
|
|
e66308498b | ||
|
|
29b87ece72 | ||
|
|
fac8a144e5 | ||
|
|
eb25c16fe6 | ||
|
|
8f771fdcc4 | ||
|
|
c1911ea1de | ||
|
|
179b6555c4 | ||
|
|
e6971dd702 | ||
|
|
770d4fda38 | ||
|
|
11d03091a7 | ||
|
|
7a0f1ebd6f | ||
|
|
950056b48d | ||
|
|
05b84bf796 | ||
|
|
7cd516a164 | ||
|
|
c3c536892e | ||
|
|
cc9db51b78 | ||
|
|
7384d8e97d | ||
|
|
4bf176a631 | ||
|
|
aa0c60f5a2 | ||
|
|
47934b7ddb | ||
|
|
48c8910e7b | ||
|
|
6ec8043934 | ||
|
|
99707c48fa | ||
|
|
5557aa66d5 | ||
|
|
6456f503f1 | ||
|
|
4836c5a84a | ||
|
|
937a0a6824 | ||
|
|
fcbb15cb4b | ||
|
|
8aca7836f9 | ||
|
|
60a5a98174 | ||
|
|
51800c77d7 | ||
|
|
518763c5c9 | ||
|
|
ef0f046939 | ||
|
|
519ba0591a | ||
|
|
42b2f69fa0 | ||
|
|
0204f69e9e | ||
|
|
d4ef9116c8 | ||
|
|
f8c60c9753 | ||
|
|
607972cf1e | ||
|
|
96b70600f0 | ||
|
|
b0ff6ee441 | ||
|
|
bca22056b0 | ||
|
|
830cfd9faa | ||
|
|
77dac97afe | ||
|
|
2bea1f7672 | ||
|
|
0f279c7536 | ||
|
|
d6bc921a4b | ||
|
|
3221e4aa97 | ||
|
|
3d96cdbab1 | ||
|
|
18cc2ea498 | ||
|
|
a9cb389f04 | ||
|
|
923c47acb8 | ||
|
|
478a0e6cdd | ||
|
|
2633b4ed72 | ||
|
|
b46abfc07c | ||
|
|
fd78f088ec | ||
|
|
005f7ded1f | ||
|
|
3e4886f965 | ||
|
|
aac53e3139 | ||
|
|
c2bf87596f | ||
|
|
69d4feaa07 | ||
|
|
d481dcc15c | ||
|
|
e0442c56b9 | ||
|
|
ee76f4ac60 | ||
|
|
f6c651b06b | ||
|
|
8b7d7ae87f | ||
|
|
160626b29d | ||
|
|
f1f1f8ad4d | ||
|
|
bb28a18f49 | ||
|
|
a3c0c9ee9b | ||
|
|
91fa1614ed | ||
|
|
87361a4573 | ||
|
|
6d0b6ad553 | ||
|
|
170c3f01ff | ||
|
|
976ad2aa04 | ||
|
|
b73cd087aa | ||
|
|
e402981708 | ||
|
|
453c439b74 | ||
|
|
ceba0f5849 | ||
|
|
3e0db8cf46 | ||
|
|
2a86830e72 | ||
|
|
43031ecf99 | ||
|
|
2b6c37f543 | ||
|
|
3458e2e72d | ||
|
|
0ac5928138 | ||
|
|
beb3b2e4e4 | ||
|
|
bf43476e88 | ||
|
|
6363125fec | ||
|
|
51d114a8da | ||
|
|
31be27f30e | ||
|
|
4c498d559c | ||
|
|
7fd3439b60 | ||
|
|
c4847c4d52 | ||
|
|
ad49e32de9 | ||
|
|
19854b2d76 | ||
|
|
085335ca14 | ||
|
|
9fb7e84d41 | ||
|
|
7ebe8e5210 | ||
|
|
922399b9a2 | ||
|
|
3b80e9bc8f | ||
|
|
dc8eb27c99 | ||
|
|
07d4ee42d9 | ||
|
|
0ac5e2e4fe | ||
|
|
7eb6aa0640 | ||
|
|
d13e74fc12 | ||
|
|
c0d211238b | ||
|
|
3e3dd7db41 |
No files matched your search
@@ -3,6 +3,9 @@ contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||
about: Report privately — never in a public issue.
|
||||
- name: Troubleshooting and FAQ
|
||||
url: https://github.com/narrrl/proton-drive-linux/wiki/Troubleshooting
|
||||
about: Fixes for common problems, and what to include in a bug report.
|
||||
- name: Known issues
|
||||
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||
about: The authoritative ledger of tracked bugs and their status.
|
||||
|
||||
@@ -21,6 +21,7 @@ cargo test --workspace --locked
|
||||
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||
- [ ] Adds or renames a page in `docs/` (listed in `SECTIONS` in `scripts/publish-wiki.sh`, which publishes it to the wiki)
|
||||
- [ ] None of the above
|
||||
|
||||
<!-- Screenshots for GTK changes. -->
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Lints
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Unit tests
|
||||
run: cargo test --workspace --locked
|
||||
@@ -66,8 +66,9 @@ jobs:
|
||||
|
||||
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
||||
# They run on pull requests too: most data-loss fixes are only exercised
|
||||
# here, and a change that breaks one should not reach main first.
|
||||
simulation:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: COPR
|
||||
|
||||
on:
|
||||
# Called by release.yml once the GitHub release and its assets exist: COPR
|
||||
# downloads the tag archive and the vendored crates from the release.
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
COPR_CONFIG:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release to build, such as 3.5.0. It must already have its vendor tarball.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: copr
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build in COPR
|
||||
runs-on: ubuntu-24.04
|
||||
container: fedora:latest
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
COPR_PROJECT: narl-rs/proton-drive-linux
|
||||
|
||||
steps:
|
||||
- name: Install packaging tools
|
||||
run: dnf -y install copr-cli rpm-build rpmdevtools
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Configure COPR access
|
||||
env:
|
||||
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||
run: |
|
||||
install -d -m700 ~/.config
|
||||
printf '%s\n' "$COPR_CONFIG" > ~/.config/copr
|
||||
chmod 600 ~/.config/copr
|
||||
|
||||
# COPR rebuilds the source package without our --define, so the version
|
||||
# has to be written into the spec itself.
|
||||
- name: Build the source package
|
||||
run: |
|
||||
version="${VERSION#v}"
|
||||
spec=packaging/proton-drive-linux.spec
|
||||
sed -i -E "s/^(%\{!\?version: %global version )[^}]*\}/\1${version}}/" "$spec"
|
||||
grep -qF "%global version ${version}}" "$spec"
|
||||
mkdir -p rpmbuild/SOURCES
|
||||
spectool -g -C rpmbuild/SOURCES "$spec"
|
||||
rpmbuild -bs --define "_topdir $PWD/rpmbuild" "$spec"
|
||||
|
||||
# Waits for the build, so a failed COPR build fails this job.
|
||||
- name: Submit to COPR
|
||||
run: copr-cli build "$COPR_PROJECT" rpmbuild/SRPMS/*.src.rpm
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Nix
|
||||
|
||||
# Builds the flake (docs/INSTALL.md#nixos-flake) whenever its recipe, the lock
|
||||
# files or the code change, so a NixOS user's `nix build` keeps working. Run it
|
||||
# from the Actions tab for any branch to get a build to try.
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
- 'crates/**'
|
||||
- 'po/**'
|
||||
- '.github/workflows/nix.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'packaging/nix/**'
|
||||
- 'Cargo.lock'
|
||||
- '.github/workflows/nix.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Check the flake
|
||||
run: nix flake check --print-build-logs --no-build
|
||||
|
||||
- name: Build the package
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
./result/bin/pdfs --version
|
||||
for bin in pdfs-app pdfs-tray pdfs-prompt; do test -x "result/bin/$bin"; done
|
||||
grep -q "ExecStart=$(readlink -f result)/bin/pdfs" result/lib/systemd/user/proton-drive.service
|
||||
ls result/share/locale/*/LC_MESSAGES/pdfs.mo
|
||||
@@ -52,8 +52,10 @@ jobs:
|
||||
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)"
|
||||
spec_version="$(sed -n 's/^%{!?version: %global version \([^}]*\)}/\1/p' packaging/proton-drive-linux.spec)"
|
||||
test "$tag_version" = "$manifest_version"
|
||||
test "$tag_version" = "$pkgbuild_version"
|
||||
test "$tag_version" = "$spec_version"
|
||||
|
||||
- name: Release quality gates
|
||||
run: |
|
||||
@@ -129,6 +131,12 @@ jobs:
|
||||
# Create tarball
|
||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||
|
||||
# Every crate the build needs, so COPR can build the spec without network.
|
||||
- name: Package vendored crates (.tar.xz)
|
||||
run: |
|
||||
cargo vendor --locked dist/vendor > /dev/null
|
||||
tar -cJf proton-drive-linux-vendor-${{ env.VERSION }}.tar.xz -C dist vendor
|
||||
|
||||
- name: Package Debian package (.deb)
|
||||
run: |
|
||||
mkdir -p deb/DEBIAN
|
||||
@@ -153,6 +161,9 @@ jobs:
|
||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||
po/build.sh deb/usr/share/locale
|
||||
|
||||
# Stops the running daemon before removal, so the mount stops mirroring deletes
|
||||
install -m755 packaging/deb/prerm deb/DEBIAN/prerm
|
||||
|
||||
# Calculate Installed-Size in KB for the control file
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
|
||||
@@ -227,27 +238,75 @@ jobs:
|
||||
path: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
# The flake is the Nix release: a tag is what `github:narrrl/proton-drive-linux/vX.Y.Z`
|
||||
# pins, so it has to build before the release is announced.
|
||||
nix:
|
||||
name: Build the Nix package
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
|
||||
# Pulls from the project's binary cache, and pushes what this run built
|
||||
# when the token is there; a fork's pull request has none and only pulls.
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: proton-drive-linux
|
||||
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Build the flake
|
||||
run: nix build --print-build-logs .#proton-drive-linux
|
||||
|
||||
- name: Check the version
|
||||
run: |
|
||||
built="$(./result/bin/pdfs --version)"
|
||||
echo "$built"
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*) grep -qF "${GITHUB_REF_NAME#v}" <<< "$built" ;;
|
||||
esac
|
||||
|
||||
publish:
|
||||
name: Create GitHub Release
|
||||
needs: [checks, build-release]
|
||||
needs: [checks, build-release, nix]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: proton-drive-linux-builds
|
||||
|
||||
# The release notes are this version's section of the changelog, from its
|
||||
# heading up to the next one.
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
awk -v heading="## [$version]" \
|
||||
'index($0, heading) == 1 { found = 1; print; next }
|
||||
found && /^## \[/ { exit }
|
||||
found' docs/CHANGELOG.md > release-notes.md
|
||||
if [ ! -s release-notes.md ]; then
|
||||
echo "::error::docs/CHANGELOG.md has no section for $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
body_path: release-notes.md
|
||||
files: |
|
||||
*.deb
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.rpm
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -263,3 +322,21 @@ jobs:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
|
||||
copr:
|
||||
name: Build COPR packages
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/copr.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
COPR_CONFIG: ${{ secrets.COPR_CONFIG }}
|
||||
|
||||
wiki:
|
||||
name: Publish the wiki
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: ./.github/workflows/wiki.yml
|
||||
with:
|
||||
ref: ${{ github.ref_name }}
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Wiki
|
||||
|
||||
# docs/ is the source of the GitHub wiki (docs/DEVELOPMENT.md#common-changes).
|
||||
# Every change to the documentation checks its links; release.yml calls this
|
||||
# once the release is published, so the wiki documents the latest release. Run
|
||||
# it from the Actions tab to publish main in between.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'images/**'
|
||||
- 'README.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
- 'SECURITY.md'
|
||||
- 'scripts/publish-wiki.sh'
|
||||
- '.github/workflows/wiki.yml'
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Tag or branch to publish'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check documentation links
|
||||
if: inputs.ref == ''
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Check links
|
||||
run: scripts/publish-wiki.sh --check "$RUNNER_TEMP"
|
||||
|
||||
publish:
|
||||
name: Publish to the wiki
|
||||
# Set only by release.yml and the Actions tab, so pushes and pull requests
|
||||
# never publish.
|
||||
if: inputs.ref != ''
|
||||
runs-on: ubuntu-24.04
|
||||
concurrency:
|
||||
group: wiki-publish
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Publish
|
||||
env:
|
||||
WIKI_REMOTE: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.wiki.git
|
||||
run: |
|
||||
git config --global user.name 'github-actions[bot]'
|
||||
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
scripts/publish-wiki.sh --push "$RUNNER_TEMP"
|
||||
@@ -3,3 +3,5 @@
|
||||
*.pdb
|
||||
.env
|
||||
/rpmbuild
|
||||
/result
|
||||
/result-*
|
||||
+8
-6
@@ -6,10 +6,10 @@ deliberately high — everything else is ordinary Rust review.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
||||
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
||||
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||
together. [`docs/README.md`](docs/README.md) indexes the rest.
|
||||
- Read [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from
|
||||
source, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and
|
||||
SDK fit together. [`docs/README.md`](docs/README.md) indexes the rest. The same pages are on
|
||||
the [wiki](https://github.com/narrrl/proton-drive-linux/wiki).
|
||||
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||
already be tracked there with context.
|
||||
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||
@@ -58,8 +58,10 @@ runs are destructive: use a dedicated test account, never one holding data you c
|
||||
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
||||
The CLI and daemon messages stay in English.
|
||||
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
|
||||
under `docs/` (user guide, CLI reference or configuration) and in the `[Unreleased]` section of
|
||||
[`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
||||
under `docs/` (a user guide topic, the CLI reference or configuration) and in the `[Unreleased]`
|
||||
section of [`docs/CHANGELOG.md`](docs/CHANGELOG.md). Each release publishes `docs/` to the
|
||||
wiki. A new page goes into `SECTIONS` in `scripts/publish-wiki.sh`, and
|
||||
`scripts/publish-wiki.sh --check` must pass. CI runs it on every change to `docs/`.
|
||||
|
||||
## Commits and pull requests
|
||||
|
||||
|
||||
Generated
+23
-22
@@ -166,7 +166,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1254,7 +1254,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1433,7 +1433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1896,7 +1896,7 @@ dependencies = [
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 9.0.0",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3100,7 +3100,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "3.1.2"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3490,13 +3490,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "3.1.2"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"dbus-secret-service-keyring-store",
|
||||
"directories",
|
||||
"ignore",
|
||||
"kamadak-exif",
|
||||
"keyring-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
@@ -3516,7 +3517,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "3.1.2"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3538,7 +3539,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "3.1.2"
|
||||
version = "3.6.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
@@ -3778,9 +3779,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014165ed0f2d3847a4b8956ca94a82ba16c82f2d5c7fa139c3541c15150dba21"
|
||||
checksum = "8f70318394b2815b009c52f804d75747086db93a338ceb6eae62541f2b25d5bf"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3805,9 +3806,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e16bf5592b7947708dd754defeaee7a58e9fa35988a6ea345b5b9a93765fd81"
|
||||
checksum = "4196392032f56b3bbaac0bb1f24eea62fa7e2a0af72004de8d08e518d14720fb"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -3926,7 +3927,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4374,7 +4375,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4431,7 +4432,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4764,7 +4765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4932,10 +4933,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5266,7 +5267,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5590,7 +5591,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+15
-3
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "3.1.2"
|
||||
version = "3.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.8.1" }
|
||||
proton-drive-rs = { version = "0.8.1" }
|
||||
proton-sdk = { version = "0.10.0" }
|
||||
proton-drive-rs = { version = "0.10.0" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
@@ -61,3 +61,15 @@ async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.18"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
# No package ships a separate debug package (the PKGBUILD sets `!debug`, the
|
||||
# RPM spec sets `debug_package` to nil), and the unstripped binaries weighed
|
||||
# 43 MB (`pdfs`), 24 MB (`pdfs-app`) and 12 MB (`pdfs-tray`).
|
||||
strip = "symbols"
|
||||
# Off on purpose: LTO has broken GTK links for this workspace, which is why
|
||||
# the packages turn it off as well.
|
||||
lto = false
|
||||
# `Workers` and the thumbnail build catch panics to keep the daemon alive, so
|
||||
# unwinding must stay on.
|
||||
panic = "unwind"
|
||||
@@ -73,15 +73,30 @@ scriptable CLI.
|
||||
|
||||
| Distribution | Package |
|
||||
|---|---|
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| Fedora 44 or newer | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, updated with `dnf upgrade` |
|
||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||
| NixOS | The flake in this repository: see [Installation → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||
|
||||
**Fedora**
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
||||
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
||||
paru -S proton-drive-for-linux # Arch
|
||||
sudo dnf copr enable narl-rs/proton-drive-linux
|
||||
sudo dnf install proton-drive-linux
|
||||
```
|
||||
|
||||
**Arch Linux**
|
||||
|
||||
```bash
|
||||
paru -S proton-drive-for-linux
|
||||
```
|
||||
|
||||
**Debian and Ubuntu:** download the `.deb` from the
|
||||
[latest release](https://github.com/narrrl/proton-drive-linux/releases/latest), then:
|
||||
|
||||
```bash
|
||||
sudo apt install ./proton-drive-linux_*_amd64.deb
|
||||
```
|
||||
|
||||
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||
@@ -96,18 +111,25 @@ systemctl --user enable --now proton-drive.service
|
||||
pdfs status
|
||||
```
|
||||
|
||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
||||
video thumbnails), headless use and uninstalling.
|
||||
New to the client? [Getting started](docs/GETTING_STARTED.md) walks through the first steps.
|
||||
[Installation](docs/INSTALL.md) has the optional extras (tray on GNOME, RAW and video
|
||||
thumbnails), updating, headless use and uninstalling.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation is in [`docs/`](docs/README.md) and on the
|
||||
[wiki](https://github.com/narrrl/proton-drive-linux/wiki).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [Getting started](docs/GETTING_STARTED.md) | From installing to your first synced folder |
|
||||
| [User guide](docs/USER_GUIDE.md) | Everything the app, tray and launcher do |
|
||||
| [FAQ](docs/FAQ.md) | Short answers to common questions |
|
||||
| [Command-line reference](docs/CLI.md) | Every `pdfs` command and scripting with `--json` |
|
||||
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
||||
| [Recovering on a new computer](docs/RECOVERY.md) | Getting your synced folders back after a reinstall or a lost machine |
|
||||
| [Security model](docs/SECURITY_MODEL.md) | What protects your files, what stays on this computer, what the client connects to |
|
||||
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||
|
||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||
@@ -149,7 +171,8 @@ keys before it leaves the machine, through a Rust implementation of Proton's Dri
|
||||
|
||||
Files you open are decrypted into a local cache so programs can read them. What that means for
|
||||
the security of the machine is spelled out in the
|
||||
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
[security model](docs/SECURITY_MODEL.md#what-is-kept-on-this-computer), and in full detail in
|
||||
the [threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||
|
||||
For what the client protects and what it does not, written for users, see the
|
||||
[security model](docs/SECURITY_MODEL.md).
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Do not open a public issue.** Report privately through GitHub's
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Log priorities for journald.
|
||||
//!
|
||||
//! journald files each line a service writes at the unit's default priority,
|
||||
//! info, unless the line opens with a syslog `<N>` prefix. Without one every
|
||||
//! WARN and ERROR the daemon logs is stored as info, and the
|
||||
//! `journalctl -p warning` that the troubleshooting guide asks for shows
|
||||
//! nothing (`docs/BUGS.md` B258).
|
||||
|
||||
use std::fmt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::fmt::format::Writer;
|
||||
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// Whether standard output is the journal stream systemd connected this
|
||||
/// process to.
|
||||
///
|
||||
/// `JOURNAL_STREAM` alone does not say so: a terminal started from a user unit
|
||||
/// inherits it, and every `pdfs` run in that terminal would print the
|
||||
/// prefixes. systemd sets it to the stream's `device:inode`, so it is compared
|
||||
/// against what fd 1 actually is.
|
||||
pub(crate) fn stdout_is_journal() -> bool {
|
||||
let Some(stream) = std::env::var_os("JOURNAL_STREAM") else {
|
||||
return false;
|
||||
};
|
||||
let Ok(stdout) = std::fs::metadata("/proc/self/fd/1") else {
|
||||
return false;
|
||||
};
|
||||
stream
|
||||
.to_str()
|
||||
.is_some_and(|value| names_stream(value, stdout.dev(), stdout.ino()))
|
||||
}
|
||||
|
||||
/// Whether a `JOURNAL_STREAM` value names the file `dev`:`ino`.
|
||||
fn names_stream(value: &str, dev: u64, ino: u64) -> bool {
|
||||
value
|
||||
.split_once(':')
|
||||
.is_some_and(|(d, i)| d.parse() == Ok(dev) && i.parse() == Ok(ino))
|
||||
}
|
||||
|
||||
/// The syslog priority journald should file a `level` line at.
|
||||
fn priority(level: &Level) -> u8 {
|
||||
match *level {
|
||||
Level::ERROR => 3,
|
||||
Level::WARN => 4,
|
||||
Level::INFO => 6,
|
||||
Level::DEBUG | Level::TRACE => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// An event format that opens each line with its `<N>` priority, then writes
|
||||
/// the line as `F` would.
|
||||
pub(crate) struct Prioritized<F>(pub(crate) F);
|
||||
|
||||
impl<S, N, F> FormatEvent<S, N> for Prioritized<F>
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'a> FormatFields<'a> + 'static,
|
||||
F: FormatEvent<S, N>,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &FmtContext<'_, S, N>,
|
||||
mut writer: Writer<'_>,
|
||||
event: &Event<'_>,
|
||||
) -> fmt::Result {
|
||||
write!(writer, "<{}>", priority(event.metadata().level()))?;
|
||||
self.0.format_event(ctx, writer, event)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Buf(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl io::Write for Buf {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_opens_with_the_priority_of_its_level() {
|
||||
let buf = Buf(Arc::new(Mutex::new(Vec::new())));
|
||||
let writer = buf.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(Level::DEBUG)
|
||||
.with_ansi(false)
|
||||
.with_writer(move || writer.clone())
|
||||
.event_format(Prioritized(tracing_subscriber::fmt::format()))
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
tracing::error!("e");
|
||||
tracing::warn!("w");
|
||||
tracing::info!("i");
|
||||
tracing::debug!("d");
|
||||
});
|
||||
let out = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
|
||||
let prefixes: Vec<&str> = out.lines().map(|line| &line[..3]).collect();
|
||||
assert_eq!(prefixes, ["<3>", "<4>", "<6>", "<7>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_stream_must_name_the_same_file() {
|
||||
assert!(names_stream("8:12345", 8, 12345));
|
||||
assert!(!names_stream("8:12345", 8, 54321));
|
||||
assert!(!names_stream("9:12345", 8, 12345));
|
||||
assert!(!names_stream("8", 8, 12345));
|
||||
assert!(!names_stream("", 8, 12345));
|
||||
}
|
||||
}
|
||||
+768
-73
File diff suppressed because it is too large.
Load diff
@@ -30,3 +30,4 @@ async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
zeroize.workspace = true
|
||||
base64.workspace = true
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
+638
-41
@@ -21,6 +21,9 @@
|
||||
//! such separation to protect and are stored, as they always were.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
@@ -31,6 +34,7 @@ use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::http::Tokens;
|
||||
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
|
||||
use proton_sdk::telemetry::TracingTelemetry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -206,6 +210,44 @@ pub async fn login_interactive(
|
||||
}
|
||||
}
|
||||
|
||||
/// Proton's verification page for the user's own browser, for a front-end that
|
||||
/// cannot host it in a webview.
|
||||
///
|
||||
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
|
||||
/// not the embedded one: a browser tab has no host to post the token to, so the
|
||||
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
|
||||
/// same URL.
|
||||
pub fn browser_verification_url(hv: &HumanVerification) -> String {
|
||||
let token: String = hv
|
||||
.token
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"https://verify.proton.me/?methods={}&token={token}",
|
||||
hv.methods.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
/// The credential to retry with once the user has solved the challenge in
|
||||
/// their browser.
|
||||
///
|
||||
/// The browser keeps the page's own token, so the retry carries the challenge
|
||||
/// token instead: solving the page marks that token as verified on Proton's
|
||||
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
|
||||
/// the user may have picked any of them on the page.
|
||||
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
|
||||
HumanVerificationCredential {
|
||||
token: hv.token.clone(),
|
||||
method: hv.methods.join(","),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`login`], replaying a human-verification token the user has already earned.
|
||||
///
|
||||
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||
@@ -269,8 +311,8 @@ pub async fn login_verified(
|
||||
}
|
||||
};
|
||||
|
||||
register_refresh_handler(&session);
|
||||
let tokens = session.current_tokens().await;
|
||||
register_refresh_handler(&session, tokens.refresh_token.clone());
|
||||
let stored = StoredSession {
|
||||
session_id: session.session_id().as_str().to_owned(),
|
||||
username: session.username().to_owned(),
|
||||
@@ -322,11 +364,11 @@ fn keep_passphrases(
|
||||
let outcome = if remember {
|
||||
stored.key_passphrases = Some(encode(passphrases));
|
||||
// Best-effort: a system without a kernel keyring has nothing to clear.
|
||||
let _ = kernelkey::clear();
|
||||
let _ = kernelkey::PASSPHRASES.clear();
|
||||
Ok(())
|
||||
} else {
|
||||
stored.key_passphrases = None;
|
||||
kernelkey::store(&encode_secret(passphrases)).map_err(|e| {
|
||||
kernelkey::PASSPHRASES.store(&encode_secret(passphrases)).map_err(|e| {
|
||||
Error::Other(format!(
|
||||
"the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}"
|
||||
))
|
||||
@@ -427,7 +469,7 @@ fn find_passphrases(stored: &StoredSession) -> Result<Option<(KeyPassphrases, Ke
|
||||
if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) {
|
||||
return Ok(Some((found, KeyState::Stored)));
|
||||
}
|
||||
match kernelkey::load() {
|
||||
match kernelkey::PASSPHRASES.load() {
|
||||
Ok(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))),
|
||||
Ok(None) => Ok(None),
|
||||
// No keyring on this system: nothing was ever kept there.
|
||||
@@ -453,17 +495,18 @@ pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> {
|
||||
return Err(Error::ReloginRequired);
|
||||
}
|
||||
let mailbox = Zeroizing::new(mailbox.to_owned());
|
||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||
register_refresh_handler(&session);
|
||||
let session = resume_stored(&stored)?;
|
||||
let passphrases =
|
||||
derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?;
|
||||
keep_passphrases(stored, &passphrases, remember)
|
||||
// Read again: verifying may have rotated the tokens, and the copy read
|
||||
// above would put the spent refresh token back.
|
||||
keep_passphrases(load()?, &passphrases, remember)
|
||||
}
|
||||
|
||||
/// Drop the passphrases held in kernel memory, so the account is locked again
|
||||
/// until [`unlock`]. With `forget`, also those in the keyring blob.
|
||||
pub fn lock(forget: bool) -> Result<()> {
|
||||
kernelkey::clear()?;
|
||||
kernelkey::PASSPHRASES.clear()?;
|
||||
if forget {
|
||||
let mut stored = load()?;
|
||||
if stored.key_passphrases.take().is_some() {
|
||||
@@ -473,8 +516,59 @@ pub fn lock(forget: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget the persisted session (best-effort; absent entry is not an error).
|
||||
pub fn logout() -> Result<()> {
|
||||
/// How long [`logout`] waits for Proton to end the session before it signs
|
||||
/// out locally without.
|
||||
const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false
|
||||
/// when there is none or Proton could not be reached in time.
|
||||
///
|
||||
/// Runs on a thread of its own, so it works the same whether the caller is
|
||||
/// inside a tokio runtime or not.
|
||||
fn end_server_session() -> bool {
|
||||
let Ok(stored) = load() else {
|
||||
return false;
|
||||
};
|
||||
let ended = std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| -> std::result::Result<(), String> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
rt.block_on(async {
|
||||
let session = resume_stored(&stored).map_err(|e| e.to_string())?;
|
||||
match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await {
|
||||
Ok(result) => result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err(format!(
|
||||
"no answer within {}s",
|
||||
END_SESSION_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
})
|
||||
})
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err("the sign-out thread panicked".into()))
|
||||
});
|
||||
match ended {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "ending the session on Proton failed; signing out locally");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End the session on Proton, then forget it here. Returns whether Proton
|
||||
/// ended it: offline, the session stays valid on Proton's side until it
|
||||
/// expires or is signed out from the account's security settings.
|
||||
///
|
||||
/// Before this, signing out only deleted the keyring entry. The tokens stayed
|
||||
/// valid on Proton, so a copy of them taken from this machine still worked.
|
||||
///
|
||||
/// The local half is best effort; an absent entry is not an error.
|
||||
pub fn logout() -> Result<bool> {
|
||||
let ended = end_server_session();
|
||||
// The persisted entity cache describes the account being logged out of. It
|
||||
// is encrypted under a key derived from that account's passphrases, but
|
||||
// leaving it behind would also mean the next account inherits a store it can
|
||||
@@ -491,11 +585,12 @@ pub fn logout() -> Result<()> {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
if let Err(e) = kernelkey::clear() {
|
||||
if let Err(e) = kernelkey::PASSPHRASES.clear() {
|
||||
tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed");
|
||||
}
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -513,8 +608,7 @@ pub fn logout() -> Result<()> {
|
||||
/// a daemon starting offline can still mount from its cache.
|
||||
pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
let stored = load()?;
|
||||
let session = ProtonApiSession::resume(client_config(), stored.to_params())?;
|
||||
register_refresh_handler(&session);
|
||||
let session = resume_stored(&stored)?;
|
||||
|
||||
let passphrases = match find_passphrases(&stored)? {
|
||||
Some((passphrases, _)) => passphrases,
|
||||
@@ -529,6 +623,48 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> {
|
||||
Ok((client, session))
|
||||
}
|
||||
|
||||
/// Resume `stored`, with the rotation the keyring refused last time put in
|
||||
/// first, if there is one: Proton has spent the stored refresh token then,
|
||||
/// and the kernel keyring holds the one that replaced it (B61).
|
||||
fn resume_stored(stored: &StoredSession) -> Result<ProtonApiSession> {
|
||||
let unsaved = recover_unsaved(stored);
|
||||
let mut resumed = stored.clone();
|
||||
if let Some(tokens) = &unsaved {
|
||||
tracing::info!("resuming with session tokens the keyring refused last time");
|
||||
resumed.access_token = tokens.access_token.clone();
|
||||
resumed.refresh_token = tokens.refresh_token.clone();
|
||||
}
|
||||
let session = ProtonApiSession::resume(client_config(), resumed.to_params())?;
|
||||
let sink = register_refresh_handler(&session, stored.refresh_token.clone());
|
||||
if let Some(tokens) = unsaved {
|
||||
let _ = sink.rotated(tokens);
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// The rotation the kernel keyring holds for `stored`, if it belongs there.
|
||||
/// One that does not is from an older login, or was written since, and is
|
||||
/// dropped.
|
||||
fn recover_unsaved(stored: &StoredSession) -> Option<Tokens> {
|
||||
let kept = match kernelkey::UNSAVED_TOKENS.load() {
|
||||
Ok(kept) => kept?,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "kernel keyring unavailable; no unsaved tokens to recover");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<UnsavedRotation>(&kept) {
|
||||
Ok(unsaved) if unsaved.belongs_in(stored) => Some(Tokens {
|
||||
access_token: unsaved.access_token,
|
||||
refresh_token: unsaved.refresh_token,
|
||||
}),
|
||||
_ => {
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a blob written by 3.0.x — which holds the mailbox password — into one
|
||||
/// that holds passphrases, so the password stops being stored.
|
||||
///
|
||||
@@ -559,11 +695,14 @@ async fn migrate_legacy(
|
||||
stored.key_salts.clone()
|
||||
};
|
||||
|
||||
let mut migrated = stored.clone();
|
||||
let verified = derive_verified(session, legacy.as_bytes(), key_salts.clone()).await;
|
||||
// Read again: the calls above may have rotated the tokens, and `stored`
|
||||
// would put the spent refresh token back.
|
||||
let mut migrated = load()?;
|
||||
migrated.mailbox_password = String::new();
|
||||
migrated.key_salts = key_salts.clone();
|
||||
migrated.key_salts = key_salts;
|
||||
|
||||
match derive_verified(session, legacy.as_bytes(), key_salts).await {
|
||||
match verified {
|
||||
Ok(passphrases) => {
|
||||
migrated.key_passphrases = Some(encode(&passphrases));
|
||||
write_stored(&migrated)?;
|
||||
@@ -621,39 +760,309 @@ fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveC
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the session's current tokens back to the keyring.
|
||||
/// Write any rotation of the session's tokens that has not reached the keyring
|
||||
/// yet. A best-effort last chance before the process exits.
|
||||
///
|
||||
/// Everything else in the stored blob — salts, passphrases — is preserved, so
|
||||
/// this never undoes an [`unlock`] or [`lock`] made since the session started.
|
||||
/// Call whenever the session may have rotated its tokens so a later
|
||||
/// [`resume_client`] presents a live refresh token.
|
||||
pub async fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let tokens = session.current_tokens().await;
|
||||
store_tokens(tokens.access_token, tokens.refresh_token)
|
||||
/// Rotations are written as they happen (see [`register_refresh_handler`]), so
|
||||
/// this usually has nothing to do. It never writes the session's current
|
||||
/// tokens for their own sake: the blob may by now belong to a newer login, or
|
||||
/// hold a newer rotation, and writing them would put a spent refresh token
|
||||
/// over a live one.
|
||||
pub fn persist(session: &ProtonApiSession) -> Result<()> {
|
||||
let sink = TOKEN_SINKS
|
||||
.lock()
|
||||
.get(session.session_id().as_str())
|
||||
.cloned();
|
||||
match sink {
|
||||
Some(sink) => sink.flush(),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn store_tokens(access_token: String, refresh_token: String) -> Result<()> {
|
||||
let mut stored = load()?;
|
||||
stored.access_token = access_token;
|
||||
stored.refresh_token = refresh_token;
|
||||
write_stored(&stored)
|
||||
/// Whether the keyring holds a session newer than any this process runs on:
|
||||
/// another login, or one of this process's sessions rotated by another
|
||||
/// process (`pdfs unlock` resumes the same login and may refresh it).
|
||||
///
|
||||
/// The refresh token this process holds is spent then, and Proton refuses it
|
||||
/// as if the session were gone. A daemon asks this before it reports the
|
||||
/// session expired, and resumes the stored one instead.
|
||||
pub fn stored_session_is_newer() -> bool {
|
||||
// Nothing to compare with, and no keyring to wake up for it.
|
||||
if TOKEN_SINKS.lock().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(stored) = load() else {
|
||||
return false;
|
||||
};
|
||||
let sinks = TOKEN_SINKS.lock();
|
||||
match sinks.get(&stored.session_id) {
|
||||
Some(sink) => sink.state.lock().stored_refresh != stored.refresh_token,
|
||||
None => !sinks.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session in this process holds refreshed tokens the keyring has
|
||||
/// not taken yet. They are retried and kept in the kernel keyring meanwhile,
|
||||
/// which a reboot clears, so the user is asked to unlock the keyring (B61).
|
||||
pub fn tokens_unsaved() -> bool {
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.values()
|
||||
.any(|sink| sink.state.lock().unsaved.is_some())
|
||||
}
|
||||
|
||||
/// Where each live session in this process writes its rotated tokens, by
|
||||
/// session id, for [`persist`] to find.
|
||||
static TOKEN_SINKS: parking_lot::Mutex<BTreeMap<String, Arc<TokenSink>>> =
|
||||
parking_lot::Mutex::new(BTreeMap::new());
|
||||
|
||||
/// One session's rotated tokens on their way to the keyring.
|
||||
///
|
||||
/// Proton refresh tokens are single-use, so a rotation has to reach the
|
||||
/// keyring, and it must reach only the blob it belongs to. That is the blob of
|
||||
/// this session id that still holds the refresh token this session last knew
|
||||
/// it to hold. Anything else means the blob moved on without this session: a
|
||||
/// new login, or another session on the same login (`pdfs unlock`) that
|
||||
/// rotated first. Writing into it would replace a live refresh token with a
|
||||
/// spent one, and the next start fails with `InvalidRefreshToken` (issue #35).
|
||||
struct TokenSink {
|
||||
session_id: String,
|
||||
state: parking_lot::Mutex<SinkState>,
|
||||
/// Whether a thread is retrying a write the keyring refused.
|
||||
retrying: AtomicBool,
|
||||
}
|
||||
|
||||
/// How long to wait before each new try at a write the keyring refused; the
|
||||
/// last delay repeats until it succeeds. A locked or restarting keyring
|
||||
/// usually comes back within seconds, and one that does not must not be asked
|
||||
/// in a tight loop.
|
||||
const KEYRING_RETRY: [Duration; 4] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
];
|
||||
|
||||
struct SinkState {
|
||||
/// The refresh token the blob holds for this session, as far as this
|
||||
/// session knows: the one it started from, or the last one it wrote.
|
||||
stored_refresh: String,
|
||||
/// The latest rotation, until the keyring has taken it.
|
||||
unsaved: Option<Tokens>,
|
||||
/// Whether `unsaved` is also in [`kernelkey::UNSAVED_TOKENS`].
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
/// A rotation the keyring refused, as kept in the kernel keyring until it is
|
||||
/// written: the tokens, and the blob they go into.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct UnsavedRotation {
|
||||
session_id: String,
|
||||
/// The refresh token this rotation spent, which the blob still holds.
|
||||
spent: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
impl UnsavedRotation {
|
||||
/// Whether `stored` is the blob this rotation was refused for, still
|
||||
/// holding the refresh token it spent.
|
||||
fn belongs_in(&self, stored: &StoredSession) -> bool {
|
||||
self.session_id == stored.session_id && self.spent == stored.refresh_token
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a rotation offered to a stored blob.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Rotation {
|
||||
/// The blob is this session's and took the tokens.
|
||||
Taken,
|
||||
/// The blob moved on without this session; it is left alone.
|
||||
MovedOn,
|
||||
}
|
||||
|
||||
/// Put `tokens` into `stored` if it is still this session's blob: the same
|
||||
/// session id, holding the refresh token this session last left in it, or
|
||||
/// already holding this very rotation (another process wrote it first).
|
||||
fn take_rotation(
|
||||
stored: &mut StoredSession,
|
||||
session_id: &str,
|
||||
stored_refresh: &str,
|
||||
tokens: &Tokens,
|
||||
) -> Rotation {
|
||||
if stored.session_id != session_id
|
||||
|| (stored.refresh_token != stored_refresh && stored.refresh_token != tokens.refresh_token)
|
||||
{
|
||||
return Rotation::MovedOn;
|
||||
}
|
||||
stored.access_token = tokens.access_token.clone();
|
||||
stored.refresh_token = tokens.refresh_token.clone();
|
||||
Rotation::Taken
|
||||
}
|
||||
|
||||
impl TokenSink {
|
||||
fn new(session_id: &str, stored_refresh: String) -> Self {
|
||||
Self {
|
||||
session_id: session_id.to_owned(),
|
||||
state: parking_lot::Mutex::new(SinkState {
|
||||
stored_refresh,
|
||||
unsaved: None,
|
||||
kept: false,
|
||||
}),
|
||||
retrying: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a new rotation and try to write it. If the keyring refuses, keep
|
||||
/// trying in the background: until then the only live refresh token is in
|
||||
/// memory, and a stop that cannot write it either loses the session
|
||||
/// (`docs/BUGS.md` B61).
|
||||
fn rotated(self: &Arc<Self>, tokens: Tokens) -> Result<()> {
|
||||
self.state.lock().unsaved = Some(tokens);
|
||||
let written = self.flush();
|
||||
if let Err(e) = &written {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"the keyring refused the refreshed session tokens; retrying"
|
||||
);
|
||||
self.keep_in_kernel();
|
||||
self.retry_in_background();
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Keep the unsaved rotation in the kernel keyring too, so a process that
|
||||
/// dies before the retry lands does not take the only live refresh token
|
||||
/// with it. The next resume puts it back (see [`resume_stored`]).
|
||||
fn keep_in_kernel(&self) {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = &state.unsaved else {
|
||||
return;
|
||||
};
|
||||
let unsaved = UnsavedRotation {
|
||||
session_id: self.session_id.clone(),
|
||||
spent: state.stored_refresh.clone(),
|
||||
access_token: tokens.access_token.clone(),
|
||||
refresh_token: tokens.refresh_token.clone(),
|
||||
};
|
||||
let Ok(json) = serde_json::to_vec(&unsaved).map(Zeroizing::new) else {
|
||||
return;
|
||||
};
|
||||
match kernelkey::UNSAVED_TOKENS.store(&json) {
|
||||
Ok(()) => state.kept = true,
|
||||
Err(e) => tracing::error!(
|
||||
error = %e,
|
||||
"the kernel keyring refused them too; if this process stops first, \
|
||||
the next start needs `pdfs login`"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry the unsaved rotation on [`KEYRING_RETRY`] until it is written or
|
||||
/// has nowhere left to go. One thread per sink; a newer rotation simply
|
||||
/// replaces what it is retrying.
|
||||
fn retry_in_background(self: &Arc<Self>) {
|
||||
if self.retrying.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let sink = Arc::clone(self);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-keyring-retry".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let delays = KEYRING_RETRY
|
||||
.into_iter()
|
||||
.chain(std::iter::repeat(KEYRING_RETRY[KEYRING_RETRY.len() - 1]));
|
||||
for delay in delays {
|
||||
std::thread::sleep(delay);
|
||||
match sink.flush() {
|
||||
Ok(()) => break,
|
||||
Err(e) => tracing::debug!(error = %e, ?delay, "keyring still refuses"),
|
||||
}
|
||||
}
|
||||
sink.retrying.store(false, Ordering::SeqCst);
|
||||
// A rotation refused after the last try succeeded found
|
||||
// this thread still running, and started none of its own.
|
||||
if sink.state.lock().unsaved.is_none()
|
||||
|| sink.retrying.swap(true, Ordering::SeqCst)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
self.retrying.store(false, Ordering::SeqCst);
|
||||
tracing::error!(error = %e, "could not start retrying the keyring write");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the unsaved rotation, if there is one. A blob that moved on, or
|
||||
/// is gone with a logout, drops it: there is nothing it may still go into.
|
||||
fn flush(&self) -> Result<()> {
|
||||
let mut state = self.state.lock();
|
||||
let Some(tokens) = state.unsaved.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut stored = match load() {
|
||||
Ok(stored) => stored,
|
||||
Err(Error::NotLoggedIn) => {
|
||||
state.settle();
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
match take_rotation(
|
||||
&mut stored,
|
||||
&self.session_id,
|
||||
&state.stored_refresh,
|
||||
&tokens,
|
||||
) {
|
||||
Rotation::Taken => {
|
||||
write_stored(&stored)?;
|
||||
state.stored_refresh = tokens.refresh_token;
|
||||
tracing::info!("successfully auto-persisted refreshed tokens in keyring");
|
||||
}
|
||||
Rotation::MovedOn => {
|
||||
tracing::info!(
|
||||
"the stored session has moved on; not writing this session's tokens over it"
|
||||
);
|
||||
}
|
||||
}
|
||||
state.settle();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SinkState {
|
||||
/// The unsaved rotation has gone where it can: drop it, and its copy in
|
||||
/// the kernel keyring.
|
||||
fn settle(&mut self) {
|
||||
self.unsaved = None;
|
||||
if std::mem::take(&mut self.kept) {
|
||||
let _ = kernelkey::UNSAVED_TOKENS.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist rotated tokens the moment the session obtains them.
|
||||
///
|
||||
/// The handler re-reads the stored blob instead of capturing one, so it cannot
|
||||
/// write back a stale copy over a later unlock or lock — and cannot resurrect a
|
||||
/// session that was logged out while the daemon still ran.
|
||||
fn register_refresh_handler(session: &ProtonApiSession) {
|
||||
/// `stored_refresh` is the refresh token the keyring holds for this session
|
||||
/// now. The handler re-reads the stored blob instead of capturing one, so it
|
||||
/// cannot write back a stale copy over a later unlock or lock, and it writes
|
||||
/// only into a blob that is still this session's (see [`TokenSink`]), so it
|
||||
/// cannot resurrect a logged-out session or overwrite a newer login.
|
||||
fn register_refresh_handler(session: &ProtonApiSession, stored_refresh: String) -> Arc<TokenSink> {
|
||||
let session_id = session.session_id().as_str();
|
||||
let sink = Arc::new(TokenSink::new(session_id, stored_refresh));
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.insert(session_id.to_owned(), Arc::clone(&sink));
|
||||
let handler = Arc::clone(&sink);
|
||||
session.http().set_on_tokens_refreshed(move |tokens| {
|
||||
match store_tokens(tokens.access_token, tokens.refresh_token) {
|
||||
Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"),
|
||||
Err(Error::NotLoggedIn) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring")
|
||||
}
|
||||
}
|
||||
// A refusal is logged and retried by the sink itself.
|
||||
let _ = handler.rotated(tokens);
|
||||
});
|
||||
sink
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -674,6 +1083,30 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn challenge() -> HumanVerification {
|
||||
serde_json::from_str(
|
||||
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_gets_the_full_verification_page() {
|
||||
assert_eq!(
|
||||
browser_verification_url(&challenge()),
|
||||
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
|
||||
);
|
||||
}
|
||||
|
||||
/// The browser keeps the page's token, so the retry replays the challenge
|
||||
/// itself, typed with every method the page offered.
|
||||
#[test]
|
||||
fn a_browser_verification_retries_with_the_challenge_token() {
|
||||
let credential = browser_verified(&challenge());
|
||||
assert_eq!(credential.token, "ab+c/d=");
|
||||
assert_eq!(credential.method, "captcha,email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passphrases_survive_the_blob_encoding() {
|
||||
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
||||
@@ -724,4 +1157,168 @@ mod tests {
|
||||
assert_eq!(origin, KeyState::Stored);
|
||||
assert_eq!(found, passphrases());
|
||||
}
|
||||
|
||||
fn tokens(access: &str, refresh: &str) -> Tokens {
|
||||
Tokens {
|
||||
access_token: access.into(),
|
||||
refresh_token: refresh.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_session_tokens_are_written() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||
Rotation::Taken
|
||||
);
|
||||
assert_eq!(stored.access_token, "a2");
|
||||
assert_eq!(stored.refresh_token, "r2");
|
||||
}
|
||||
|
||||
/// Issue #35: `pdfs login` stores a new session and restarts the daemon,
|
||||
/// and the old daemon's stop wrote its spent tokens into the new blob.
|
||||
#[test]
|
||||
fn tokens_of_another_session_are_not_written() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "old", "r", &tokens("a2", "r2")),
|
||||
Rotation::MovedOn
|
||||
);
|
||||
assert_eq!(stored.refresh_token, "r");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` resumes a second session on the same login. Once it has
|
||||
/// rotated, the daemon's copy of the refresh token is spent.
|
||||
#[test]
|
||||
fn tokens_are_not_written_over_a_newer_rotation_of_the_same_session() {
|
||||
let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap();
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "spent", &tokens("a2", "r2")),
|
||||
Rotation::MovedOn
|
||||
);
|
||||
assert_eq!(stored.refresh_token, "r");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` wrote this very rotation first, from the same session.
|
||||
#[test]
|
||||
fn a_rotation_already_in_the_blob_is_taken() {
|
||||
let mut stored = stored_with("r2");
|
||||
assert_eq!(
|
||||
take_rotation(&mut stored, "s", "r", &tokens("a2", "r2")),
|
||||
Rotation::Taken
|
||||
);
|
||||
}
|
||||
|
||||
/// The keyring is one store per process, so the tests that use it,
|
||||
/// through `keyring-core`'s in-memory store, take turns.
|
||||
fn mock_keyring() -> parking_lot::MutexGuard<'static, ()> {
|
||||
static KEYRING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
let turn = KEYRING.lock();
|
||||
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
|
||||
turn
|
||||
}
|
||||
|
||||
fn stored_with(refresh: &str) -> StoredSession {
|
||||
serde_json::from_str(&blob("").replace(r#""r""#, &format!("{refresh:?}"))).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sink_keeps_writing_its_own_rotations_and_stops_at_a_new_login() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
sink.rotated(tokens("a2", "r2")).unwrap();
|
||||
sink.rotated(tokens("a3", "r3")).unwrap();
|
||||
assert_eq!(load().unwrap().refresh_token, "r3");
|
||||
|
||||
// A new login replaces the blob; the old session's next rotation, and
|
||||
// its stop, leave it alone.
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
sink.rotated(tokens("a4", "r4")).unwrap();
|
||||
sink.flush().unwrap();
|
||||
let now = load().unwrap();
|
||||
assert_eq!(now.session_id, "new");
|
||||
assert_eq!(now.refresh_token, "fresh");
|
||||
}
|
||||
|
||||
/// `pdfs unlock` rotated the daemon's session behind its back: the
|
||||
/// daemon's refresh token is spent, and the keyring holds the live one.
|
||||
#[test]
|
||||
fn a_session_rotated_by_another_process_is_newer_in_the_keyring() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
TOKEN_SINKS
|
||||
.lock()
|
||||
.insert("s".into(), Arc::new(TokenSink::new("s", "r".into())));
|
||||
assert!(!stored_session_is_newer());
|
||||
|
||||
write_stored(&stored_with("r2")).unwrap();
|
||||
assert!(stored_session_is_newer());
|
||||
|
||||
let mut login = stored_with("fresh");
|
||||
login.session_id = "new".into();
|
||||
write_stored(&login).unwrap();
|
||||
assert!(stored_session_is_newer());
|
||||
TOKEN_SINKS.lock().remove("s");
|
||||
}
|
||||
|
||||
/// B61: a rotation the keyring refused lived only in memory.
|
||||
#[test]
|
||||
fn a_rotation_the_keyring_refused_is_written_once_it_takes_it() {
|
||||
let _turn = mock_keyring();
|
||||
write_stored(&stored_with("r")).unwrap();
|
||||
let sink = Arc::new(TokenSink::new("s", "r".into()));
|
||||
|
||||
let entry = keyring_entry().unwrap();
|
||||
let cred = entry
|
||||
.as_any()
|
||||
.downcast_ref::<keyring_core::mock::Cred>()
|
||||
.unwrap();
|
||||
cred.set_error(keyring_core::Error::NoStorageAccess(
|
||||
std::io::Error::other("locked").into(),
|
||||
));
|
||||
assert!(sink.rotated(tokens("a2", "r2")).is_err());
|
||||
// Until it is written, a stop does not lose it: the next resume finds
|
||||
// it in the kernel keyring, where one is offered.
|
||||
let kept = sink.state.lock().kept;
|
||||
if kept {
|
||||
assert_eq!(
|
||||
recover_unsaved(&stored_with("r")).unwrap().refresh_token,
|
||||
"r2"
|
||||
);
|
||||
}
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while load().unwrap().refresh_token != "r2" {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the retry never wrote the rotation"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(sink.state.lock().unsaved.is_none());
|
||||
if kept {
|
||||
assert!(kernelkey::UNSAVED_TOKENS.load().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_rotation_goes_only_into_the_blob_it_was_refused_for() {
|
||||
let unsaved = UnsavedRotation {
|
||||
session_id: "s".into(),
|
||||
spent: "r".into(),
|
||||
access_token: "a2".into(),
|
||||
refresh_token: "r2".into(),
|
||||
};
|
||||
assert!(unsaved.belongs_in(&stored_with("r")));
|
||||
// Written since, by a retry or another process.
|
||||
assert!(!unsaved.belongs_in(&stored_with("r2")));
|
||||
let mut login = stored_with("r");
|
||||
login.session_id = "new".into();
|
||||
assert!(!unsaved.belongs_in(&login));
|
||||
}
|
||||
}
|
||||
@@ -2309,42 +2309,26 @@ mod tests {
|
||||
/// call also needs.
|
||||
#[test]
|
||||
fn budget_check_is_free_when_under_budget() {
|
||||
use std::time::Instant;
|
||||
|
||||
// Cap far above what we store, so every store stays under budget.
|
||||
let (c, _d) = cache_capped(1 << 30);
|
||||
let payload = vec![0u8; 4096];
|
||||
|
||||
// Cost of a store against a near-empty index...
|
||||
let t = Instant::now();
|
||||
for i in 0..500u64 {
|
||||
for i in 0..8u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let empty = t.elapsed() / 500;
|
||||
|
||||
// ...and against one with a few thousand entries in it.
|
||||
for i in 500..2500u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let t = Instant::now();
|
||||
for i in 2500..3000u64 {
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||
.unwrap();
|
||||
}
|
||||
let full = t.elapsed() / 500;
|
||||
// Plant an index row far over the cap behind the running total's back,
|
||||
// and older than every real block. Any budget check that consults the
|
||||
// index would see the pool over budget, evict, and reseed the total.
|
||||
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
|
||||
|
||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
||||
// path does not grow with the index, and only a ratio tests that. An
|
||||
// absolute threshold measures the machine's load instead, and this test
|
||||
// runs alongside the rest of the suite.
|
||||
assert!(
|
||||
full < empty * 3,
|
||||
"under-budget store should not scale with the index: \
|
||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
||||
);
|
||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||
.unwrap();
|
||||
|
||||
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||
}
|
||||
|
||||
/// The running totals are a fast path, not a second source of truth: after
|
||||
|
||||
@@ -163,6 +163,10 @@ pub struct AppConfig {
|
||||
/// Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub files_view: FilesView,
|
||||
/// The desktop app's main window size, remembered between runs. Defaulted
|
||||
/// for configs predating the field.
|
||||
#[serde(default)]
|
||||
pub window: WindowState,
|
||||
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||
/// back on. Defaulted for configs predating the field.
|
||||
@@ -186,6 +190,24 @@ pub struct AppConfig {
|
||||
/// the field.
|
||||
#[serde(default)]
|
||||
pub local_first: Option<bool>,
|
||||
/// Ask GitHub once a day whether a newer release is out, and say so in the
|
||||
/// desktop app. Off by default: GitHub then sees the address and version
|
||||
/// asking. Never used where a package manager installs updates (see
|
||||
/// [`crate::update::updates_managed`]). Defaulted for configs predating the
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub update_check: bool,
|
||||
/// When the update check last asked GitHub, in Unix seconds, so a restart
|
||||
/// does not ask again within [`crate::update::CHECK_INTERVAL`]. Defaulted
|
||||
/// for configs predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_last_checked: Option<i64>,
|
||||
/// The latest release that check found, such as `3.6.0`, so the app can
|
||||
/// say so again after a restart without asking. Older than or equal to the
|
||||
/// running version once the update is installed. Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub update_latest: Option<String>,
|
||||
}
|
||||
|
||||
/// The My Files page's view choices, remembered between runs.
|
||||
@@ -211,6 +233,44 @@ impl Default for FilesView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop app's main window, as it was when it last closed.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
pub struct WindowState {
|
||||
/// The unmaximised size, in logical pixels.
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
/// Below this the saved size is taken for damage and the default is used.
|
||||
pub const MIN_WIDTH: i32 = 480;
|
||||
pub const MIN_HEIGHT: i32 = 400;
|
||||
|
||||
/// This state, with a size too small to use replaced by the default.
|
||||
pub fn usable(self) -> Self {
|
||||
if self.width < Self::MIN_WIDTH || self.height < Self::MIN_HEIGHT {
|
||||
Self {
|
||||
maximized: self.maximized,
|
||||
..Self::default()
|
||||
}
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 980,
|
||||
height: 680,
|
||||
maximized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key a My Files listing is ordered by.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -310,6 +370,22 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fail when the config file at `path` exists but does not parse, so that
|
||||
/// [`AppDirs::save_config`] never writes over settings it could not read.
|
||||
fn refuse_to_replace_unreadable(path: &Path) -> Result<()> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(Error::Other(format!(
|
||||
"{} cannot be read ({e}); fix it or move it aside before changing settings",
|
||||
path.display()
|
||||
))),
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||
pub struct AppDirs {
|
||||
dirs: ProjectDirs,
|
||||
@@ -341,6 +417,7 @@ impl AppDirs {
|
||||
// Preserve the original bytes for diagnosis/recovery. In
|
||||
// particular, never replace a truncated config with defaults
|
||||
// and silently lose mount, device, or ignore settings.
|
||||
// `save_config` refuses to write over it for the same reason.
|
||||
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
||||
return AppConfig::default();
|
||||
}
|
||||
@@ -348,9 +425,8 @@ impl AppDirs {
|
||||
}
|
||||
|
||||
let default_config = AppConfig::default();
|
||||
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
||||
let _ = std::fs::write(&path, content);
|
||||
if let Err(e) = self.save_config(&default_config) {
|
||||
tracing::warn!(path = %path.display(), error = %e, "could not write the default config");
|
||||
}
|
||||
default_config
|
||||
}
|
||||
@@ -358,8 +434,15 @@ impl AppDirs {
|
||||
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
||||
/// config dir if missing. The Settings page calls this after editing the
|
||||
/// cache budget or mountpoint; the daemon re-reads the file on its next mount.
|
||||
///
|
||||
/// Refuses while the file on disk does not parse. Every caller loads,
|
||||
/// changes one field and saves, and [`load_config`](Self::load_config)
|
||||
/// gives defaults for a file it cannot read, so the first change after
|
||||
/// the file was damaged wrote defaults over the mountpoint, the device and
|
||||
/// the ignore rules the user had (`docs/BUGS.md` B223).
|
||||
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
||||
let path = self.config_path();
|
||||
refuse_to_replace_unreadable(&path)?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
||||
@@ -383,6 +466,46 @@ impl AppDirs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This machine's id (e.g. ~/.config/proton-drive-linux/machine-id). See
|
||||
/// [`machine_id`](Self::machine_id).
|
||||
pub fn machine_id_path(&self) -> PathBuf {
|
||||
self.dirs.config_dir().join("machine-id")
|
||||
}
|
||||
|
||||
/// A random id for this machine, made on first use and kept in
|
||||
/// [`machine_id_path`](Self::machine_id_path).
|
||||
///
|
||||
/// It names this machine's profile in a device it may share with other
|
||||
/// computers. Random rather than `/etc/machine-id`, so nothing that
|
||||
/// identifies the host goes to Drive. In a file of its own rather than
|
||||
/// `config.json`, because the app saves the config from a copy it loaded
|
||||
/// earlier, and that would drop an id the daemon had just added.
|
||||
pub fn machine_id(&self) -> Result<String> {
|
||||
let path = self.machine_id_path();
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(id) if is_machine_id(id.trim()) => return Ok(id.trim().to_string()),
|
||||
Ok(_) => {
|
||||
tracing::warn!(path = %path.display(), "machine id is unusable; making a new one");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let mut bytes = [0u8; 16];
|
||||
use std::io::Read as _;
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
|
||||
let id: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("machine id path has no parent".into()))?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
|
||||
std::fs::write(&tmp, format!("{id}\n"))?;
|
||||
std::fs::File::open(&tmp)?.sync_all()?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
std::fs::File::open(parent)?.sync_all()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Persistent state: inode map / cache index DB lives here.
|
||||
pub fn state_dir(&self) -> PathBuf {
|
||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||
@@ -471,6 +594,12 @@ impl AppDirs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` is what [`AppDirs::machine_id`] writes: 32 lowercase hex
|
||||
/// digits.
|
||||
fn is_machine_id(id: &str) -> bool {
|
||||
id.len() == 32 && id.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
||||
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
||||
fn restrict_dir(dir: &Path) -> Result<()> {
|
||||
@@ -526,6 +655,28 @@ mod tests {
|
||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
/// A damaged config must not be replaced by defaults the next time a
|
||||
/// setting changes (`docs/BUGS.md` B223).
|
||||
#[test]
|
||||
fn an_unreadable_config_is_never_written_over() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"pdfs-cfg-guard-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.json");
|
||||
|
||||
assert!(refuse_to_replace_unreadable(&path).is_ok(), "no file yet");
|
||||
std::fs::write(&path, serde_json::to_vec(&AppConfig::default()).unwrap()).unwrap();
|
||||
assert!(refuse_to_replace_unreadable(&path).is_ok());
|
||||
std::fs::write(&path, br#"{"mountpoint": "/home/me/Pro"#).unwrap();
|
||||
assert!(refuse_to_replace_unreadable(&path).is_err(), "truncated");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `docs/BUGS.md` B6. A permissive mode here is not a cosmetic problem: these
|
||||
/// directories hold decrypted content, a plaintext index of every node name,
|
||||
/// and the control socket that commands the daemon's session.
|
||||
@@ -611,10 +762,18 @@ mod tests {
|
||||
descending: true,
|
||||
folders_first: false,
|
||||
},
|
||||
window: WindowState {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
maximized: true,
|
||||
},
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
online_map: true,
|
||||
local_first: Some(false),
|
||||
update_check: true,
|
||||
update_last_checked: Some(1_760_000_000),
|
||||
update_latest: Some("3.6.0".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||
@@ -626,10 +785,46 @@ mod tests {
|
||||
assert_eq!(decoded.proton_theme, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
assert!(decoded.online_map);
|
||||
assert!(decoded.update_check);
|
||||
assert_eq!(decoded.update_last_checked, Some(1_760_000_000));
|
||||
assert_eq!(decoded.update_latest.as_deref(), Some("3.6.0"));
|
||||
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||
assert_eq!(decoded.download_limit, None);
|
||||
assert_eq!(decoded.files_view, config.files_view);
|
||||
assert_eq!(decoded.window, config.window);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_a_window_opens_at_the_default_size() {
|
||||
let decoded: AppConfig =
|
||||
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||
assert_eq!(decoded.window, WindowState::default());
|
||||
let partial: WindowState = serde_json::from_str(r#"{"maximized":true}"#).unwrap();
|
||||
assert_eq!((partial.width, partial.height), (980, 680));
|
||||
assert!(partial.maximized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_saved_too_small_opens_at_the_default_size() {
|
||||
let tiny = WindowState {
|
||||
width: 0,
|
||||
height: 500,
|
||||
maximized: true,
|
||||
};
|
||||
assert_eq!(
|
||||
tiny.usable(),
|
||||
WindowState {
|
||||
maximized: true,
|
||||
..WindowState::default()
|
||||
}
|
||||
);
|
||||
let fine = WindowState {
|
||||
width: 640,
|
||||
height: 480,
|
||||
maximized: false,
|
||||
};
|
||||
assert_eq!(fine.usable(), fine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -760,6 +955,15 @@ mod tests {
|
||||
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_id_is_32_lowercase_hex_digits() {
|
||||
assert!(is_machine_id("0123456789abcdef0123456789abcdef"));
|
||||
assert!(!is_machine_id("0123456789ABCDEF0123456789ABCDEF"));
|
||||
assert!(!is_machine_id("0123456789abcdef"));
|
||||
assert!(!is_machine_id("../../../../etc/passwd/0123456789"));
|
||||
assert!(!is_machine_id(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_predating_ignore_patterns_still_loads() {
|
||||
// Every installed config on disk looks like this; the field must not be
|
||||
|
||||
@@ -344,6 +344,17 @@ pub enum Request {
|
||||
/// or a mirror synced folder), and the move is still made on Proton Drive
|
||||
/// without transferring content. Replies with [`Response::Ok`].
|
||||
Move { path: String, new_parent: String },
|
||||
/// Copy a file or folder into a folder on Proton Drive, without
|
||||
/// downloading or uploading it. Both `path` and `new_parent` are
|
||||
/// mountpoint-relative, or absolute paths inside My files. A name already
|
||||
/// taken in `new_parent` gets the next free ` (n)` suffix. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Copy { path: String, new_parent: String },
|
||||
/// Ask Proton Drive how much the folder at mountpoint-relative `path`
|
||||
/// holds. Replies with [`Response::FolderSize`].
|
||||
FolderSize { path: String },
|
||||
/// The by-uid twin of [`Request::FolderSize`].
|
||||
FolderSizeByUid { uid: String },
|
||||
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||
/// [`Response::Ok`].
|
||||
Delete { path: String },
|
||||
@@ -446,6 +457,13 @@ pub enum Request {
|
||||
/// of registering a duplicate. `uid: None` clears the pin.
|
||||
/// Replies with [`Response::Ok`].
|
||||
AdoptDevice { uid: Option<String> },
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, as server-side moves on the main volume. The other device stays
|
||||
/// registered, without those folders. Sync folders here that already track
|
||||
/// one of them follow it to its new share. Nothing is downloaded: the moved
|
||||
/// folders are then offered by [`Request::ListRestorableFolders`].
|
||||
/// Replies with [`Response::Ok`].
|
||||
MigrateDevice { uid: String },
|
||||
|
||||
// ---- device folder sync ----------------------------------
|
||||
/// Add a local folder to this machine's device, uploading its tree and
|
||||
@@ -462,6 +480,11 @@ pub enum Request {
|
||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||
SetSyncFolderPaused { id: i64, paused: bool },
|
||||
/// Forget what a mirror folder synced before, so its next pass compares
|
||||
/// both sides afresh: what is on one side only is copied to the other, and
|
||||
/// a file that differs keeps both versions. Nothing is deleted. Replies
|
||||
/// with [`Response::Ok`].
|
||||
ResetSyncFolder { id: i64 },
|
||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
SyncNow { id: Option<i64> },
|
||||
@@ -589,6 +612,24 @@ pub enum Request {
|
||||
RemovePublicLink { path: String, id: String },
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
/// Change the public link `id` on the node at `path`, keeping its URL.
|
||||
/// `role` is "viewer" or "editor", or `None` to keep the current one.
|
||||
/// Replies with [`Response::PublicLink`].
|
||||
UpdatePublicLink {
|
||||
path: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// The by-uid twin of [`Request::UpdatePublicLink`].
|
||||
UpdatePublicLinkByUid {
|
||||
uid: String,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
},
|
||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||
/// invitation and every member. Each removal is attempted even if an
|
||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||
@@ -1068,6 +1109,18 @@ pub struct PublicLinkInfo {
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
/// What [`Request::UpdatePublicLink`] does with one option of a link: its
|
||||
/// custom password or its expiry.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LinkChange<T> {
|
||||
/// Leave the option as it is.
|
||||
Keep,
|
||||
/// Remove the option: no custom password, or no expiry.
|
||||
Remove,
|
||||
/// Set the option to this value.
|
||||
Set(T),
|
||||
}
|
||||
|
||||
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct InvitationInfo {
|
||||
@@ -1162,6 +1215,7 @@ pub enum ActivityKind {
|
||||
Sync,
|
||||
Rename,
|
||||
Move,
|
||||
Copy,
|
||||
CreateFolder,
|
||||
Trash,
|
||||
Restore,
|
||||
@@ -1218,6 +1272,12 @@ pub struct JobItem {
|
||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||
/// May *grow* mid-job as more work is discovered.
|
||||
pub total: u64,
|
||||
/// Housekeeping that changes nothing in the user's files: a mirror folder
|
||||
/// scan that finds nothing to do yet, an index rebuild, a cache check. Front
|
||||
/// ends may show it, but should not report the drive as busy over it. Absent
|
||||
/// from older daemons, which never set it.
|
||||
#[serde(default)]
|
||||
pub background: bool,
|
||||
}
|
||||
|
||||
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||
@@ -1247,8 +1307,15 @@ pub struct DirEntry {
|
||||
pub size: u64,
|
||||
/// Modification time, epoch seconds.
|
||||
pub modified: i64,
|
||||
/// Whether the file is pinned to this device.
|
||||
/// Whether the node is pinned to this device, on its own or through a
|
||||
/// pinned folder above it.
|
||||
pub pinned: bool,
|
||||
/// Whether the node is kept on this device because a folder above it is
|
||||
/// pinned. Such a node cannot be unpinned on its own. Set in
|
||||
/// [`Request::ListDir`] listings only. Defaulted for wire-compat with
|
||||
/// daemons predating the field.
|
||||
#[serde(default)]
|
||||
pub pinned_by_folder: bool,
|
||||
/// Whether the file's full content is present in the local cache (a current,
|
||||
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
||||
/// clients/daemons predating the field.
|
||||
@@ -1571,6 +1638,10 @@ pub struct RedateSummary {
|
||||
/// name implies)` — what a dry run is read for.
|
||||
#[serde(default)]
|
||||
pub samples: Vec<(String, i64, i64)>,
|
||||
/// Why the run as a whole failed, when it did. The counts are then
|
||||
/// empty: the run never got to the photos.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||
@@ -1600,6 +1671,10 @@ pub struct ImportSummary {
|
||||
pub bytes: u64,
|
||||
/// True when the run stopped early because it was cancelled.
|
||||
pub cancelled: bool,
|
||||
/// Why the run as a whole failed, when it did, such as an archive that
|
||||
/// is not a zip. The counts are then empty.
|
||||
#[serde(default)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// One album in a [`Response::Albums`] listing.
|
||||
@@ -1837,6 +1912,27 @@ pub fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the daemon that reported `version` in a [`Response::Status`] is an
|
||||
/// older release than this build. Installing a package replaces the binaries
|
||||
/// but leaves the running user service on the old one, which keeps its bugs
|
||||
/// until it restarts, while the app and the CLI already describe the new
|
||||
/// release (#33). Empty means a daemon from before the field, so older too.
|
||||
///
|
||||
/// Only older counts. A newer daemon means this front-end is the one still
|
||||
/// running from before the upgrade, and restarting the service would not help.
|
||||
pub fn daemon_outdated(version: &str) -> bool {
|
||||
fn parse(v: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = v.split('.').map(|p| p.parse().ok());
|
||||
let parsed = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(parsed)
|
||||
}
|
||||
match (parse(version), parse(env!("CARGO_PKG_VERSION"))) {
|
||||
(Some(running), Some(this)) => running < this,
|
||||
(None, _) => version.is_empty(),
|
||||
(Some(_), None) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// One worker thread in a [`Diagnostics`] report.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct WorkerState {
|
||||
@@ -1884,6 +1980,29 @@ pub struct Diagnostics {
|
||||
pub control_handler_limit: usize,
|
||||
/// Queued mutations not yet drained.
|
||||
pub pending_ops: u64,
|
||||
/// The folder-sync engine. `None` from a daemon that predates the field.
|
||||
#[serde(default)]
|
||||
pub sync: Option<SyncEngineState>,
|
||||
}
|
||||
|
||||
/// What the folder-sync engine is doing, for [`Diagnostics`].
|
||||
///
|
||||
/// The engine is one thread that runs every synced folder's passes in turn.
|
||||
/// Dead or stuck, it looked exactly like an idle one: workers idle, nothing
|
||||
/// queued, nothing in the journal (`docs/BUGS.md` B259).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct SyncEngineState {
|
||||
/// Whether the engine thread is running. Once it has ended, nothing syncs
|
||||
/// until the daemon restarts.
|
||||
pub running: bool,
|
||||
/// The synced folder whose pass is running, if one is.
|
||||
pub pass_folder: Option<i64>,
|
||||
/// How long that pass has been running.
|
||||
pub pass_age_secs: u64,
|
||||
/// How long since that pass last reported progress.
|
||||
pub pass_idle_secs: u64,
|
||||
/// Passes that ended in a panic since the daemon started.
|
||||
pub panics: u64,
|
||||
}
|
||||
|
||||
/// The daemon's reply to a [`Request`].
|
||||
@@ -1946,6 +2065,21 @@ pub enum Response {
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
/// True when Proton refused the session itself (refresh token refused,
|
||||
/// account deleted or disabled). `online` is false then too, but no
|
||||
/// connection brings it back: only signing in again does.
|
||||
#[serde(default)]
|
||||
session_expired: bool,
|
||||
/// True while the keyring refuses the session's refreshed tokens. The
|
||||
/// daemon keeps retrying; until it succeeds, a reboot signs the user
|
||||
/// out.
|
||||
#[serde(default)]
|
||||
tokens_unsaved: bool,
|
||||
/// The release of the daemon that answered. A package upgrade does not
|
||||
/// restart the user service, so it can be older than the front-end
|
||||
/// asking; daemons before 3.3.0 omit it, which reads as empty.
|
||||
#[serde(default)]
|
||||
version: String,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -2121,9 +2255,14 @@ pub enum Response {
|
||||
entries: Vec<ShareEntry>,
|
||||
link: Option<PublicLinkInfo>,
|
||||
},
|
||||
/// A freshly created public link, carrying the shareable URL (reply to
|
||||
/// [`Request::CreatePublicLink`]).
|
||||
/// A freshly created or changed public link, carrying the shareable URL
|
||||
/// (reply to [`Request::CreatePublicLink`] and
|
||||
/// [`Request::UpdatePublicLink`]).
|
||||
PublicLink { link: PublicLinkInfo },
|
||||
/// What a folder holds on Proton Drive (reply to [`Request::FolderSize`]):
|
||||
/// the bytes on storage and the number of items below it, folders and
|
||||
/// trashed items included.
|
||||
FolderSize { size: u64, items: u64 },
|
||||
/// Invitations addressed to me (reply to [`Request::ListInvitations`]).
|
||||
Invitations { items: Vec<InvitationInfo> },
|
||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
||||
@@ -2589,6 +2728,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #33: a daemon left running over an upgrade is named as older, also when
|
||||
/// it predates the version field, but a newer one is not, since restarting
|
||||
/// the service cannot bring it closer to this build.
|
||||
#[test]
|
||||
fn only_an_older_daemon_is_outdated() {
|
||||
assert!(!daemon_outdated(env!("CARGO_PKG_VERSION")));
|
||||
assert!(daemon_outdated(""));
|
||||
assert!(daemon_outdated("0.9.9"));
|
||||
assert!(!daemon_outdated("999.0.0"));
|
||||
assert!(!daemon_outdated("not-a-version"));
|
||||
let old: Response = serde_json::from_str(
|
||||
r#"{"Status":{"username":"u","mountpoint":"/m","pinned":0,
|
||||
"used":0,"budget":0,"pins":[]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let Response::Status { version, .. } = old else {
|
||||
panic!("not a status: {old:?}");
|
||||
};
|
||||
assert!(daemon_outdated(&version));
|
||||
}
|
||||
|
||||
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||
/// that is exactly how they cross the control socket.
|
||||
#[test]
|
||||
@@ -2602,6 +2762,10 @@ mod tests {
|
||||
path: "a/b.txt".into(),
|
||||
new_parent: "d".into(),
|
||||
},
|
||||
Request::Copy {
|
||||
path: "a/b.txt".into(),
|
||||
new_parent: "d".into(),
|
||||
},
|
||||
Request::Delete {
|
||||
path: "a/b.txt".into(),
|
||||
},
|
||||
@@ -3073,6 +3237,7 @@ mod tests {
|
||||
id: 3,
|
||||
paused: true,
|
||||
},
|
||||
Request::ResetSyncFolder { id: 3 },
|
||||
Request::SetBandwidthLimits {
|
||||
upload: 1_000_000,
|
||||
download: 0,
|
||||
@@ -3082,6 +3247,9 @@ mod tests {
|
||||
uid: Some("dev-1".into()),
|
||||
},
|
||||
Request::AdoptDevice { uid: None },
|
||||
Request::MigrateDevice {
|
||||
uid: "dev-2".into(),
|
||||
},
|
||||
Request::ListRestorableFolders,
|
||||
Request::RestoreSyncFolders {
|
||||
items: vec![RestoreItem {
|
||||
@@ -3159,6 +3327,24 @@ mod tests {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
},
|
||||
Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: Some("editor".into()),
|
||||
password: LinkChange::Set("pw".into()),
|
||||
expires: LinkChange::Remove,
|
||||
},
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
},
|
||||
Request::FolderSize { path: "a".into() },
|
||||
Request::FolderSizeByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
Request::ListSharedByMe,
|
||||
Request::ListActivity { limit: 100 },
|
||||
Request::ListSharedWithMe,
|
||||
@@ -3533,6 +3719,16 @@ mod tests {
|
||||
},
|
||||
"RemovePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::UpdatePublicLinkByUid {
|
||||
uid: "vol~link".into(),
|
||||
id: "link".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Keep,
|
||||
},
|
||||
"UpdatePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::StopSharingByUid {
|
||||
uid: "vol~link".into(),
|
||||
@@ -3558,6 +3754,7 @@ mod tests {
|
||||
| Request::RemoveShareEntryByUid { uid, .. }
|
||||
| Request::CreatePublicLinkByUid { uid, .. }
|
||||
| Request::RemovePublicLinkByUid { uid, .. }
|
||||
| Request::UpdatePublicLinkByUid { uid, .. }
|
||||
| Request::StopSharingByUid { uid } => uid,
|
||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||
};
|
||||
@@ -3566,6 +3763,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_update_names_what_it_keeps_removes_and_sets() {
|
||||
let request = Request::UpdatePublicLink {
|
||||
path: "a/b".into(),
|
||||
id: "url-1".into(),
|
||||
role: None,
|
||||
password: LinkChange::Keep,
|
||||
expires: LinkChange::Set(1_700_000_000),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_string(&request).unwrap(),
|
||||
r#"{"UpdatePublicLink":{"path":"a/b","id":"url-1","role":null,"password":"Keep","expires":{"Set":1700000000}}}"#
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&LinkChange::<String>::Remove).unwrap(),
|
||||
r#""Remove""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
||||
|
||||
@@ -111,9 +111,80 @@ impl Db {
|
||||
)?;
|
||||
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||
tx.commit()?;
|
||||
drop(conn);
|
||||
self.compact_search_indexes_if_due(finished_at)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Close a scan that did not see everything: it stopped at the entry cap,
|
||||
/// or a batch failed to write. Nothing is pruned, since a path it missed is
|
||||
/// not a path that is gone; the next complete scan prunes. Returns the
|
||||
/// number of indexed entries.
|
||||
///
|
||||
/// Pruning after such a scan dropped every entry it had not reached, and
|
||||
/// the next scan put them back. That churn is most of what grew
|
||||
/// `local_fts` to 16 times its size (`docs/BUGS.md` B217).
|
||||
pub fn local_finish_partial_scan(&self, finished_at: i64) -> Result<i64> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[finished_at.to_string()],
|
||||
)?;
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Compact both full-text indexes, at most once a day.
|
||||
///
|
||||
/// FTS5 keeps a deleted entry's data until the segments holding it are
|
||||
/// merged, and with the few large segments these indexes settle into that
|
||||
/// is close to never. Every file the local scan sees disappear or appear
|
||||
/// leaves some behind, and every node row the tree replaces does too: on
|
||||
/// one machine `local_fts` held 317 MiB for 40,000 paths, against 20 MiB
|
||||
/// rebuilt, and a search took four times as long (`docs/BUGS.md` B217).
|
||||
///
|
||||
/// `local_fts` is rebuilt from `local_files`, which also drops anything
|
||||
/// left over, and `nodes_fts`, which has no content table to rebuild from,
|
||||
/// is merged into one segment. Each runs in its own transaction on the
|
||||
/// write connection, about half a second on an index that size; once a
|
||||
/// day keeps the leftovers small without paying that at every scan.
|
||||
pub fn compact_search_indexes_if_due(&self, now: i64) -> Result<()> {
|
||||
const EVERY_SECS: i64 = 24 * 60 * 60;
|
||||
let last: i64 = self
|
||||
.read()
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'fts_compacted_at'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
if now - last < EVERY_SECS {
|
||||
return Ok(());
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
{
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("INSERT INTO local_fts(local_fts) VALUES('rebuild')", [])?;
|
||||
}
|
||||
{
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('optimize')", [])?;
|
||||
conn.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('fts_compacted_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
[now.to_string()],
|
||||
)?;
|
||||
}
|
||||
tracing::info!(
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"compacted the search indexes"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When the last local scan completed (epoch seconds), or `None` if the index
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
|
||||
@@ -91,8 +91,13 @@ impl Db {
|
||||
/// 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.
|
||||
///
|
||||
/// Runs on a read connection: it reads every page, which took 34 s on a
|
||||
/// 567 MiB file, and on the write connection every write in the daemon
|
||||
/// waited that long behind it (`docs/BUGS.md` B218). WAL lets it read a
|
||||
/// consistent snapshot while the writer goes on.
|
||||
pub fn integrity_check(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("PRAGMA integrity_check")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut problems = Vec::new();
|
||||
@@ -107,9 +112,11 @@ impl Db {
|
||||
Ok(problems)
|
||||
}
|
||||
|
||||
/// Size and row-count snapshot for `pdfs cache inspect`.
|
||||
/// Size and row-count snapshot for `pdfs cache inspect`. On a read
|
||||
/// connection, for the reason [`integrity_check`](Self::integrity_check)
|
||||
/// gives: counting the rows of every table reads most of the file.
|
||||
pub fn stats(&self) -> Result<DbStats> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
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))?;
|
||||
@@ -218,6 +225,40 @@ mod tests {
|
||||
assert!(db.integrity_check().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// `pdfs diagnose` and `pdfs cache inspect` read the whole file. Behind the
|
||||
/// write lock they froze every write in the daemon for as long as that
|
||||
/// took (`docs/BUGS.md` B218).
|
||||
#[test]
|
||||
fn the_integrity_check_and_stats_do_not_wait_for_the_writer() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-check-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let db = std::sync::Arc::new(Db::open(&path).unwrap());
|
||||
let writer = db.conn.lock();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let reader = std::sync::Arc::clone(&db);
|
||||
std::thread::spawn(move || {
|
||||
let checked = reader.integrity_check().map(|p| p.is_empty());
|
||||
let counted = reader.stats().map(|s| s.page_count > 0);
|
||||
let _ = tx.send((checked.ok(), counted.ok()));
|
||||
});
|
||||
let answer = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
drop(writer);
|
||||
assert_eq!(answer.ok(), Some((Some(true), Some(true))));
|
||||
drop(db);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut file = path.as_os_str().to_owned();
|
||||
file.push(suffix);
|
||||
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
|
||||
}
|
||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_report_the_schema_and_the_known_tables() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 42;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 44;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -440,6 +440,33 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V42)?;
|
||||
}
|
||||
}
|
||||
if current < 43 {
|
||||
let has_ops: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_ops {
|
||||
tx.execute_batch(MIGRATION_V43)?;
|
||||
}
|
||||
}
|
||||
if current < 44 {
|
||||
// Same guards as V26-V42.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||
WHERE name = 'remote_revision_id'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_entries: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_entries && !has_column {
|
||||
tx.execute_batch(MIGRATION_V44)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -1330,3 +1357,28 @@ const MIGRATION_V42: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||
ALTER TABLE pending_op ADD COLUMN sent_to TEXT;
|
||||
";
|
||||
|
||||
/// Schema v43: queued ops by kind and name.
|
||||
///
|
||||
/// Before each create, rename and trash the drain asks whether a queued trash
|
||||
/// or rename holds the name it wants, and a released name wakes the ops
|
||||
/// waiting for it. Each of those scanned the whole queue, so a large offline
|
||||
/// batch made draining it quadratic. The kind leads, so a lookup of every
|
||||
/// queued rename uses the index too.
|
||||
const MIGRATION_V43: &str = "
|
||||
CREATE INDEX IF NOT EXISTS pending_op_kind_name ON pending_op(kind, name);
|
||||
";
|
||||
|
||||
/// Schema v44: a synced file's baseline remembers the revision it was synced
|
||||
/// at.
|
||||
///
|
||||
/// A mirrored file edited here is uploaded as a new revision of whatever is
|
||||
/// current on Drive, and another computer syncing the same folder may have
|
||||
/// committed one since this pass looked. The revision id is what the upload
|
||||
/// checks first, so that edit becomes a conflict copy instead of being
|
||||
/// replaced (B249). `NULL` means "this row predates the column", and the check
|
||||
/// falls back to the modification time and size; each row gains a real value
|
||||
/// the next time its path is synced.
|
||||
const MIGRATION_V44: &str = "
|
||||
ALTER TABLE sync_entry ADD COLUMN remote_revision_id TEXT;
|
||||
";
|
||||
+163
-15
@@ -179,6 +179,45 @@ impl Drop for Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The write connection with `synchronous=FULL`, back to NORMAL on drop.
|
||||
///
|
||||
/// NORMAL in WAL mode keeps a commit across a crash of the process but not
|
||||
/// across a power loss: the WAL is synced only at checkpoints. That is fine for
|
||||
/// metadata the next listing re-fetches, and not for the queue. A staged blob
|
||||
/// is synced before its op is queued, and the op row is what makes it
|
||||
/// reachable; losing that row loses a write `close()` already reported as
|
||||
/// done. A FULL commit syncs the WAL, which covers every NORMAL commit before
|
||||
/// it as well.
|
||||
pub(super) struct Durable<'a>(parking_lot::MutexGuard<'a, Connection>);
|
||||
|
||||
impl<'a> Durable<'a> {
|
||||
pub(super) fn new(conn: parking_lot::MutexGuard<'a, Connection>) -> Result<Self> {
|
||||
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||
Ok(Self(conn))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Durable<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Connection {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for Durable<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Connection {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Durable<'_> {
|
||||
fn drop(&mut self) {
|
||||
// A failure leaves the writer at FULL: slower, never less safe.
|
||||
let _ = self.0.pragma_update(None, "synchronous", "NORMAL");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a second connection to the same file for reads only.
|
||||
///
|
||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||
@@ -216,18 +255,34 @@ impl Db {
|
||||
let conn = match Self::open_configured(path) {
|
||||
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.
|
||||
// Most of what is in here is derived from the account and is
|
||||
// fetched again. The queue is not: it holds writes accepted
|
||||
// here that Drive does not have yet, and the mirror baselines
|
||||
// and pins are this machine's alone. So the damaged file is
|
||||
// moved aside, kept as evidence, and those tables are copied
|
||||
// out of it into the new one as far as SQLite can still read
|
||||
// them (`docs/BUGS.md` B216).
|
||||
tracing::error!(
|
||||
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)"
|
||||
"cache database is corrupt; moving it aside, rebuilding, and \
|
||||
copying the upload queue, sync baselines and pins out of it"
|
||||
);
|
||||
quarantine_corrupt_db(path)?;
|
||||
Self::open_configured(path)?
|
||||
let damaged = quarantine_corrupt_db(path)?;
|
||||
let conn = Self::open_configured(path)?;
|
||||
for (table, result) in salvage(&conn, &damaged) {
|
||||
match result {
|
||||
Ok(rows) => {
|
||||
tracing::warn!(table, rows, "salvaged from the damaged database")
|
||||
}
|
||||
Err(error) => tracing::error!(
|
||||
table,
|
||||
%error,
|
||||
"could not salvage from the damaged database"
|
||||
),
|
||||
}
|
||||
}
|
||||
conn
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -254,6 +309,12 @@ impl Db {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// The write connection for a commit that accepts a user's change into
|
||||
/// the queue. See [`Durable`].
|
||||
pub(super) fn durable(&self) -> Result<Durable<'_>> {
|
||||
Durable::new(self.conn.lock())
|
||||
}
|
||||
|
||||
/// A connection for a `SELECT`-only method.
|
||||
///
|
||||
/// Reads see the last committed state, which is the same thing they saw
|
||||
@@ -281,7 +342,8 @@ impl Db {
|
||||
fn open_configured(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
// standard durability/throughput tradeoff for WAL; the queue commits
|
||||
// that need more raise it to FULL for themselves (`Durable`).
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
@@ -428,12 +490,96 @@ fn is_corrupt(e: &Error) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty.
|
||||
/// The tables [`salvage`] copies out of a damaged database, in order, each
|
||||
/// with the rows it keeps.
|
||||
///
|
||||
/// 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<()> {
|
||||
/// The queue goes with the node rows it names by local id: a create made here
|
||||
/// has no other record of its node, and a real node keeps its local id when its
|
||||
/// folder is listed again. Those rows come back unlisted, so their folders are
|
||||
/// fetched again rather than trusted. Claims are cleared by [`Db::open`] as
|
||||
/// after any other crash.
|
||||
const SALVAGE: &[(&str, &str)] = &[
|
||||
(
|
||||
"nodes",
|
||||
"lid IN (SELECT lid FROM old.pending_op WHERE lid IS NOT NULL
|
||||
UNION SELECT parent_lid FROM old.pending_op WHERE parent_lid IS NOT NULL)
|
||||
OR uid LIKE 'local~%'",
|
||||
),
|
||||
("pending_op", "1"),
|
||||
("sync_folder", "1"),
|
||||
("sync_entry", "1"),
|
||||
("pins", "1"),
|
||||
("own_sealed_rev", "1"),
|
||||
];
|
||||
|
||||
/// Copy what only this machine has out of the damaged database at `damaged`
|
||||
/// into the fresh one `conn` just created. Returns how each table went.
|
||||
///
|
||||
/// Each table is copied in its own transaction, so a table SQLite cannot read
|
||||
/// to the end is left out whole rather than half copied, and does not stop
|
||||
/// the others. Only the columns both files have are copied, which covers a
|
||||
/// file that broke in the middle of an upgrade. The damaged file is opened read
|
||||
/// only and left as it was.
|
||||
fn salvage(conn: &Connection, damaged: &Path) -> Vec<(&'static str, Result<usize>)> {
|
||||
let uri = format!(
|
||||
"file:{}?mode=ro",
|
||||
damaged
|
||||
.to_string_lossy()
|
||||
.replace('%', "%25")
|
||||
.replace('?', "%3f")
|
||||
.replace('#', "%23")
|
||||
);
|
||||
if let Err(e) = conn.execute("ATTACH DATABASE ?1 AS old", [&uri]) {
|
||||
let e = Error::Db(e);
|
||||
tracing::error!(error = %e, "could not open the damaged database to salvage it");
|
||||
return Vec::new();
|
||||
}
|
||||
let results = SALVAGE
|
||||
.iter()
|
||||
.map(|&(table, rows)| (table, salvage_table(conn, table, rows)))
|
||||
.collect();
|
||||
let _ = conn.execute("UPDATE nodes SET listed = 0", []);
|
||||
if let Err(e) = conn.execute("DETACH DATABASE old", []) {
|
||||
tracing::warn!(error = %e, "could not detach the damaged database");
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
fn salvage_table(conn: &Connection, table: &str, rows: &str) -> Result<usize> {
|
||||
let columns = |schema: &str| -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA {schema}.table_info({table})"))?;
|
||||
let names = stmt
|
||||
.query_map([], |r| r.get::<_, String>(1))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(names)
|
||||
};
|
||||
let new = columns("main")?;
|
||||
let shared: Vec<String> = columns("old")?
|
||||
.into_iter()
|
||||
.filter(|c| new.contains(c))
|
||||
.map(|c| format!("\"{c}\""))
|
||||
.collect();
|
||||
if shared.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let shared = shared.join(", ");
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
let copied = tx.execute(
|
||||
&format!(
|
||||
"INSERT OR IGNORE INTO main.{table} ({shared}) SELECT {shared} FROM old.{table} WHERE {rows}"
|
||||
),
|
||||
[],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty. Returns where the database itself went.
|
||||
///
|
||||
/// Renamed rather than deleted: it is the only evidence of what went wrong,
|
||||
/// and [`salvage`] reads what only this machine has back out of it.
|
||||
fn quarantine_corrupt_db(path: &Path) -> Result<PathBuf> {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@@ -457,7 +603,9 @@ fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
let mut kept = path.as_os_str().to_owned();
|
||||
kept.push(format!(".corrupt-{stamp}"));
|
||||
Ok(PathBuf::from(kept))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+156
-37
@@ -252,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
|
||||
const PARENT_NOW: &str =
|
||||
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
|
||||
|
||||
/// The `WHERE` terms for a row a drain worker may claim once it falls due: not
|
||||
/// claimed already, no other claimed op on the same node, and not under a
|
||||
/// folder that has yet to land.
|
||||
///
|
||||
/// One fragment for [`Db::claim_next_due_op`] and [`Db::earliest_due_at`]. A
|
||||
/// row the claim skips but the wait counts is due "now" for as long as it is
|
||||
/// held, and every idle worker spun on it (`docs/BUGS.md` B200).
|
||||
fn claimable() -> String {
|
||||
format!(
|
||||
"claimed_at = 0
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
|
||||
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||
AND (lid IS NULL OR lid NOT IN (
|
||||
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
)
|
||||
}
|
||||
|
||||
/// The volume id given to a node that exists only on this machine, so far. A
|
||||
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
|
||||
/// round-trips through the same `Display`/parse path as any other uid.
|
||||
@@ -260,6 +279,45 @@ const PARENT_NOW: &str =
|
||||
/// drain replaces it with the uid the server assigns.
|
||||
pub const LOCAL_VOLUME: &str = "local";
|
||||
|
||||
/// One place a queued create was sent, with when it first was.
|
||||
struct SentTarget {
|
||||
parent: String,
|
||||
name: String,
|
||||
at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// The `sent_to` column: `[[parent, name, at_ms], …]`, or `[[parent, name], …]`
|
||||
/// as written before the time was kept. `None` when it does not parse.
|
||||
fn parse_sent_to(json: Option<&str>) -> Option<Vec<SentTarget>> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Entry {
|
||||
At(String, String, i64),
|
||||
Bare(String, String),
|
||||
}
|
||||
let Some(json) = json else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
let entries: Vec<Entry> = serde_json::from_str(json).ok()?;
|
||||
Some(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
Entry::At(parent, name, at) => SentTarget {
|
||||
parent,
|
||||
name,
|
||||
at_ms: Some(at),
|
||||
},
|
||||
Entry::Bare(parent, name) => SentTarget {
|
||||
parent,
|
||||
name,
|
||||
at_ms: None,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The uid a node made on this machine goes by until Drive gives it one: its
|
||||
/// row's local id under [`LOCAL_VOLUME`].
|
||||
///
|
||||
@@ -522,7 +580,7 @@ impl Db {
|
||||
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
|
||||
/// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it.
|
||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||
let mut conn = self.conn.lock();
|
||||
let mut conn = self.durable()?;
|
||||
let tx = conn.transaction()?;
|
||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||
let lid = node_lid(&tx, &op.uid)?;
|
||||
@@ -579,7 +637,7 @@ impl Db {
|
||||
name: &str,
|
||||
created_at: i64,
|
||||
) -> Result<(i64, Vec<String>)> {
|
||||
let mut conn = self.conn.lock();
|
||||
let mut conn = self.durable()?;
|
||||
let tx = conn.transaction()?;
|
||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||
tx.execute(
|
||||
@@ -609,7 +667,7 @@ impl Db {
|
||||
blob_path: &str,
|
||||
meta_json: &str,
|
||||
) -> Result<Option<AttachedBlob>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.durable()?;
|
||||
let lid = node_lid(&conn, uid)?;
|
||||
let existing: Option<(i64, Option<String>)> = conn
|
||||
.query_row(
|
||||
@@ -672,7 +730,7 @@ impl Db {
|
||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||
) -> Result<CreateRetired> {
|
||||
let (local, real) = (landing.local, landing.real);
|
||||
let mut conn = self.conn.lock();
|
||||
let mut conn = self.durable()?;
|
||||
let tx = conn.transaction()?;
|
||||
let row: Option<CreateRow> = tx
|
||||
.query_row(
|
||||
@@ -789,12 +847,19 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record that queued create `id` is being sent to `parent` under `name`.
|
||||
/// Record that queued create `id` is being sent to `parent` under `name`
|
||||
/// at `at_ms`.
|
||||
///
|
||||
/// A rename while the op waits rewrites its target, but the file a lost
|
||||
/// answer left stays where it was sent. The retry and the trash look
|
||||
/// there too (`docs/BUGS.md` B184).
|
||||
pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> {
|
||||
/// there too (`docs/BUGS.md` B184). A target sent before keeps its first
|
||||
/// time.
|
||||
///
|
||||
/// A record that does not parse is left as it is rather than replaced
|
||||
/// with this one target: the earlier ones are what the duplicate checks
|
||||
/// look for. Without them a lost answer forks a conflict copy, which
|
||||
/// loses nothing.
|
||||
pub fn note_create_target(&self, id: i64, parent: &str, name: &str, at_ms: i64) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let sent: Option<String> = tx
|
||||
@@ -803,15 +868,26 @@ impl Db {
|
||||
})
|
||||
.optional()?
|
||||
.flatten();
|
||||
let mut targets: Vec<(String, String)> = sent
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default();
|
||||
let target = (parent.to_string(), name.to_string());
|
||||
if !targets.contains(&target) {
|
||||
targets.push(target);
|
||||
let Some(mut targets) = parse_sent_to(sent.as_deref()) else {
|
||||
tracing::warn!(
|
||||
id,
|
||||
"a queued create's send record does not parse; keeping it"
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
if !targets.iter().any(|t| t.parent == parent && t.name == name) {
|
||||
targets.push(SentTarget {
|
||||
parent: parent.to_string(),
|
||||
name: name.to_string(),
|
||||
at_ms: Some(at_ms),
|
||||
});
|
||||
let json: Vec<(&str, &str, i64)> = targets
|
||||
.iter()
|
||||
.map(|t| (t.parent.as_str(), t.name.as_str(), t.at_ms.unwrap_or(0)))
|
||||
.collect();
|
||||
tx.execute(
|
||||
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
||||
params![id, serde_json::to_string(&targets)?],
|
||||
params![id, serde_json::to_string(&json)?],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -821,6 +897,24 @@ impl Db {
|
||||
/// The parent and name queued op `id` was sent under as a create, in the
|
||||
/// order it was ([`Db::note_create_target`]).
|
||||
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
|
||||
Ok(self
|
||||
.sent_targets(id)?
|
||||
.into_iter()
|
||||
.map(|t| (t.parent, t.name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// When queued create `id` was first sent, if that was recorded. Rows
|
||||
/// written before the time was kept have none.
|
||||
pub fn create_first_sent(&self, id: i64) -> Result<Option<i64>> {
|
||||
Ok(self
|
||||
.sent_targets(id)?
|
||||
.into_iter()
|
||||
.filter_map(|t| t.at_ms.filter(|&at| at > 0))
|
||||
.min())
|
||||
}
|
||||
|
||||
fn sent_targets(&self, id: i64) -> Result<Vec<SentTarget>> {
|
||||
let sent: Option<String> = self
|
||||
.conn
|
||||
.lock()
|
||||
@@ -829,9 +923,7 @@ impl Db {
|
||||
})
|
||||
.optional()?
|
||||
.flatten();
|
||||
Ok(sent
|
||||
.and_then(|json| serde_json::from_str(&json).ok())
|
||||
.unwrap_or_default())
|
||||
Ok(parse_sent_to(sent.as_deref()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The SHA-1 of each blob queued op `id` sent as a create
|
||||
@@ -879,6 +971,27 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Replace the sidecar of op `id`, but only while it still holds the blob
|
||||
/// at `blob_path`.
|
||||
///
|
||||
/// The drain fills an incomplete blob in place and then records it as
|
||||
/// whole, so a retry after a lost answer can recognise its own bytes on the
|
||||
/// server. A write that closed meanwhile has pointed the row at a newer
|
||||
/// blob with its own sidecar; that one must not be overwritten. Returns
|
||||
/// false when the row is gone or holds another blob.
|
||||
pub fn update_op_meta_for_blob(
|
||||
&self,
|
||||
id: i64,
|
||||
blob_path: &str,
|
||||
meta_json: &str,
|
||||
) -> Result<bool> {
|
||||
let n = self.conn.lock().execute(
|
||||
"UPDATE pending_op SET meta_json = ?3 WHERE id = ?1 AND blob_path = ?2",
|
||||
params![id, blob_path, meta_json],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Point a queued create at a new parent and name, for a node renamed or
|
||||
/// moved before it ever reached the server.
|
||||
///
|
||||
@@ -892,7 +1005,7 @@ impl Db {
|
||||
/// name it no longer wants, such as one held by a queued change. A parked
|
||||
/// create stays parked.
|
||||
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.durable()?;
|
||||
let lid = node_lid(&conn, uid)?;
|
||||
let parent_lid = node_lid(&conn, parent_uid)?;
|
||||
let n = conn.execute(
|
||||
@@ -1127,15 +1240,9 @@ impl Db {
|
||||
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
FROM pending_op
|
||||
WHERE next_attempt_at <= ?1
|
||||
AND claimed_at = 0
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')
|
||||
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||
AND (lid IS NULL OR lid NOT IN (
|
||||
SELECT lid FROM pending_op WHERE claimed_at <> 0 AND lid IS NOT NULL))
|
||||
WHERE next_attempt_at <= ?1 AND {claimable}
|
||||
ORDER BY id LIMIT 1",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
claimable = claimable(),
|
||||
))?;
|
||||
stmt.query_row(params![now], |r| {
|
||||
Ok(PendingOp {
|
||||
@@ -1208,19 +1315,17 @@ impl Db {
|
||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||
/// idle-poll interval.
|
||||
///
|
||||
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||
/// not work this one is waiting for, and counting them would have an idle
|
||||
/// worker spin on a row it cannot have.
|
||||
/// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
|
||||
/// count. A claimed op, or one held behind a claimed op on the same node,
|
||||
/// is not work this worker is waiting for, and counting it would have an
|
||||
/// idle worker spin on a row it cannot have.
|
||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||
WHERE claimed_at = 0 \
|
||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
|
||||
v = LOCAL_VOLUME,
|
||||
n = LOCAL_VOLUME.len() + 1,
|
||||
"SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
|
||||
claimable = claimable(),
|
||||
),
|
||||
[],
|
||||
|r| r.get(0),
|
||||
@@ -1378,12 +1483,26 @@ impl Db {
|
||||
}
|
||||
|
||||
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||
/// least once. Returns how many rows changed.
|
||||
/// least once, for the user's "Retry all". Returns how many rows changed.
|
||||
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||
self.retry_ops_now(now, "")
|
||||
}
|
||||
|
||||
/// [`retry_failed_ops_now`](Self::retry_failed_ops_now) for the ops Drive
|
||||
/// did not refuse, for a reconnect. A link coming back changes nothing
|
||||
/// about a full quota or a missing permission, so those keep their backoff
|
||||
/// instead of all being sent again at once.
|
||||
pub fn retry_unrefused_ops_now(&self, now: i64) -> Result<usize> {
|
||||
self.retry_ops_now(now, "AND issue IS NULL")
|
||||
}
|
||||
|
||||
fn retry_ops_now(&self, now: i64, filter: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let changed = conn.execute(
|
||||
"UPDATE pending_op SET next_attempt_at = ?1
|
||||
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||
&format!(
|
||||
"UPDATE pending_op SET next_attempt_at = ?1
|
||||
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2 {filter}"
|
||||
),
|
||||
params![now, PARK_UNTIL],
|
||||
)?;
|
||||
Ok(changed)
|
||||
|
||||
@@ -45,6 +45,12 @@ pub struct StoredSyncEntry {
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
pub remote_rev: Option<String>,
|
||||
pub remote_hash: Option<String>,
|
||||
/// The id of the remote revision this row was synced at. A mirror upload
|
||||
/// checks it before it supersedes what Drive holds, because another client
|
||||
/// may have committed a revision since (`docs/BUGS.md` B249). `None` on a
|
||||
/// row written before schema 44, or by a pass that did not learn it; the
|
||||
/// check then falls back to `remote_rev` and `remote_hash`.
|
||||
pub remote_revision_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
@@ -139,6 +145,17 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-point a synced folder at the share that now holds its remote folder,
|
||||
/// after the folder moved to another device without changing its uid.
|
||||
pub fn sync_folder_set_share(&self, id: i64, share_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE sync_folder SET remote_share_id = ?2 WHERE id = ?1",
|
||||
params![id, share_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||
/// is safe to switch.
|
||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
||||
@@ -177,7 +194,7 @@ impl Db {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
local_mtime_ns, remote_revision_id
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
@@ -189,6 +206,7 @@ impl Db {
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
remote_revision_id: r.get(7)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
@@ -227,15 +245,16 @@ impl Db {
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
remote_hash, local_mtime_ns, remote_revision_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns",
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns,
|
||||
remote_revision_id = excluded.remote_revision_id",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
@@ -245,6 +264,7 @@ impl Db {
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
e.local_mtime_ns,
|
||||
e.remote_revision_id,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -295,7 +315,7 @@ impl Db {
|
||||
{
|
||||
let mut select = tx.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
local_mtime_ns, remote_revision_id
|
||||
FROM sync_entry
|
||||
WHERE folder_id = ?1
|
||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
||||
@@ -310,6 +330,7 @@ impl Db {
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
remote_revision_id: r.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -321,8 +342,8 @@ impl Db {
|
||||
let mut insert = tx.prepare(
|
||||
"INSERT OR REPLACE INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
remote_hash, local_mtime_ns, remote_revision_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for row in rows {
|
||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||
@@ -335,6 +356,7 @@ impl Db {
|
||||
row.remote_rev,
|
||||
row.remote_hash,
|
||||
row.local_mtime_ns,
|
||||
row.remote_revision_id,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,6 +1283,90 @@ fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||
}
|
||||
|
||||
/// A rebuilt database must not lose what only this machine has: writes Drive
|
||||
/// has not received, the mirror baselines and the pins (`docs/BUGS.md` B216).
|
||||
#[test]
|
||||
fn a_rebuild_keeps_the_queue_baselines_and_pins_of_the_damaged_file() {
|
||||
let id = now_test_id();
|
||||
let damaged =
|
||||
std::env::temp_dir().join(format!("pdfs-salvage-old-{}-{id}.db", std::process::id()));
|
||||
let fresh =
|
||||
std::env::temp_dir().join(format!("pdfs-salvage-new-{}-{id}.db", std::process::id()));
|
||||
|
||||
let db = Db::open(&damaged).unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&file("listed", "root", "listed.txt", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("queued", "root", "queued.txt", 2))
|
||||
.unwrap();
|
||||
db.set_listed(&uid("root"), true).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: uid("queued").to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some("/staging/queued".into()),
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 3,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let folder_id = db
|
||||
.sync_folder_add("/home/me/Docs", "vol~docs", "share")
|
||||
.unwrap();
|
||||
db.sync_entry_upsert(
|
||||
folder_id,
|
||||
&StoredSyncEntry {
|
||||
rel_path: "a.txt".into(),
|
||||
remote_uid: Some("vol~a".into()),
|
||||
local_mtime: 5,
|
||||
local_mtime_ns: None,
|
||||
local_size: 6,
|
||||
remote_rev: Some("7".into()),
|
||||
remote_hash: Some("8".into()),
|
||||
remote_revision_id: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
db.pin_add("vol~pinned", "/pinned", true).unwrap();
|
||||
drop(db);
|
||||
|
||||
let db = Db::open(&fresh).unwrap();
|
||||
let results = salvage(&db.conn.lock(), &damaged);
|
||||
assert!(results.iter().all(|(_, r)| r.is_ok()), "{results:?}");
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
assert_eq!(ops[0].uid, uid("queued").to_string());
|
||||
assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/queued"));
|
||||
// The op's node came with it; the rest of the tree is fetched again.
|
||||
assert!(
|
||||
db.node_by_uid(&uid("queued").to_string())
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
db.node_by_uid(&uid("listed").to_string())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(db.sync_entries(folder_id).unwrap().len(), 1);
|
||||
assert!(db.is_pinned("vol~pinned").unwrap());
|
||||
drop(db);
|
||||
|
||||
for path in [&damaged, &fresh] {
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut file = path.as_os_str().to_owned();
|
||||
file.push(suffix);
|
||||
let _ = std::fs::remove_file(std::path::PathBuf::from(file));
|
||||
}
|
||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||
}
|
||||
}
|
||||
|
||||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||
LocalEntry {
|
||||
path: path.into(),
|
||||
@@ -1346,6 +1430,77 @@ fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan that stopped early or lost a batch did not see every file, so it
|
||||
/// must not prune the ones it missed (`docs/BUGS.md` B217).
|
||||
#[test]
|
||||
fn a_partial_local_scan_prunes_nothing() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let g = db.local_begin_scan().unwrap();
|
||||
db.local_upsert_batch(
|
||||
g,
|
||||
&[
|
||||
local("/home/u/a.txt", "a.txt", false),
|
||||
local("/home/u/b.txt", "b.txt", false),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(db.local_finish_scan(g, 1).unwrap(), 2);
|
||||
|
||||
let g = db.local_begin_scan().unwrap();
|
||||
db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)])
|
||||
.unwrap();
|
||||
assert_eq!(db.local_finish_partial_scan(2).unwrap(), 2);
|
||||
assert_eq!(db.search_local("b.txt", 10).unwrap().len(), 1);
|
||||
assert_eq!(db.local_indexed_at().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
/// Deleted entries pile up in an FTS5 index until it is compacted. The daily
|
||||
/// compaction must leave both indexes whole and searchable, and must not run
|
||||
/// again the same day (`docs/BUGS.md` B217).
|
||||
#[test]
|
||||
fn compacting_the_search_indexes_keeps_them_whole_and_runs_once_a_day() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&file("f1", "root", "quarterly-report.pdf", 1))
|
||||
.unwrap();
|
||||
for round in 0..20 {
|
||||
let g = db.local_begin_scan().unwrap();
|
||||
let name = format!("churn-{round}.txt");
|
||||
db.local_upsert_batch(
|
||||
g,
|
||||
&[
|
||||
local("/home/u/keep.txt", "keep.txt", false),
|
||||
local(&format!("/home/u/{name}"), &name, false),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
// Far in the past, so no scan compacts on its own.
|
||||
db.local_finish_scan(g, 0).unwrap();
|
||||
}
|
||||
|
||||
let day = 24 * 60 * 60;
|
||||
db.compact_search_indexes_if_due(day).unwrap();
|
||||
{
|
||||
let conn = db.conn.lock();
|
||||
for table in ["local_fts", "nodes_fts"] {
|
||||
conn.execute(
|
||||
&format!("INSERT INTO {table}({table}) VALUES('integrity-check')"),
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(db.search_local("keep", 10).unwrap().len(), 1);
|
||||
assert_eq!(db.search_local("churn-19", 10).unwrap().len(), 1);
|
||||
assert!(db.search_local("churn-3.txt", 10).unwrap().is_empty());
|
||||
assert_eq!(db.search("quarterly", 10).unwrap().len(), 1);
|
||||
let stamp = |db: &Db| db.state_str("fts_compacted_at").unwrap();
|
||||
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
|
||||
|
||||
db.compact_search_indexes_if_due(day + 60).unwrap();
|
||||
assert_eq!(stamp(&db).as_deref(), Some(day.to_string().as_str()));
|
||||
}
|
||||
|
||||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||
#[test]
|
||||
fn local_search_short_query_like_fallback() {
|
||||
@@ -1689,9 +1844,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
|
||||
.collect();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
|
||||
let t = Instant::now();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
t.elapsed()
|
||||
// The fastest of a few runs: the rest of the suite runs alongside, and
|
||||
// one run that lost the CPU to it read as the regression.
|
||||
(0..5)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
db.upsert_nodes(&batch).unwrap();
|
||||
t.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
let small = cost_against_index_of(200);
|
||||
@@ -2191,6 +2353,100 @@ fn refuses_a_schema_newer_than_the_running_build() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// Queueing a write syncs it to disk, and leaves the writer at NORMAL after.
|
||||
///
|
||||
/// The op row is what makes a staged blob reachable once `close()` has
|
||||
/// reported success, so its commit must survive a power loss. Everything else
|
||||
/// the writer commits is metadata the next listing re-fetches, and pays only
|
||||
/// NORMAL's cost.
|
||||
#[test]
|
||||
fn queueing_a_write_commits_at_full_sync_and_restores_normal() {
|
||||
let path = std::env::temp_dir().join(format!("pdfs-db-sync-{}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let db = Db::open(&path).unwrap();
|
||||
let level = |db: &Db| -> i64 {
|
||||
db.conn
|
||||
.lock()
|
||||
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||
.unwrap()
|
||||
};
|
||||
// 1 = NORMAL, 2 = FULL.
|
||||
assert_eq!(level(&db), 1);
|
||||
{
|
||||
let durable = db.durable().unwrap();
|
||||
let during: i64 = durable
|
||||
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(during, 2);
|
||||
}
|
||||
assert_eq!(level(&db), 1);
|
||||
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_TRASH.to_string(),
|
||||
uid: uid("a").to_string(),
|
||||
parent_uid: None,
|
||||
name: Some("a".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(level(&db), 1);
|
||||
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||
drop(db);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A gap-filled blob is recorded as whole only while the op still holds it.
|
||||
///
|
||||
/// A write that closes during the drain points the row at a newer blob with
|
||||
/// its own sidecar. Overwriting that sidecar with the older blob's would
|
||||
/// describe bytes the row no longer holds.
|
||||
#[test]
|
||||
fn marking_a_blob_whole_leaves_a_newer_blob_alone() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let op = PendingOp {
|
||||
id: 0,
|
||||
kind: OP_REVISION.to_string(),
|
||||
uid: uid("a").to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: Some("/staging/first".to_string()),
|
||||
meta_json: Some(r#"{"complete":false}"#.to_string()),
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
};
|
||||
db.enqueue_op(&op).unwrap();
|
||||
let id = db.pending_ops().unwrap()[0].id;
|
||||
|
||||
assert!(
|
||||
db.update_op_meta_for_blob(id, "/staging/first", r#"{"complete":true}"#)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
db.pending_ops().unwrap()[0].meta_json.as_deref(),
|
||||
Some(r#"{"complete":true}"#)
|
||||
);
|
||||
|
||||
assert!(
|
||||
!db.update_op_meta_for_blob(id, "/staging/older", r#"{"stale":true}"#)
|
||||
.unwrap(),
|
||||
"the row holds another blob"
|
||||
);
|
||||
assert_eq!(
|
||||
db.pending_ops().unwrap()[0].meta_json.as_deref(),
|
||||
Some(r#"{"complete":true}"#)
|
||||
);
|
||||
}
|
||||
|
||||
/// A queued write whose baseline is restamped must keep everything else.
|
||||
///
|
||||
/// The restamp happens after our *own* upload seals a new revision under a
|
||||
@@ -3920,15 +4176,15 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
|
||||
.unwrap();
|
||||
assert!(db.create_sent(id).unwrap().is_empty());
|
||||
assert!(db.create_targets(id).unwrap().is_empty());
|
||||
db.note_create_target(id, &parent, "c").unwrap();
|
||||
db.note_create_target(id, &parent, "c").unwrap();
|
||||
db.note_create_target(id, &parent, "c", 10).unwrap();
|
||||
db.note_create_target(id, &parent, "c", 20).unwrap();
|
||||
db.note_create_sent(id, "aaaa").unwrap();
|
||||
db.note_create_sent(id, "aaaa").unwrap();
|
||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||||
.unwrap()
|
||||
.expect("the create is still queued");
|
||||
db.note_create_target(id, &parent, "d").unwrap();
|
||||
db.note_create_target(id, &parent, "d", 30).unwrap();
|
||||
db.note_create_sent(id, "bbbb").unwrap();
|
||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||
db.delete_ops_for_uid("local~sent").unwrap();
|
||||
@@ -3951,6 +4207,73 @@ fn what_a_create_sent_outlives_a_newer_blob_its_trash_and_a_restart() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// A queued create knows when it first went out, which is where the window
|
||||
/// for adopting a node its lost answer left starts. Records written before
|
||||
/// the time was kept still list their targets, and one that does not parse is
|
||||
/// not replaced by the next target.
|
||||
#[test]
|
||||
fn a_create_remembers_when_it_was_first_sent() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let parent = uid("parent").to_string();
|
||||
let (id, _) = db
|
||||
.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: "local~first".to_string(),
|
||||
parent_uid: Some(parent.clone()),
|
||||
name: Some("a.txt".to_string()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(db.create_first_sent(id).unwrap(), None, "never sent");
|
||||
db.note_create_target(id, &parent, "a.txt", 500).unwrap();
|
||||
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
|
||||
db.note_create_target(id, &parent, "b.txt", 700).unwrap();
|
||||
assert_eq!(db.create_first_sent(id).unwrap(), Some(500));
|
||||
|
||||
let set = |json: &str| {
|
||||
db.conn
|
||||
.lock()
|
||||
.execute(
|
||||
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
||||
rusqlite::params![id, json],
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
set(&format!(r#"[["{parent}","old.txt"]]"#));
|
||||
assert_eq!(
|
||||
db.create_targets(id).unwrap(),
|
||||
vec![(parent.clone(), "old.txt".to_string())]
|
||||
);
|
||||
assert_eq!(db.create_first_sent(id).unwrap(), None);
|
||||
db.note_create_target(id, &parent, "a.txt", 900).unwrap();
|
||||
assert_eq!(
|
||||
db.create_targets(id).unwrap().len(),
|
||||
2,
|
||||
"the old target stays"
|
||||
);
|
||||
assert_eq!(db.create_first_sent(id).unwrap(), Some(900));
|
||||
|
||||
set("not json");
|
||||
db.note_create_target(id, &parent, "c.txt", 1000).unwrap();
|
||||
let raw: String = db
|
||||
.conn
|
||||
.lock()
|
||||
.query_row("SELECT sent_to FROM pending_op WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
raw, "not json",
|
||||
"an unreadable record is kept, not replaced"
|
||||
);
|
||||
}
|
||||
|
||||
/// A write after a create failed clears its attempts, so the next try goes
|
||||
/// out at once. The create was still sent: deleting the file then left no
|
||||
/// trash, and the file a lost answer made stayed on Drive under the name it
|
||||
@@ -3974,7 +4297,7 @@ fn a_create_rewritten_after_it_failed_still_leaves_a_trash_when_deleted() {
|
||||
next_attempt_at: 0,
|
||||
})
|
||||
.unwrap();
|
||||
db.note_create_target(id, &parent, "a.txt").unwrap();
|
||||
db.note_create_target(id, &parent, "a.txt", 10).unwrap();
|
||||
db.note_create_sent(id, "aaaa").unwrap();
|
||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||||
@@ -4940,6 +5263,28 @@ fn pending_mode_is_queued_until_the_mode_is_reached() {
|
||||
assert_eq!(folder.pending_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_sync_folder_points_at_its_new_share() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let id = db
|
||||
.sync_folder_add("/tmp/pdfs-moved-share", "vol~folder", "windows-share")
|
||||
.unwrap();
|
||||
let other = db
|
||||
.sync_folder_add("/tmp/pdfs-unmoved-share", "vol~other", "windows-share")
|
||||
.unwrap();
|
||||
|
||||
db.sync_folder_set_share(id, "linux-share").unwrap();
|
||||
|
||||
let folder = db.sync_folder_get(id).unwrap().unwrap();
|
||||
assert_eq!(folder.remote_share_id, "linux-share");
|
||||
// A move keeps the uid, so nothing but the share changes.
|
||||
assert_eq!(folder.remote_uid, "vol~folder");
|
||||
assert_eq!(
|
||||
db.sync_folder_get(other).unwrap().unwrap().remote_share_id,
|
||||
"windows-share"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -4999,6 +5344,7 @@ fn baseline_row(rel: &str) -> StoredSyncEntry {
|
||||
local_size: 7,
|
||||
remote_rev: Some("1700000000".into()),
|
||||
remote_hash: Some("7".into()),
|
||||
remote_revision_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5093,6 +5439,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
remote_revision_id: None,
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
@@ -5110,6 +5457,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
remote_revision_id: None,
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
@@ -6287,6 +6635,71 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
/// V44 adds the remote revision a baseline row was synced at. A row carried
|
||||
/// over from V43 has none, and the mirror then compares the modification time
|
||||
/// and size as before, so upgrading changes nothing about what is in step
|
||||
/// (`docs/BUGS.md` B249).
|
||||
#[test]
|
||||
fn migration_v44_adds_the_synced_revision_without_disturbing_a_v43_baseline() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v43-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let row = StoredSyncEntry {
|
||||
rel_path: "notes.txt".into(),
|
||||
remote_uid: Some("vol~notes".into()),
|
||||
local_mtime: 1_700_000_000,
|
||||
local_mtime_ns: Some(1_700_000_000_123_456_789),
|
||||
local_size: 42,
|
||||
remote_rev: Some("1700000000".into()),
|
||||
remote_hash: Some("42".into()),
|
||||
remote_revision_id: Some("rev-1".into()),
|
||||
};
|
||||
{
|
||||
let db = Db::open(&path).unwrap();
|
||||
let folder = db
|
||||
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
|
||||
.unwrap();
|
||||
db.sync_entry_upsert(folder, &row).unwrap();
|
||||
}
|
||||
{
|
||||
// Put the file back in the state a released V43 database was in.
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE sync_entry DROP COLUMN remote_revision_id;
|
||||
UPDATE sync_state SET value = '43' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
let entries = db.sync_entries(1).unwrap();
|
||||
let entry = entries.get("notes.txt").expect("the baseline row survives");
|
||||
assert_eq!(
|
||||
entry,
|
||||
&StoredSyncEntry {
|
||||
remote_revision_id: None,
|
||||
..row.clone()
|
||||
}
|
||||
);
|
||||
|
||||
// And the column is live, including through a move of the row.
|
||||
db.sync_entry_upsert(1, &row).unwrap();
|
||||
db.sync_entries_move(1, "notes.txt", 1, "moved.txt")
|
||||
.unwrap();
|
||||
let entries = db.sync_entries(1).unwrap();
|
||||
assert_eq!(
|
||||
entries["moved.txt"].remote_revision_id.as_deref(),
|
||||
Some("rev-1")
|
||||
);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
||||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||
/// comparison fall back to whole seconds for rows written before the column
|
||||
@@ -6317,6 +6730,7 @@ fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline()
|
||||
local_size: 42,
|
||||
remote_rev: Some("1700000000".into()),
|
||||
remote_hash: Some("42".into()),
|
||||
remote_revision_id: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -6525,6 +6939,36 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
||||
);
|
||||
}
|
||||
|
||||
/// B200: a rename queued behind a long upload of the same node is due, but no
|
||||
/// worker may claim it until the upload retires. Counted as due, it had every
|
||||
/// idle worker wake at once, find nothing, and go round again.
|
||||
#[test]
|
||||
fn earliest_due_at_skips_an_op_held_behind_a_claimed_one() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let root = uid("root").to_string();
|
||||
let mut revision = bulk_op(0, &root);
|
||||
revision.parent_uid = None;
|
||||
db.enqueue_op(&revision).unwrap();
|
||||
db.enqueue_op(&PendingOp {
|
||||
kind: OP_RENAME.to_string(),
|
||||
parent_uid: Some(root.clone()),
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
..bulk_op(0, &root)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
db.claim_next_due_op(10)
|
||||
.unwrap()
|
||||
.expect("the revision is due");
|
||||
assert!(db.claim_next_due_op(10).unwrap().is_none());
|
||||
assert_eq!(
|
||||
db.earliest_due_at().unwrap(),
|
||||
None,
|
||||
"a row no worker can claim is not work an idle worker is waiting for"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
||||
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||
#[test]
|
||||
@@ -7157,6 +7601,56 @@ fn a_refused_change_names_its_node_by_local_id() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The name checks the drain makes before each op look the name up in an
|
||||
/// index. Scanning the queue instead made a large offline batch quadratic.
|
||||
#[test]
|
||||
fn queue_name_checks_use_an_index() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let conn = db.conn.lock();
|
||||
let plan = |sql: &str| -> String {
|
||||
let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap();
|
||||
stmt.query_map([], |r| r.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.map(|row| row.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
for sql in [
|
||||
"SELECT 1 FROM pending_op WHERE kind = 'trash' AND name = 'a'",
|
||||
"SELECT 1 FROM pending_op WHERE name = 'a' AND kind IN ('create', 'mkdir', 'rename')",
|
||||
"SELECT 1 FROM pending_op WHERE kind = 'rename'",
|
||||
] {
|
||||
let plan = plan(sql);
|
||||
assert!(plan.contains("pending_op_kind_name"), "{sql}: {plan}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A reconnect moves up the ops that failed for want of a link. One Drive
|
||||
/// refused, for quota or access, keeps its backoff: the link coming back
|
||||
/// changes nothing about it. "Retry all" still moves both.
|
||||
#[test]
|
||||
fn a_reconnect_leaves_refused_ops_to_their_backoff() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let offline = queued_op(&db, "vol~a", 2, 90_000);
|
||||
let refused = queued_op(&db, "vol~b", 2, 90_000);
|
||||
db.set_op_issue(refused, Some("quota")).unwrap();
|
||||
|
||||
assert_eq!(db.retry_unrefused_ops_now(5_000).unwrap(), 1);
|
||||
let due = |id: i64| {
|
||||
db.pending_ops()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|op| op.id == id)
|
||||
.unwrap()
|
||||
.next_attempt_at
|
||||
};
|
||||
assert_eq!(due(offline), 5_000);
|
||||
assert_eq!(due(refused), 90_000);
|
||||
|
||||
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||||
assert_eq!(due(refused), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_all_touches_only_ops_that_have_failed() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
+135
-24
@@ -154,19 +154,20 @@ impl CoreError {
|
||||
/// through the boxes the drain deals in, where the concrete type survives
|
||||
/// but the static one does not.
|
||||
///
|
||||
/// A failure that is not an API response at all — a transport error, a
|
||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
||||
/// user their connection is down beats telling them the server refused.
|
||||
/// A transport error, a timeout or a TLS failure is what being offline
|
||||
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||
/// telling the user their connection is down beats telling them the
|
||||
/// server refused. The SDK's other failures say what went wrong instead
|
||||
/// (see [`proton_kind`]).
|
||||
///
|
||||
/// Callers also hand it this crate's [`Error`] from the cache and the
|
||||
/// database, which is not a transport error at all. Taking those for
|
||||
/// "offline" told the user their connection was down when a pin failed on
|
||||
/// the database (`docs/BUGS.md` B266), so the source chain is searched for
|
||||
/// either type before falling back.
|
||||
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that is not an `Api` variant never reached the
|
||||
// API; so does anything that is not a `ProtonError` at all.
|
||||
_ => ErrorKind::Offline,
|
||||
};
|
||||
Self::new(kind, format!("{what}: {e}"))
|
||||
Self::new(api_kind_of(e), format!("{what}: {e}"))
|
||||
}
|
||||
|
||||
/// Prefix the message while keeping the classification.
|
||||
@@ -208,18 +209,57 @@ impl From<serde_json::Error> for CoreError {
|
||||
|
||||
impl From<Error> for CoreError {
|
||||
fn from(e: Error) -> Self {
|
||||
let kind = match &e {
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
||||
// A `ProtonError` that never reached the API is a transport failure.
|
||||
Error::Proton(_) => ErrorKind::Offline,
|
||||
_ => ErrorKind::Internal,
|
||||
};
|
||||
Self::new(kind, e.to_string())
|
||||
Self::new(core_kind(&e), e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What this crate's [`Error`] means to a front-end.
|
||||
fn core_kind(e: &Error) -> ErrorKind {
|
||||
match e {
|
||||
Error::NotLoggedIn
|
||||
| Error::ReloginRequired
|
||||
| Error::Locked
|
||||
| Error::WrongMailboxPassword
|
||||
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||
Error::Proton(proton) => proton_kind(proton),
|
||||
_ => ErrorKind::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind for [`CoreError::from_api`]: the first SDK or crate error in `e`'s
|
||||
/// source chain decides it.
|
||||
fn api_kind_of(e: &(dyn std::error::Error + 'static)) -> ErrorKind {
|
||||
let mut cause = Some(e);
|
||||
while let Some(err) = cause {
|
||||
if let Some(proton) = err.downcast_ref::<proton_sdk::ProtonError>() {
|
||||
return proton_kind(proton);
|
||||
}
|
||||
if let Some(core) = err.downcast_ref::<Error>() {
|
||||
return core_kind(core);
|
||||
}
|
||||
cause = err.source();
|
||||
}
|
||||
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||
ErrorKind::Offline
|
||||
}
|
||||
|
||||
/// What a failed SDK call means to a front-end.
|
||||
///
|
||||
/// Only a transport failure is the connection. The rest happened on this
|
||||
/// machine, and calling them "offline" sent the user to check a network that
|
||||
/// worked (issue #38: a photo upload on an account without a photo library
|
||||
/// read "You're offline").
|
||||
fn proton_kind(e: &proton_sdk::ProtonError) -> ErrorKind {
|
||||
use proton_sdk::ProtonError as P;
|
||||
match e {
|
||||
P::Api(api) => api_kind(api.code),
|
||||
P::Transport(_) => ErrorKind::Offline,
|
||||
// Proton answered, but not with anything this client can read.
|
||||
P::Serialization(_) => ErrorKind::Remote,
|
||||
// The SDK refused the call before sending it, such as an upload to a
|
||||
// photo library the account does not have.
|
||||
P::InvalidOperation(_) => ErrorKind::Invalid,
|
||||
P::Crypto(_) | P::KeysLocked => ErrorKind::Internal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +318,32 @@ fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` says the session itself is gone: its refresh token was refused,
|
||||
/// or the account was deleted or disabled.
|
||||
///
|
||||
/// Nothing but a new login helps then, and no retry will ever succeed, so a
|
||||
/// caller must not take it for a lost connection and wait for it to come back
|
||||
/// (issue #35: a revoked session was shown as "offline" for good).
|
||||
pub fn session_revoked(e: &proton_sdk::ProtonError) -> bool {
|
||||
use proton_sdk::api::ResponseCode as C;
|
||||
match e {
|
||||
proton_sdk::ProtonError::Api(api) => {
|
||||
api.is_invalid_refresh_token()
|
||||
|| matches!(api.code, C::AccountDeleted | C::AccountDisabled)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `e` is Proton's temporary block on a sign-in (code 2028).
|
||||
///
|
||||
/// Its message says the app version is no longer supported, which is not what
|
||||
/// happened: the release is current and only waiting helps (issues #28, #39).
|
||||
/// A front-end must say that instead of passing the message through.
|
||||
pub fn sign_in_blocked(e: &proton_sdk::ProtonError) -> bool {
|
||||
matches!(e, proton_sdk::ProtonError::Api(api) if api.code == proton_sdk::api::ResponseCode::Blocked)
|
||||
}
|
||||
|
||||
/// The result every request-serving method in the daemon returns.
|
||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||
|
||||
@@ -309,6 +375,25 @@ mod tests {
|
||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refused_refresh_or_a_closed_account_revokes_the_session() {
|
||||
assert!(session_revoked(&api(C::InvalidRefreshToken)));
|
||||
assert!(session_revoked(&api(C::AccountDeleted)));
|
||||
assert!(session_revoked(&api(C::AccountDisabled)));
|
||||
// One file the account may not touch says nothing about the session.
|
||||
assert!(!session_revoked(&api(C::Forbidden)));
|
||||
assert!(!session_revoked(&api(C::Timeout)));
|
||||
assert!(!session_revoked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_code_2028_is_a_blocked_sign_in() {
|
||||
assert!(sign_in_blocked(&api(C::Blocked)));
|
||||
assert!(!sign_in_blocked(&api(C::OutdatedApp)));
|
||||
assert!(!sign_in_blocked(&api(C::TooManyRequests)));
|
||||
assert!(!sign_in_blocked(&ProtonError::KeysLocked));
|
||||
}
|
||||
|
||||
/// The regression this whole pass exists to prevent: before it, every SDK
|
||||
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||
/// request that could never succeed.
|
||||
@@ -339,6 +424,32 @@ mod tests {
|
||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// Issue #38: the SDK refused a photo upload before sending it, and the
|
||||
/// app said "You're offline" while every other call went through.
|
||||
#[test]
|
||||
fn a_call_the_sdk_refuses_to_send_is_not_offline() {
|
||||
let refused = ProtonError::invalid_operation("account has no photos volume");
|
||||
let e = CoreError::from_api(&refused, "upload photo failed");
|
||||
assert_eq!(e.kind, ErrorKind::Invalid);
|
||||
assert!(e.message.ends_with("account has no photos volume"));
|
||||
let e: CoreError = Error::Proton(ProtonError::KeysLocked).into();
|
||||
assert_ne!(e.kind, ErrorKind::Offline);
|
||||
}
|
||||
|
||||
/// A pin or a search that fails on the database is not the connection, so
|
||||
/// it must not say "You're offline" (`docs/BUGS.md` B266). An SDK error
|
||||
/// wrapped in this crate's type classifies as the SDK error does.
|
||||
#[test]
|
||||
fn a_database_error_handed_to_from_api_is_not_offline() {
|
||||
let db = Error::Other("database is locked".into());
|
||||
assert_eq!(CoreError::from_api(&db, "pin").kind, ErrorKind::Internal);
|
||||
let wrapped = Error::Proton(api(C::DoesNotExist));
|
||||
assert_eq!(
|
||||
CoreError::from_api(&wrapped, "load node").kind,
|
||||
ErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||
/// the direct path, or the two disagree about the same failure.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! When a photo was taken, as its own EXIF tags say.
|
||||
//!
|
||||
//! The fallback for a photo nothing else dates: a Takeout photo without a
|
||||
//! sidecar or a date in its name, and a photo uploaded from the app. Without
|
||||
//! it the first was filed under the moment of the import and the second under
|
||||
//! the file's modification time, which a copy resets.
|
||||
|
||||
use std::io::{BufRead, Seek};
|
||||
use std::path::Path;
|
||||
|
||||
use crate::takeout::days_from_civil;
|
||||
|
||||
/// The capture time in the EXIF of the file at `path`, in epoch seconds.
|
||||
pub fn capture_time_of(path: &Path) -> Option<i64> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
capture_time(&mut std::io::BufReader::new(file))
|
||||
}
|
||||
|
||||
/// The capture time in the EXIF `reader` holds: `DateTimeOriginal`, then
|
||||
/// `DateTimeDigitized`, each adjusted by its `OffsetTime…` tag when the camera
|
||||
/// wrote one. Without an offset the time is read as UTC, as a date in a file
|
||||
/// name is (`takeout::capture_time_from_name`).
|
||||
pub fn capture_time(reader: &mut (impl BufRead + Seek)) -> Option<i64> {
|
||||
let exif = exif::Reader::new().read_from_container(reader).ok()?;
|
||||
let ascii = |tag: exif::Tag| -> Option<String> {
|
||||
let field = exif.get_field(tag, exif::In::PRIMARY)?;
|
||||
match &field.value {
|
||||
exif::Value::Ascii(parts) => parts.first().map(|part| {
|
||||
String::from_utf8_lossy(part)
|
||||
.trim_matches('\0')
|
||||
.trim()
|
||||
.to_string()
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
[
|
||||
(exif::Tag::DateTimeOriginal, exif::Tag::OffsetTimeOriginal),
|
||||
(exif::Tag::DateTimeDigitized, exif::Tag::OffsetTimeDigitized),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(time, offset)| {
|
||||
let local = parse_exif_time(&ascii(time)?)?;
|
||||
let offset = ascii(offset).and_then(|o| parse_offset(&o)).unwrap_or(0);
|
||||
Some(local - offset)
|
||||
})
|
||||
}
|
||||
|
||||
/// `YYYY:MM:DD HH:MM:SS` as epoch seconds, read as UTC. `None` for the
|
||||
/// all-zero or blank value cameras write when they do not know.
|
||||
fn parse_exif_time(text: &str) -> Option<i64> {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() < 19 || bytes[4] != b':' || bytes[7] != b':' || bytes[13] != b':' {
|
||||
return None;
|
||||
}
|
||||
let number = |range: std::ops::Range<usize>| text.get(range)?.parse::<i64>().ok();
|
||||
let (year, month, day) = (number(0..4)?, number(5..7)?, number(8..10)?);
|
||||
let (hour, minute, second) = (number(11..13)?, number(14..16)?, number(17..19)?);
|
||||
if !(1800..=9999).contains(&year)
|
||||
|| !(1..=12).contains(&month)
|
||||
|| !(1..=31).contains(&day)
|
||||
|| hour > 23
|
||||
|| minute > 59
|
||||
|| second > 60
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
|
||||
}
|
||||
|
||||
/// `+HH:MM` or `-HH:MM` as seconds east of UTC.
|
||||
fn parse_offset(text: &str) -> Option<i64> {
|
||||
let sign = match text.as_bytes().first()? {
|
||||
b'+' => 1,
|
||||
b'-' => -1,
|
||||
_ => return None,
|
||||
};
|
||||
let (hours, minutes) = text.get(1..)?.split_once(':')?;
|
||||
let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
|
||||
(hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_exif_time_reads_as_utc_and_its_offset_moves_it() {
|
||||
// 2023-06-01 12:00:00 UTC.
|
||||
let noon = 1_685_620_800;
|
||||
assert_eq!(parse_exif_time("2023:06:01 12:00:00"), Some(noon));
|
||||
assert_eq!(parse_offset("+02:00"), Some(7200));
|
||||
assert_eq!(parse_offset("-05:30"), Some(-19_800));
|
||||
assert_eq!(
|
||||
parse_exif_time("2023:06:01 14:00:00").map(|t| t - parse_offset("+02:00").unwrap()),
|
||||
Some(noon)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_exif_time_is_no_time() {
|
||||
assert_eq!(parse_exif_time("0000:00:00 00:00:00"), None);
|
||||
assert_eq!(parse_exif_time(" : : : : "), None);
|
||||
assert_eq!(parse_exif_time(""), None);
|
||||
assert_eq!(parse_offset("02:00"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_is_dated_by_its_original_time_and_offset() {
|
||||
let ascii = |tag, text: &str| exif::Field {
|
||||
tag,
|
||||
ifd_num: exif::In::PRIMARY,
|
||||
value: exif::Value::Ascii(vec![text.as_bytes().to_vec()]),
|
||||
};
|
||||
let taken = ascii(exif::Tag::DateTimeOriginal, "2023:06:01 14:00:00");
|
||||
let offset = ascii(exif::Tag::OffsetTimeOriginal, "+02:00");
|
||||
let digitized = ascii(exif::Tag::DateTimeDigitized, "2024:01:01 00:00:00");
|
||||
let mut writer = exif::experimental::Writer::new();
|
||||
writer.push_field(&taken);
|
||||
writer.push_field(&offset);
|
||||
writer.push_field(&digitized);
|
||||
let mut tiff = std::io::Cursor::new(Vec::new());
|
||||
writer.write(&mut tiff, false).unwrap();
|
||||
tiff.set_position(0);
|
||||
assert_eq!(capture_time(&mut tiff), Some(1_685_620_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_without_exif_has_no_capture_time() {
|
||||
let mut png = std::io::Cursor::new(b"\x89PNG\r\n\x1a\n".to_vec());
|
||||
assert_eq!(capture_time(&mut png), None);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
|
||||
//! when the user's last session ends or the machine reboots. Both the daemon and
|
||||
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
|
||||
//! It is also where session tokens wait while the Secret Service refuses them
|
||||
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
|
||||
//!
|
||||
//! Kernel keys are not paged to swap, are invisible to other users, and are
|
||||
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
|
||||
@@ -20,12 +22,34 @@ use std::io;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Key description. Namespaced so it cannot collide with another program's key.
|
||||
/// One secret in the user keyring, by its description. Descriptions are
|
||||
/// namespaced so they cannot collide with another program's keys.
|
||||
pub struct Key {
|
||||
description: &'static [u8],
|
||||
}
|
||||
|
||||
/// The account's key passphrases (see the module docs).
|
||||
#[cfg(not(test))]
|
||||
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0";
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases\0",
|
||||
};
|
||||
/// Tests must never touch a real unlock.
|
||||
#[cfg(test)]
|
||||
const DESCRIPTION: &[u8] = b"pdfs:key-passphrases:test\0";
|
||||
pub const PASSPHRASES: Key = Key {
|
||||
description: b"pdfs:key-passphrases:test\0",
|
||||
};
|
||||
|
||||
/// Session tokens the Secret Service refused to store, until it takes them.
|
||||
/// A refresh token held only in a process's memory dies with the process.
|
||||
#[cfg(not(test))]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens\0",
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub const UNSAVED_TOKENS: Key = Key {
|
||||
description: b"pdfs:unsaved-tokens:test\0",
|
||||
};
|
||||
|
||||
const KEY_TYPE: &[u8] = b"user\0";
|
||||
|
||||
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
||||
@@ -49,7 +73,7 @@ fn last_error<T>() -> io::Result<T> {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
|
||||
fn find() -> io::Result<Option<libc::c_long>> {
|
||||
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
|
||||
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
@@ -57,7 +81,7 @@ fn find() -> io::Result<Option<libc::c_long>> {
|
||||
KEYCTL_SEARCH,
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
description.as_ptr(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
@@ -83,100 +107,102 @@ pub fn forbid_dumps() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(secret: &[u8]) -> io::Result<()> {
|
||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||
// describes `secret`, which outlives the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
DESCRIPTION.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
)
|
||||
};
|
||||
if id < 0 {
|
||||
return last_error();
|
||||
}
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SETPERM,
|
||||
id,
|
||||
PERM_OWNER_AND_POSSESSOR,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load() -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
for _ in 0..3 {
|
||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||
let len = unsafe {
|
||||
impl Key {
|
||||
/// Store `secret`, replacing any earlier one.
|
||||
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
|
||||
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||
// describes `secret`, which outlives the call.
|
||||
let id = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
std::ptr::null_mut::<u8>(),
|
||||
0usize,
|
||||
libc::SYS_add_key,
|
||||
KEY_TYPE.as_ptr(),
|
||||
self.description.as_ptr(),
|
||||
secret.as_ptr(),
|
||||
secret.len(),
|
||||
KEY_SPEC_USER_KEYRING,
|
||||
)
|
||||
};
|
||||
if len < 0 {
|
||||
if id < 0 {
|
||||
return last_error();
|
||||
}
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_SETPERM,
|
||||
id,
|
||||
PERM_OWNER_AND_POSSESSOR,
|
||||
)
|
||||
};
|
||||
if rc < 0 {
|
||||
// A key whose permissions are unknown is not one to leave behind.
|
||||
let err = io::Error::last_os_error();
|
||||
let _ = self.clear();
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the stored secret, or `None` when there is none.
|
||||
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Size first, then read; retry if the key was replaced in between.
|
||||
for _ in 0..3 {
|
||||
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||
let len = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
std::ptr::null_mut::<u8>(),
|
||||
0usize,
|
||||
)
|
||||
};
|
||||
if len < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||
// SAFETY: `buf` is `len` writable bytes.
|
||||
let read = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if read < 0 {
|
||||
return last_error();
|
||||
}
|
||||
if read as usize <= buf.len() {
|
||||
buf.truncate(read as usize);
|
||||
return Ok(Some(buf));
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("the key changed while it was being read"))
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear(&self) -> io::Result<()> {
|
||||
let Some(id) = find(self.description)? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||
if rc < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||
// SAFETY: `buf` is `len` writable bytes.
|
||||
let read = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_keyctl,
|
||||
KEYCTL_READ,
|
||||
id,
|
||||
buf.as_mut_ptr(),
|
||||
buf.len(),
|
||||
)
|
||||
};
|
||||
if read < 0 {
|
||||
return last_error();
|
||||
}
|
||||
if read as usize <= buf.len() {
|
||||
buf.truncate(read as usize);
|
||||
return Ok(Some(buf));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(io::Error::other("the key changed while it was being read"))
|
||||
}
|
||||
|
||||
/// Remove the stored secret. Succeeds when there is none.
|
||||
pub fn clear() -> io::Result<()> {
|
||||
let Some(id) = find()? else {
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: plain integer arguments.
|
||||
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||
if rc < 0 {
|
||||
return match io::Error::last_os_error().raw_os_error() {
|
||||
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||
_ => last_error(),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -187,18 +213,21 @@ mod tests {
|
||||
// so one test covers the whole round trip and skips when it is unavailable.
|
||||
#[test]
|
||||
fn stored_secret_round_trips_and_clears() {
|
||||
if store(b"probe").is_err() {
|
||||
if PASSPHRASES.store(b"probe").is_err() {
|
||||
eprintln!("kernel keyring unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let saved = load().unwrap().expect("stored secret is readable");
|
||||
let saved = PASSPHRASES
|
||||
.load()
|
||||
.unwrap()
|
||||
.expect("stored secret is readable");
|
||||
assert_eq!(saved.as_slice(), b"probe");
|
||||
|
||||
store(b"replaced").unwrap();
|
||||
assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
PASSPHRASES.store(b"replaced").unwrap();
|
||||
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||
|
||||
clear().unwrap();
|
||||
assert!(load().unwrap().is_none());
|
||||
clear().unwrap();
|
||||
PASSPHRASES.clear().unwrap();
|
||||
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||
PASSPHRASES.clear().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Trace the key chain that opens My Files, for `pdfs diagnose`.
|
||||
//!
|
||||
//! The daemon only says "missing key" when the My Files share does not open
|
||||
//! (#40). This walks the chain the other way and names the link that breaks:
|
||||
//! the share's passphrase is encrypted to an address key, whose token is
|
||||
//! encrypted to an account key, which opens with the stored passphrase. Every
|
||||
//! finding holds key IDs and flags only, never key material, so the output is
|
||||
//! safe to post in an issue.
|
||||
|
||||
use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport};
|
||||
use proton_sdk::ids::AddressId;
|
||||
|
||||
use crate::auth;
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Level {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Finding {
|
||||
pub level: Level,
|
||||
pub label: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// The address and recipients of the My Files share passphrase, or why they
|
||||
/// could not be read.
|
||||
pub type ShareRecipients = std::result::Result<(AddressId, Vec<Option<String>>), String>;
|
||||
|
||||
/// Resume the stored session and ask Proton for the account's keys and the
|
||||
/// My Files share. Fails when there is no session, the keys are locked, or
|
||||
/// Proton cannot be reached; reading the share is allowed to fail on its own.
|
||||
///
|
||||
/// The session's tokens are the daemon's too. A refresh here spends the
|
||||
/// refresh token the daemon holds, which it recovers from by resuming the
|
||||
/// stored session (see [`auth::stored_session_is_newer`]).
|
||||
pub async fn run() -> Result<(KeyReport, ShareRecipients)> {
|
||||
let (client, _session) = auth::resume_client().await?;
|
||||
let report = client.account().key_report().await?;
|
||||
let share = client
|
||||
.my_files_share_recipients()
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((report, share))
|
||||
}
|
||||
|
||||
/// One finding per key, then one for the My Files share.
|
||||
pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
for key in &report.user_keys {
|
||||
findings.push(user_key_finding(key));
|
||||
}
|
||||
for address in &report.addresses {
|
||||
for key in &address.keys {
|
||||
findings.push(address_key_finding(report, &address.email, key));
|
||||
}
|
||||
}
|
||||
findings.push(share_finding(report, share));
|
||||
findings
|
||||
}
|
||||
|
||||
fn user_key_finding(key: &UserKeyReport) -> Finding {
|
||||
let label = format!("account key {}", short(key.id.as_str()));
|
||||
let (level, detail) = match (key.active, key.unlocks) {
|
||||
(true, true) => (Level::Ok, "opens".to_string()),
|
||||
(true, false) => (
|
||||
Level::Fail,
|
||||
format!(
|
||||
"does not open: {}",
|
||||
key.error.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
),
|
||||
(false, true) => (Level::Ok, "inactive, opens with your password".to_string()),
|
||||
(false, false) => (Level::Ok, "inactive, does not open".to_string()),
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail: with_primary(key.primary, detail),
|
||||
}
|
||||
}
|
||||
|
||||
fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding {
|
||||
let label = format!("{email} key {}", short(key.id.as_str()));
|
||||
let (level, detail) = if !key.active {
|
||||
(Level::Ok, "inactive, not used".to_string())
|
||||
} else if key.unlocks {
|
||||
(Level::Ok, "opens".to_string())
|
||||
} else {
|
||||
// A non-primary key matters only for what was encrypted to it; the
|
||||
// share finding says whether My Files is.
|
||||
let level = if key.primary {
|
||||
Level::Fail
|
||||
} else {
|
||||
Level::Warn
|
||||
};
|
||||
(level, format!("does not open: {}", why_closed(report, key)))
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail: with_primary(key.primary, detail),
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an active address key does not open, named by the account key its
|
||||
/// token was meant for when the token has one.
|
||||
fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String {
|
||||
let error = key.error.as_deref().unwrap_or("unknown");
|
||||
let Some(recipients) = &key.token_recipients else {
|
||||
return error.to_string();
|
||||
};
|
||||
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||
if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) {
|
||||
let id = short(user_key.id.as_str());
|
||||
return match (user_key.active, user_key.unlocks) {
|
||||
(false, true) => {
|
||||
format!(
|
||||
"its token is for inactive account key {id}, which opens with your password"
|
||||
)
|
||||
}
|
||||
(false, false) => {
|
||||
format!("its token is for inactive account key {id}, which does not open either")
|
||||
}
|
||||
(true, false) => format!("its token is for account key {id}, which does not open"),
|
||||
// The token opened; the key itself refused its passphrase.
|
||||
(true, true) => error.to_string(),
|
||||
};
|
||||
}
|
||||
match named.first() {
|
||||
Some(recipient) => {
|
||||
format!("its token is for key {recipient}, which is none of your account keys")
|
||||
}
|
||||
None if recipients.is_empty() => error.to_string(),
|
||||
None => "its token does not name the key it is for".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding {
|
||||
let label = "My Files share".to_string();
|
||||
let (level, detail) = match share {
|
||||
Err(e) => (Level::Warn, format!("could not be read: {e}")),
|
||||
Ok((address_id, recipients)) => {
|
||||
let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect();
|
||||
match named.iter().find_map(|r| report.address_key_named(r)) {
|
||||
Some((address, key)) => {
|
||||
let name = format!("{} key {}", address.email, short(key.id.as_str()));
|
||||
if key.unlocks {
|
||||
(Level::Ok, format!("encrypted to {name}, which opens"))
|
||||
} else {
|
||||
(
|
||||
Level::Fail,
|
||||
format!("encrypted to {name}, which does not open"),
|
||||
)
|
||||
}
|
||||
}
|
||||
None => (
|
||||
Level::Fail,
|
||||
match named.first() {
|
||||
Some(recipient) => format!(
|
||||
"encrypted to key {recipient}, which is none of the keys of address {address_id}"
|
||||
),
|
||||
None => "its passphrase does not name the key it is for".to_string(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
Finding {
|
||||
level,
|
||||
label,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_primary(primary: bool, detail: String) -> String {
|
||||
if primary {
|
||||
format!("primary, {detail}")
|
||||
} else {
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
/// The start of a Proton key ID: enough to tell keys apart and to find them
|
||||
/// in the daemon log, which prints the whole ID.
|
||||
fn short(id: &str) -> String {
|
||||
match id.char_indices().nth(8) {
|
||||
Some((end, _)) => format!("{}…", &id[..end]),
|
||||
None => id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proton_sdk::account::AddressReport;
|
||||
use proton_sdk::ids::{AddressKeyId, UserKeyId};
|
||||
|
||||
fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport {
|
||||
UserKeyReport {
|
||||
id: UserKeyId::new(id),
|
||||
primary: active,
|
||||
active,
|
||||
unlocks,
|
||||
error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()),
|
||||
key_ids: vec![pgp.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport {
|
||||
AddressKeyReport {
|
||||
id: AddressKeyId::new(id),
|
||||
primary,
|
||||
active: true,
|
||||
token_recipients: Some(vec![Some(token_for.to_string())]),
|
||||
unlocks: token_for == "u1",
|
||||
error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()),
|
||||
key_ids: vec![pgp.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// The account in #40, as far as its log shows: the primary address key
|
||||
/// opens, an older one does not, and My Files is encrypted to the older one.
|
||||
fn report(stranded_token_for: &str) -> KeyReport {
|
||||
KeyReport {
|
||||
user_keys: vec![
|
||||
user_key("UserKeyOne-123", true, true, "u1"),
|
||||
user_key("UserKeyTwo-456", false, true, "u2"),
|
||||
],
|
||||
addresses: vec![AddressReport {
|
||||
id: AddressId::new("address-1"),
|
||||
email: "user@proton.me".to_string(),
|
||||
keys: vec![
|
||||
address_key("AddrKeyNew-789", true, "u1", "a1"),
|
||||
address_key("AddrKeyOld-000", false, stranded_token_for, "a2"),
|
||||
],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stranded_address_key_is_traced_to_the_inactive_account_key() {
|
||||
let report = report("u2");
|
||||
let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())]));
|
||||
let findings = findings(&report, &share);
|
||||
|
||||
assert_eq!(findings[0].detail, "primary, opens");
|
||||
assert_eq!(findings[1].detail, "inactive, opens with your password");
|
||||
assert_eq!(findings[2].level, Level::Ok);
|
||||
assert_eq!(findings[3].level, Level::Warn);
|
||||
assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…");
|
||||
assert_eq!(
|
||||
findings[3].detail,
|
||||
"does not open: its token is for inactive account key UserKeyT…, which opens with your password"
|
||||
);
|
||||
assert_eq!(findings[4].level, Level::Fail);
|
||||
assert_eq!(
|
||||
findings[4].detail,
|
||||
"encrypted to user@proton.me key AddrKeyO…, which does not open"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_for_a_key_the_account_does_not_list_says_so() {
|
||||
let report = report("ffff");
|
||||
let detail = &findings(&report, &Err("offline".to_string()))[3].detail;
|
||||
assert_eq!(
|
||||
detail,
|
||||
"does not open: its token is for key ffff, which is none of your account keys"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_that_opens_and_one_that_cannot_be_read() {
|
||||
let report = report("u2");
|
||||
let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())]));
|
||||
let last = findings(&report, &open).pop().unwrap();
|
||||
assert_eq!(last.level, Level::Ok);
|
||||
assert_eq!(
|
||||
last.detail,
|
||||
"encrypted to user@proton.me key AddrKeyN…, which opens"
|
||||
);
|
||||
|
||||
let last = findings(&report, &Err("offline".to_string()))
|
||||
.pop()
|
||||
.unwrap();
|
||||
assert_eq!(last.level, Level::Warn);
|
||||
assert_eq!(last.detail, "could not be read: offline");
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ pub mod config;
|
||||
pub mod control;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod exiftime;
|
||||
pub mod kernelkey;
|
||||
pub mod keycheck;
|
||||
pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mountfail;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod places;
|
||||
@@ -23,8 +26,10 @@ pub mod service;
|
||||
pub mod shell;
|
||||
pub mod similar;
|
||||
pub mod syncignore;
|
||||
pub mod syncroot;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
pub mod update;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
pub use error::{CoreError, CoreResult, Error, Result};
|
||||
|
||||
@@ -42,7 +42,7 @@ const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
pub const MAX_ENTRIES: usize = 400_000;
|
||||
|
||||
/// Rows handed to the sink at a time. Large enough that the per-transaction cost
|
||||
/// amortises, small enough that the writer lock is released often (FUSE
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Why the daemon's mount keeps failing, for the front ends to read.
|
||||
//!
|
||||
//! The control socket opens only once a mount has succeeded, so a daemon whose
|
||||
//! mount fails on every try had no way to say why. The app and the tray showed
|
||||
//! "Connecting…" for days while the reason sat in the journal (`docs/BUGS.md`
|
||||
//! B263). The daemon now writes its last failure to a small file in the state
|
||||
//! directory, next to the control socket, and removes it once a mount is up.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The file's name inside the state directory.
|
||||
const FILE_NAME: &str = "mount-failure.json";
|
||||
|
||||
/// The last failed mount attempt.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MountFailure {
|
||||
/// The error, in English, as the journal has it.
|
||||
pub error: String,
|
||||
/// Unix seconds of the first failure in this run of failures.
|
||||
pub since: i64,
|
||||
/// Failed attempts in a row.
|
||||
pub attempts: u32,
|
||||
/// Unix seconds of the next attempt.
|
||||
pub next_attempt: i64,
|
||||
}
|
||||
|
||||
/// Where the failure is kept for the state directory `state_dir`.
|
||||
pub fn path(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(FILE_NAME)
|
||||
}
|
||||
|
||||
/// Record `failure`, replacing the one before it. Written to a temporary file
|
||||
/// and renamed, so a reader never sees half of it.
|
||||
pub fn record(state_dir: &Path, failure: &MountFailure) -> std::io::Result<()> {
|
||||
let json = serde_json::to_vec(failure).map_err(std::io::Error::other)?;
|
||||
let tmp = state_dir.join(format!("{FILE_NAME}.tmp"));
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, path(state_dir))
|
||||
}
|
||||
|
||||
/// Forget the failure: a mount is up, or a new daemon has started.
|
||||
pub fn clear(state_dir: &Path) {
|
||||
let _ = std::fs::remove_file(path(state_dir));
|
||||
}
|
||||
|
||||
/// The recorded failure, if there is one and it can be read.
|
||||
pub fn read(state_dir: &Path) -> Option<MountFailure> {
|
||||
let bytes = std::fs::read(path(state_dir)).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_recorded_failure_reads_back_until_cleared() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfs-mountfail-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert_eq!(read(&dir), None);
|
||||
let failure = MountFailure {
|
||||
error: "fetch My Files root: missing key".into(),
|
||||
since: 1_700_000_000,
|
||||
attempts: 3,
|
||||
next_attempt: 1_700_000_040,
|
||||
};
|
||||
record(&dir, &failure).unwrap();
|
||||
assert_eq!(read(&dir), Some(failure));
|
||||
clear(&dir);
|
||||
assert_eq!(read(&dir), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -189,11 +189,21 @@ fn spawn(argv: &[String], path: &Path) {
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn();
|
||||
if let Err(e) = spawned {
|
||||
tracing::error!(command = ?argv, "open failed: {e}");
|
||||
match spawned {
|
||||
Ok(child) => reap(child),
|
||||
Err(e) => tracing::error!(command = ?argv, "open failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for `child` on a thread of its own. A child nobody waits for stays a
|
||||
/// zombie for as long as its parent lives, which for the app and the tray is
|
||||
/// the whole session.
|
||||
pub fn reap(mut child: std::process::Child) {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
|
||||
/// [`open`] with the policy from the user's `config.json`, read once per
|
||||
/// 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.
|
||||
@@ -472,6 +482,21 @@ fn glob_match(pattern: &str, name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_reaped_child_does_not_stay_a_zombie() {
|
||||
let child = std::process::Command::new("true").spawn().unwrap();
|
||||
let proc_entry = std::path::PathBuf::from(format!("/proc/{}", child.id()));
|
||||
reap(child);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while proc_entry.exists() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
!proc_entry.exists(),
|
||||
"the child is still in the process table"
|
||||
);
|
||||
}
|
||||
|
||||
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||
OpenRule {
|
||||
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||
|
||||
@@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mounts::MountMode;
|
||||
|
||||
/// File name of the profile.
|
||||
/// File name of the profile clients before 3.5.0 wrote, one per device.
|
||||
///
|
||||
/// Read, never written: two computers sharing a device each overwrote the
|
||||
/// other's. See [`profile_file_name`].
|
||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||
|
||||
/// File name of one machine's profile: `profile-<machine id>.json`.
|
||||
///
|
||||
/// One per machine, because several computers may sync into one device, and
|
||||
/// each describes its own arrangement. A single shared file went to whichever
|
||||
/// computer saved last.
|
||||
pub fn profile_file_name(machine_id: &str) -> String {
|
||||
format!("profile-{machine_id}.json")
|
||||
}
|
||||
|
||||
/// Whether `name` is a profile: [`PROFILE_FILE_NAME`] or one machine's
|
||||
/// [`profile_file_name`].
|
||||
pub fn is_profile_file_name(name: &str) -> bool {
|
||||
name == PROFILE_FILE_NAME
|
||||
|| name
|
||||
.strip_prefix("profile-")
|
||||
.and_then(|rest| rest.strip_suffix(".json"))
|
||||
.is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
/// Folder, directly under the device root, that holds the profile.
|
||||
///
|
||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||
@@ -74,6 +96,10 @@ pub struct Profile {
|
||||
/// Hostname of the machine that wrote it, so a restore prompt can say
|
||||
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
||||
pub hostname: String,
|
||||
/// The writing machine's id, as in its [`profile_file_name`]. `None` in a
|
||||
/// profile written before 3.5.0.
|
||||
#[serde(default)]
|
||||
pub machine_id: Option<String>,
|
||||
/// When it was written, epoch seconds.
|
||||
pub saved_at: i64,
|
||||
#[serde(default)]
|
||||
@@ -92,6 +118,25 @@ pub struct Profile {
|
||||
pub mountpoint: Option<String>,
|
||||
}
|
||||
|
||||
/// The saved arrangement of the folder with uid `remote_uid`, from the
|
||||
/// profiles of every computer that syncs the device.
|
||||
///
|
||||
/// The profile of the machine named `hostname` wins, since it describes this
|
||||
/// computer; otherwise the most recently saved one that has the folder.
|
||||
pub fn saved_folder<'a>(
|
||||
profiles: &'a [Profile],
|
||||
remote_uid: &str,
|
||||
hostname: &str,
|
||||
) -> Option<&'a ProfileFolder> {
|
||||
let holding = |p: &&'a Profile| p.folders.iter().any(|f| f.remote_uid == remote_uid);
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.filter(holding)
|
||||
.find(|p| p.hostname == hostname)
|
||||
.or_else(|| profiles.iter().filter(holding).max_by_key(|p| p.saved_at))?;
|
||||
profile.folders.iter().find(|f| f.remote_uid == remote_uid)
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Parse a profile document, refusing one written by a newer client.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
@@ -122,6 +167,7 @@ mod tests {
|
||||
version: PROFILE_VERSION,
|
||||
device_uid: "dev-1".to_string(),
|
||||
hostname: "laptop-xyz".to_string(),
|
||||
machine_id: Some("0123456789abcdef0123456789abcdef".to_string()),
|
||||
saved_at: 1_700_000_000,
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~link".to_string(),
|
||||
@@ -219,6 +265,52 @@ mod tests {
|
||||
assert!(p.ignore_patterns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_name_is_the_legacy_one_or_one_per_machine() {
|
||||
assert!(is_profile_file_name(PROFILE_FILE_NAME));
|
||||
let name = profile_file_name("0123456789abcdef0123456789abcdef");
|
||||
assert_eq!(name, "profile-0123456789abcdef0123456789abcdef.json");
|
||||
assert!(is_profile_file_name(&name));
|
||||
assert!(!is_profile_file_name("profile-.json"));
|
||||
assert!(!is_profile_file_name("profile-a/b.json"));
|
||||
assert!(!is_profile_file_name("profile (1).json"));
|
||||
assert!(!is_profile_file_name("notes.json"));
|
||||
}
|
||||
|
||||
/// A profile written before 3.5.0 has no machine id and still parses.
|
||||
#[test]
|
||||
fn a_profile_without_a_machine_id_parses() {
|
||||
let json = br#"{"version":1,"device_uid":"d","hostname":"h","saved_at":0}"#;
|
||||
assert_eq!(Profile::parse(json).unwrap().machine_id, None);
|
||||
}
|
||||
|
||||
/// Several computers sync one device, each with its own profile. This
|
||||
/// computer's own wins, then the newest that has the folder.
|
||||
#[test]
|
||||
fn the_saved_folder_comes_from_this_computer_then_the_newest_profile() {
|
||||
let profile = |hostname: &str, saved_at: i64, path: &str| Profile {
|
||||
hostname: hostname.to_string(),
|
||||
saved_at,
|
||||
folders: vec![ProfileFolder {
|
||||
remote_uid: "vol~docs".to_string(),
|
||||
local_path: path.to_string(),
|
||||
mode: MountMode::Mirror,
|
||||
}],
|
||||
..sample()
|
||||
};
|
||||
let profiles = vec![
|
||||
profile("desktop", 10, "/home/a/Docs"),
|
||||
profile("laptop", 5, "/home/b/Docs"),
|
||||
profile("old", 1, "/home/c/Docs"),
|
||||
];
|
||||
let path =
|
||||
|host: &str| saved_folder(&profiles, "vol~docs", host).map(|f| f.local_path.as_str());
|
||||
assert_eq!(path("laptop"), Some("/home/b/Docs"));
|
||||
assert_eq!(path("new-machine"), Some("/home/a/Docs"));
|
||||
assert_eq!(saved_folder(&profiles, "vol~other", "laptop"), None);
|
||||
assert_eq!(saved_folder(&[], "vol~docs", "laptop"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn future_version_is_refused() {
|
||||
let mut p = sample();
|
||||
|
||||
@@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [
|
||||
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
|
||||
/// selection when the script runs on a local (non-URI) folder, so we prefer the
|
||||
/// environment and fall back to `"$@"` for the Nemo/Caja variants.
|
||||
///
|
||||
/// The `--` keeps a file named like an option, such as `--json`, a path. A
|
||||
/// name with a newline in it still arrives split in two from Nautilus, whose
|
||||
/// list is newline-separated; `pdfs` then fails on both halves, which touches
|
||||
/// nothing.
|
||||
fn script_body(subcommand: &str) -> String {
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
@@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String {
|
||||
\n\
|
||||
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
||||
\x20 [ -n \"$path\" ] || continue\n\
|
||||
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
|
||||
\x20 pdfs {subcommand} -- \"$path\" || exit 1\n\
|
||||
done\n"
|
||||
)
|
||||
}
|
||||
@@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> {
|
||||
tracing::debug!("installed file-manager scripts in {dir:?}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::script_body;
|
||||
|
||||
#[test]
|
||||
fn the_script_ends_options_before_each_path() {
|
||||
assert!(script_body("pin").contains(r#"pdfs pin -- "$path""#));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Which local folders may become a sync root.
|
||||
//!
|
||||
//! A mirror uploads everything under its root and deletes what Drive deleted,
|
||||
//! so a root that holds more than the user meant is dangerous. `$HOME` takes
|
||||
//! the decrypted content cache, `cache.db` and `~/.ssh` up to Drive, and walks
|
||||
//! into the mount, which downloads the whole Drive only to upload it again.
|
||||
//! Every place a root is added or restored goes through [`problem`].
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::config::{AppConfig, AppDirs};
|
||||
|
||||
/// The paths a sync root must stay clear of.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Reserved {
|
||||
/// The user's home folder. A root may sit inside it, not be it or above it.
|
||||
pub home: Option<PathBuf>,
|
||||
/// The Proton Drive mount.
|
||||
pub mountpoint: Option<PathBuf>,
|
||||
/// This app's own configuration, state and cache folders.
|
||||
pub app_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Reserved {
|
||||
/// The reserved paths of this user and `config`, resolved like a root.
|
||||
pub fn current(config: &AppConfig) -> Self {
|
||||
let Ok(dirs) = AppDirs::new() else {
|
||||
return Self::default();
|
||||
};
|
||||
let resolved = |path: PathBuf| resolve(&path).unwrap_or(path);
|
||||
let mut app_dirs = vec![dirs.state_dir(), dirs.cache_dir()];
|
||||
if let Some(config_dir) = dirs.config_path().parent() {
|
||||
app_dirs.push(config_dir.to_path_buf());
|
||||
}
|
||||
Self {
|
||||
home: dirs.home_dir().map(resolved),
|
||||
mountpoint: Some(resolved(dirs.resolved_mountpoint(config))),
|
||||
app_dirs: app_dirs.into_iter().map(resolved).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` as a root is compared: absolute, with symlinks resolved as far as the
|
||||
/// path exists. A folder a restore is about to create does not exist yet, so
|
||||
/// the part past the deepest existing folder is kept as written.
|
||||
///
|
||||
/// A `..` is refused rather than resolved: before a symlink it means something
|
||||
/// else than after it, and a path recorded on another machine has no business
|
||||
/// holding one.
|
||||
pub fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{} is not an absolute path", path.display()));
|
||||
}
|
||||
if path.components().any(|c| c == Component::ParentDir) {
|
||||
return Err(format!("{} goes up with `..`", path.display()));
|
||||
}
|
||||
let mut existing = path.to_path_buf();
|
||||
let mut rest = Vec::new();
|
||||
loop {
|
||||
match existing.canonicalize() {
|
||||
Ok(canonical) => {
|
||||
return Ok(rest
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(canonical, |path: PathBuf, part| path.join(part)));
|
||||
}
|
||||
Err(_) => {
|
||||
let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
|
||||
return Ok(path.to_path_buf());
|
||||
};
|
||||
rest.push(name);
|
||||
if !existing.pop() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `root` cannot be synced, or `None` when it can. `root` is resolved
|
||||
/// ([`resolve`]); `others` are the roots already synced.
|
||||
pub fn problem(root: &Path, others: &[PathBuf], reserved: &Reserved) -> Option<String> {
|
||||
let shown = root.display();
|
||||
if root.parent().is_none() {
|
||||
return Some(format!("{shown} is the root of the file system"));
|
||||
}
|
||||
if let Some(home) = &reserved.home {
|
||||
if root == home {
|
||||
return Some(format!(
|
||||
"{shown} is your home folder; pick a folder inside it"
|
||||
));
|
||||
}
|
||||
if home.starts_with(root) {
|
||||
return Some(format!("{shown} holds your home folder"));
|
||||
}
|
||||
}
|
||||
if let Some(mountpoint) = &reserved.mountpoint
|
||||
&& overlaps(root, mountpoint)
|
||||
{
|
||||
return Some(format!(
|
||||
"{shown} overlaps the Proton Drive folder {}",
|
||||
mountpoint.display()
|
||||
));
|
||||
}
|
||||
if let Some(dir) = reserved.app_dirs.iter().find(|dir| overlaps(root, dir)) {
|
||||
return Some(format!(
|
||||
"{shown} overlaps this app's own data in {}",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
if others.iter().any(|other| other == root) {
|
||||
return Some(format!("{shown} is already synced"));
|
||||
}
|
||||
if let Some(other) = others.iter().find(|other| overlaps(root, other)) {
|
||||
return Some(format!(
|
||||
"{shown} overlaps the synced folder {}",
|
||||
other.display()
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether one of `a` and `b` is the other or inside it.
|
||||
fn overlaps(a: &Path, b: &Path) -> bool {
|
||||
a.starts_with(b) || b.starts_with(a)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn reserved() -> Reserved {
|
||||
Reserved {
|
||||
home: Some("/home/u".into()),
|
||||
mountpoint: Some("/home/u/ProtonDrive".into()),
|
||||
app_dirs: vec![
|
||||
"/home/u/.local/state/proton-drive-linux".into(),
|
||||
"/home/u/.cache/proton-drive-linux".into(),
|
||||
"/home/u/.config/proton-drive-linux".into(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(root: &str, others: &[&str]) -> bool {
|
||||
let others: Vec<PathBuf> = others.iter().map(PathBuf::from).collect();
|
||||
problem(Path::new(root), &others, &reserved()).is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_inside_home_can_be_synced() {
|
||||
assert!(!refused("/home/u/Documents", &[]));
|
||||
assert!(!refused("/home/u/Pictures", &["/home/u/Documents"]));
|
||||
assert!(!refused("/mnt/data/photos", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_system_root_home_and_its_parents_are_refused() {
|
||||
assert!(refused("/", &[]));
|
||||
assert!(refused("/home/u", &[]));
|
||||
assert!(refused("/home", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mount_and_the_apps_own_data_are_refused_either_way_round() {
|
||||
assert!(refused("/home/u/ProtonDrive", &[]));
|
||||
assert!(refused("/home/u/ProtonDrive/Documents", &[]));
|
||||
assert!(refused("/home/u/.cache", &[]));
|
||||
assert!(refused(
|
||||
"/home/u/.local/state/proton-drive-linux/staging",
|
||||
&[]
|
||||
));
|
||||
assert!(refused("/home/u/.config", &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_inside_or_around_another_root_is_refused() {
|
||||
assert!(refused("/home/u/Documents", &["/home/u/Documents"]));
|
||||
assert!(refused("/home/u/Documents/Work", &["/home/u/Documents"]));
|
||||
assert!(refused("/home/u/stuff", &["/home/u/stuff/Documents"]));
|
||||
// A shared prefix of characters is not a shared folder.
|
||||
assert!(!refused("/home/u/Documents2", &["/home/u/Documents"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_that_goes_up_or_is_relative_does_not_resolve() {
|
||||
assert!(resolve(Path::new("/home/u/../../etc")).is_err());
|
||||
assert!(resolve(Path::new("Documents")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_still_to_be_created_resolves_under_its_existing_parent() {
|
||||
let base = std::env::temp_dir().join(format!("pdfs-syncroot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let canonical = base.canonicalize().unwrap();
|
||||
assert_eq!(
|
||||
resolve(&base.join("new/inner")).unwrap(),
|
||||
canonical.join("new/inner")
|
||||
);
|
||||
let link = base.join("link");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(&canonical, &link).unwrap();
|
||||
assert_eq!(
|
||||
resolve(&link.join("later")).unwrap(),
|
||||
canonical.join("later"),
|
||||
"a symlink is followed to where it points"
|
||||
);
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
}
|
||||
+251
-25
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
||||
.insert(file.to_string(), body);
|
||||
}
|
||||
|
||||
let indexes: HashMap<&str, SidecarIndex<'_>> = per_folder_sidecars
|
||||
.iter()
|
||||
.map(|(folder, in_folder)| (folder.as_str(), SidecarIndex::new(in_folder)))
|
||||
.collect();
|
||||
|
||||
let mut scan = TakeoutScan::default();
|
||||
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
||||
|
||||
@@ -364,9 +369,13 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
||||
continue;
|
||||
};
|
||||
|
||||
let sidecar = per_folder_sidecars
|
||||
.get(&folder)
|
||||
.and_then(|in_folder| find_sidecar(&file_name, in_folder))
|
||||
let found = indexes
|
||||
.get(folder.as_str())
|
||||
.and_then(|index| find_sidecar(&file_name, index));
|
||||
// An edited copy keeps its own name: the title is its original's.
|
||||
let title_is_ours = found.is_some_and(|((rule, _), _)| rule != EDITED_COPY);
|
||||
let sidecar = found
|
||||
.map(|(_, body)| body)
|
||||
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -388,6 +397,7 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
||||
let name = sidecar
|
||||
.title
|
||||
.as_deref()
|
||||
.filter(|_| title_is_ours)
|
||||
.map(str::trim)
|
||||
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
||||
.unwrap_or(&file_name)
|
||||
@@ -598,7 +608,7 @@ pub fn capture_time_from_name(name: &str) -> Option<i64> {
|
||||
|
||||
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
||||
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
pub(crate) fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||
let year = year - i64::from(month <= 2);
|
||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||
let year_of_era = year - era * 400;
|
||||
@@ -627,17 +637,106 @@ fn swap_duplicate_marker(file_name: &str) -> Option<String> {
|
||||
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
|
||||
}
|
||||
|
||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
||||
/// How long Google lets a sidecar's file name get, `.json` included. A longer
|
||||
/// one is cut, into its suffix first and then into the photo name.
|
||||
const SIDECAR_NAME_CAP: usize = 51;
|
||||
|
||||
/// The rank [`sidecar_rank`] gives an edited copy that shares its original's
|
||||
/// sidecar. Its date is the original's, but its name is its own.
|
||||
const EDITED_COPY: u8 = 4;
|
||||
|
||||
/// The sidecars of one folder by their `.json`-stripped stem, so a photo is
|
||||
/// ranked only against those that can match it.
|
||||
///
|
||||
/// Every rule in [`sidecar_rank`] needs the stem to start with a prefix of the
|
||||
/// photo name that the name itself determines, and a sorted map finds the
|
||||
/// stems with a given prefix as one range. Ranking every photo against every
|
||||
/// sidecar instead took 27 s for a folder of 10,000 photos.
|
||||
struct SidecarIndex<'a> {
|
||||
by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl<'a> SidecarIndex<'a> {
|
||||
fn new(in_folder: &'a BTreeMap<String, &'a Vec<u8>>) -> Self {
|
||||
let mut by_stem: BTreeMap<&'a str, Vec<(&'a str, &'a Vec<u8>)>> = BTreeMap::new();
|
||||
for (name, body) in in_folder {
|
||||
by_stem
|
||||
.entry(strip_json_suffix(name))
|
||||
.or_default()
|
||||
.push((name.as_str(), *body));
|
||||
}
|
||||
Self { by_stem }
|
||||
}
|
||||
|
||||
/// Add every sidecar whose stem starts with `prefix` to `out`.
|
||||
fn starting_with(&self, prefix: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||
let from = (
|
||||
std::ops::Bound::Included(prefix),
|
||||
std::ops::Bound::Unbounded,
|
||||
);
|
||||
for (stem, sidecars) in self.by_stem.range::<str, _>(from) {
|
||||
if !stem.starts_with(prefix) {
|
||||
break;
|
||||
}
|
||||
out.extend(sidecars.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
/// Add every sidecar whose stem is `head`, or starts with `head` and a `.`:
|
||||
/// those that have `head` as a boundary ([`suffix_boundaries`]).
|
||||
fn bounded_by(&self, head: &str, out: &mut BTreeMap<&'a str, &'a Vec<u8>>) {
|
||||
if let Some(sidecars) = self.by_stem.get(head) {
|
||||
out.extend(sidecars.iter().copied());
|
||||
}
|
||||
self.starting_with(&format!("{head}."), out);
|
||||
}
|
||||
|
||||
/// Every sidecar [`sidecar_rank`] could match `file_name` to, by name. A
|
||||
/// superset: each one is still ranked.
|
||||
fn candidates(&self, file_name: &str) -> BTreeMap<&'a str, &'a Vec<u8>> {
|
||||
let mut out = BTreeMap::new();
|
||||
// Rules 0 and 2: the stem starts with the name, or with its swapped form.
|
||||
self.starting_with(file_name, &mut out);
|
||||
if let Some(swapped) = swap_duplicate_marker(file_name) {
|
||||
self.starting_with(&swapped, &mut out);
|
||||
}
|
||||
// Rule 1: the stem of `A(N).B` starts with `A.B(N)`, so with `A.`.
|
||||
for (paren, _) in file_name.match_indices('(') {
|
||||
self.starting_with(&format!("{}.", &file_name[..paren]), &mut out);
|
||||
}
|
||||
// Rule 3: a boundary of the stem is a prefix of the name.
|
||||
for end in file_name
|
||||
.char_indices()
|
||||
.map(|(index, _)| index)
|
||||
.skip(1)
|
||||
.chain([file_name.len()])
|
||||
{
|
||||
self.bounded_by(&file_name[..end], &mut out);
|
||||
}
|
||||
// Rule 4: `<original>-<word>.<ext>` has `<original>.<ext>` as a boundary.
|
||||
if let Some((_, extension)) = file_name.rsplit_once('.') {
|
||||
for (dash, _) in file_name.match_indices('-') {
|
||||
self.bounded_by(&format!("{}.{extension}", &file_name[..dash]), &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder,
|
||||
/// with the rank it matched at.
|
||||
///
|
||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||
/// unknown trailing suffix, then a prefix match for the case where truncation
|
||||
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
||||
/// unknown trailing suffix, a prefix match for the case where truncation cut
|
||||
/// into the photo name itself, and last an edited copy's original. The
|
||||
/// best-ranked candidate in the folder wins; of equal ranks, the first by
|
||||
/// sidecar name.
|
||||
fn find_sidecar<'a>(
|
||||
file_name: &str,
|
||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||
) -> Option<&'a Vec<u8>> {
|
||||
index: &SidecarIndex<'a>,
|
||||
) -> Option<((u8, usize), &'a Vec<u8>)> {
|
||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||
for (sidecar_name, body) in in_folder {
|
||||
for (sidecar_name, body) in index.candidates(file_name) {
|
||||
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||
continue;
|
||||
};
|
||||
@@ -645,7 +744,7 @@ fn find_sidecar<'a>(
|
||||
best = Some((rank, body));
|
||||
}
|
||||
}
|
||||
best.map(|(_, body)| body)
|
||||
best
|
||||
}
|
||||
|
||||
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
||||
@@ -683,19 +782,44 @@ fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
|
||||
}
|
||||
// 3 — truncation ate into the photo name itself, leaving only a prefix of
|
||||
// it. Every suffix boundary in the stem is tried, so this holds for a
|
||||
// localized suffix too; the longest surviving prefix wins, because a folder
|
||||
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the
|
||||
// longer photo.
|
||||
let mut longest: Option<usize> = None;
|
||||
for head in suffix_boundaries(stem) {
|
||||
if !head.is_empty()
|
||||
&& file_name.starts_with(head)
|
||||
&& longest.is_none_or(|len| head.len() > len)
|
||||
{
|
||||
longest = Some(head.len());
|
||||
// localized suffix too; the longest surviving prefix wins.
|
||||
//
|
||||
// Only for a sidecar whose name is as long as Google lets one get: a name
|
||||
// that short of the cap was not cut, so a prefix of it is the name of a
|
||||
// different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and
|
||||
// date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225).
|
||||
if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 {
|
||||
let mut longest: Option<usize> = None;
|
||||
for head in suffix_boundaries(stem) {
|
||||
if !head.is_empty()
|
||||
&& file_name.starts_with(head)
|
||||
&& longest.is_none_or(|len| head.len() > len)
|
||||
{
|
||||
longest = Some(head.len());
|
||||
}
|
||||
}
|
||||
if let Some(len) = longest {
|
||||
return Some((3, usize::MAX - len));
|
||||
}
|
||||
}
|
||||
longest.map(|len| (3, usize::MAX - len))
|
||||
// 4 — an edited copy, `IMG_1-edited.jpg` (the word is localized), which
|
||||
// Google exports without a sidecar of its own next to `IMG_1.jpg`'s.
|
||||
suffix_boundaries(stem)
|
||||
.filter_map(|head| head.rsplit_once('.'))
|
||||
.any(|(original, extension)| is_edited_copy_of(file_name, original, extension))
|
||||
.then_some((EDITED_COPY, 0))
|
||||
}
|
||||
|
||||
/// Whether `file_name` is `<original>-<word>.<extension>`, the name Google
|
||||
/// gives an edited copy of `<original>.<extension>`.
|
||||
fn is_edited_copy_of(file_name: &str, original: &str, extension: &str) -> bool {
|
||||
!original.is_empty()
|
||||
&& file_name
|
||||
.strip_prefix(original)
|
||||
.and_then(|rest| rest.strip_prefix('-'))
|
||||
.and_then(|rest| rest.strip_suffix(extension))
|
||||
.and_then(|rest| rest.strip_suffix('.'))
|
||||
.is_some_and(|word| !word.is_empty() && word.chars().all(char::is_alphabetic))
|
||||
}
|
||||
|
||||
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
||||
@@ -994,21 +1118,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
|
||||
// Both sidecar names are at Google's cap, so both were cut into the
|
||||
// photo name; the longer surviving prefix is the better claim.
|
||||
let short = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_E.json";
|
||||
let long = "Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPO.json";
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Photos/Photos from 2019/IMG_1.json",
|
||||
short,
|
||||
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||
);
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Photos/Photos from 2019/IMG_12.json",
|
||||
long,
|
||||
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||
);
|
||||
let scan = assemble(
|
||||
vec![entry(
|
||||
0,
|
||||
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg",
|
||||
"Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg",
|
||||
10,
|
||||
)],
|
||||
&sidecars,
|
||||
@@ -1017,6 +1145,104 @@ mod tests {
|
||||
assert_eq!(scan.photos[0].capture_time, Some(2));
|
||||
}
|
||||
|
||||
/// A short sidecar name was not cut, so a prefix of it names another photo:
|
||||
/// `IMG_1.jpg`'s sidecar gave its title and date to `IMG_12.jpg`
|
||||
/// (`docs/BUGS.md` B225).
|
||||
#[test]
|
||||
fn a_photo_without_a_sidecar_does_not_take_a_shorter_named_siblings() {
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Photos/Photos from 2019/IMG_1.jpg.supplemental-metadata.json",
|
||||
r#"{"title":"IMG_1.jpg","photoTakenTime":{"timestamp":"1"}}"#,
|
||||
);
|
||||
let scan = assemble(
|
||||
vec![
|
||||
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_1.jpg", 10),
|
||||
entry(0, "Takeout/Google Photos/Photos from 2019/IMG_12.jpg", 10),
|
||||
],
|
||||
&sidecars,
|
||||
);
|
||||
|
||||
assert_eq!(scan.photos[0].capture_time, Some(1));
|
||||
assert_eq!(scan.photos[1].name, "IMG_12.jpg");
|
||||
assert_eq!(scan.photos[1].capture_time, None);
|
||||
}
|
||||
|
||||
/// An edited copy has no sidecar of its own. It takes its original's date,
|
||||
/// but keeps its own name rather than the original's title.
|
||||
#[test]
|
||||
fn an_edited_copy_takes_its_originals_date_and_keeps_its_name() {
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
"Takeout/Google Fotos/Urlaub/IMG_2.jpg.ergänzende-Metadaten.json",
|
||||
r#"{"title":"Holiday.jpg","photoTakenTime":{"timestamp":"7"}}"#,
|
||||
);
|
||||
let scan = assemble(
|
||||
vec![
|
||||
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2.jpg", 10),
|
||||
entry(0, "Takeout/Google Fotos/Urlaub/IMG_2-bearbeitet.jpg", 10),
|
||||
],
|
||||
&sidecars,
|
||||
);
|
||||
|
||||
assert_eq!(scan.photos[0].name, "Holiday.jpg");
|
||||
assert_eq!(scan.photos[1].name, "IMG_2-bearbeitet.jpg");
|
||||
assert_eq!(scan.photos[1].capture_time, Some(7));
|
||||
}
|
||||
|
||||
/// The index looks only at the sidecars that can match, and must find the
|
||||
/// same one that ranking every sidecar in the folder finds.
|
||||
#[test]
|
||||
fn the_sidecar_index_finds_what_ranking_every_sidecar_finds() {
|
||||
let long = "A_very_long_holiday_photo_name_from_the_beach_2019";
|
||||
let names = [
|
||||
"IMG_1.jpg".to_string(),
|
||||
"IMG_12.jpg".to_string(),
|
||||
"IMG_1(1).jpg".to_string(),
|
||||
"IMG_1-edited.jpg".to_string(),
|
||||
"IMG_1-bearbeitet.jpg".to_string(),
|
||||
"my.photo(2).jpg".to_string(),
|
||||
"x(1).jpg".to_string(),
|
||||
"Ölbild.png".to_string(),
|
||||
"PXL_20230101_101010.mp4".to_string(),
|
||||
format!("{long}.jpg"),
|
||||
format!("{long}_2.jpg"),
|
||||
"nothing.heic".to_string(),
|
||||
];
|
||||
let sidecar_names = [
|
||||
"IMG_1.jpg.supplemental-metadata.json".to_string(),
|
||||
"IMG_1.jpg(1).supplemental-metadata.json".to_string(),
|
||||
"IMG_12.jpg.ergänzende-Metadaten.json".to_string(),
|
||||
"my.photo.jpg(2).json".to_string(),
|
||||
"x.jpg(1).json".to_string(),
|
||||
"Ölbild.png.suppl.json".to_string(),
|
||||
"PXL_20230101_101010.mp4.JSON".to_string(),
|
||||
format!("{}.json", &long[..44]),
|
||||
format!("{long}.jp.json"),
|
||||
"IMG.json".to_string(),
|
||||
"unrelated.json".to_string(),
|
||||
];
|
||||
let bodies: Vec<Vec<u8>> = (0..sidecar_names.len())
|
||||
.map(|i| i.to_string().into_bytes())
|
||||
.collect();
|
||||
let in_folder: BTreeMap<String, &Vec<u8>> =
|
||||
sidecar_names.iter().cloned().zip(bodies.iter()).collect();
|
||||
let index = SidecarIndex::new(&in_folder);
|
||||
for name in &names {
|
||||
let mut every: Option<((u8, usize), &Vec<u8>)> = None;
|
||||
for (sidecar_name, body) in &in_folder {
|
||||
if let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), name)
|
||||
&& every.as_ref().is_none_or(|(current, _)| rank < *current)
|
||||
{
|
||||
every = Some((rank, *body));
|
||||
}
|
||||
}
|
||||
assert_eq!(find_sidecar(name, &index), every, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
||||
let mut sidecars = HashMap::new();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The opt-in check for a newer release ([`crate::config::AppConfig::update_check`]).
|
||||
//!
|
||||
//! Only the decisions live here: what GitHub's answer says, which version is
|
||||
//! newer, when to ask again, and whether a package manager already owns
|
||||
//! updates. The desktop app does the request itself.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// GitHub's latest published release. Drafts and pre-releases never show up
|
||||
/// here, so a tagged release candidate does not reach anyone.
|
||||
pub const RELEASES_LATEST_API: &str =
|
||||
"https://api.github.com/repos/narrrl/proton-drive-linux/releases/latest";
|
||||
|
||||
/// How often the app asks, across restarts.
|
||||
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Installed next to the binaries' `share/` by packages whose package manager
|
||||
/// brings new releases (COPR, the AUR, Nix). The release `.deb`, `.rpm` and
|
||||
/// `.tar.gz` do not ship it, since nothing updates them.
|
||||
pub const MANAGED_MARKER: &str = "share/proton-drive-linux/managed-updates";
|
||||
|
||||
/// The version of the release in a `/releases/latest` answer, without the
|
||||
/// tag's `v`. `None` for an answer that does not parse or a tag that is not
|
||||
/// `X.Y.Z`, so a changed API can never claim an update.
|
||||
pub fn latest_version(body: &[u8]) -> Option<String> {
|
||||
let json: serde_json::Value = serde_json::from_slice(body).ok()?;
|
||||
let tag = json.get("tag_name")?.as_str()?;
|
||||
let version = tag.strip_prefix('v').unwrap_or(tag);
|
||||
parse(version).map(|_| version.to_owned())
|
||||
}
|
||||
|
||||
/// Whether `version` is a later release than `current`. Anything that is not
|
||||
/// `X.Y.Z` on either side is not.
|
||||
pub fn is_newer(version: &str, current: &str) -> bool {
|
||||
matches!((parse(version), parse(current)), (Some(v), Some(c)) if v > c)
|
||||
}
|
||||
|
||||
/// The release page of `version` on GitHub.
|
||||
pub fn release_url(version: &str) -> String {
|
||||
format!("https://github.com/narrrl/proton-drive-linux/releases/tag/v{version}")
|
||||
}
|
||||
|
||||
/// `X.Y.Z` as numbers, so `3.10.0` sorts after `3.9.0`.
|
||||
fn parse(version: &str) -> Option<(u64, u64, u64)> {
|
||||
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
|
||||
let triple = (parts.next()??, parts.next()??, parts.next()??);
|
||||
parts.next().is_none().then_some(triple)
|
||||
}
|
||||
|
||||
/// How long until the next check is due, given the last one in Unix seconds.
|
||||
/// Zero when it is due now. A clock set back past the last check counts as
|
||||
/// due, so a wrong clock cannot stop checks.
|
||||
pub fn next_check_in(last_checked: Option<i64>, now: i64) -> Duration {
|
||||
let interval = CHECK_INTERVAL.as_secs() as i64;
|
||||
match last_checked {
|
||||
Some(last) if (0..interval).contains(&(now - last)) => {
|
||||
Duration::from_secs((interval - (now - last)) as u64)
|
||||
}
|
||||
_ => Duration::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the binary at `exe` was installed by a package manager that updates
|
||||
/// it, which [`MANAGED_MARKER`] next to its `bin/` says.
|
||||
pub fn updates_managed(exe: &Path) -> bool {
|
||||
exe.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|prefix| prefix.join(MANAGED_MARKER).exists())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn latest(tag: &str) -> Vec<u8> {
|
||||
format!(
|
||||
r#"{{"tag_name":"{tag}","html_url":"https://github.com/narrrl/proton-drive-linux/releases/tag/{tag}","draft":false}}"#
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_latest_release_is_read_without_its_v() {
|
||||
assert_eq!(latest_version(&latest("v3.6.0")).as_deref(), Some("3.6.0"));
|
||||
assert_eq!(latest_version(&latest("3.6.0")).as_deref(), Some("3.6.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_that_does_not_parse_has_no_version() {
|
||||
assert_eq!(latest_version(b"not json"), None);
|
||||
assert_eq!(latest_version(b"{}"), None);
|
||||
assert_eq!(latest_version(br#"{"tag_name":7}"#), None);
|
||||
assert_eq!(latest_version(&latest("nightly")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0-rc1")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0")), None);
|
||||
assert_eq!(latest_version(&latest("v4.0.0.1")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_later_release_is_newer() {
|
||||
assert!(is_newer("3.6.0", "3.5.0"));
|
||||
assert!(!is_newer("3.5.0", "3.5.0"));
|
||||
assert!(!is_newer("3.4.1", "3.5.0"));
|
||||
assert!(!is_newer("2.99.99", "3.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_compare_as_numbers() {
|
||||
assert!(is_newer("3.10.0", "3.9.0"));
|
||||
assert!(is_newer("3.5.10", "3.5.9"));
|
||||
assert!(is_newer("4.0.0", "3.99.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_version_that_is_not_x_y_z_is_never_newer() {
|
||||
assert!(!is_newer("4.0.0", "3.5.0-dev"));
|
||||
assert!(!is_newer("garbage", "3.5.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_release_page_uses_the_tag() {
|
||||
assert_eq!(
|
||||
release_url("3.6.0"),
|
||||
"https://github.com/narrrl/proton-drive-linux/releases/tag/v3.6.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_check_is_due_once_a_day() {
|
||||
let day = CHECK_INTERVAL.as_secs() as i64;
|
||||
assert_eq!(next_check_in(None, 1_000), Duration::ZERO);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000), CHECK_INTERVAL);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 1_000 + day - 1),
|
||||
Duration::from_secs(1)
|
||||
);
|
||||
assert_eq!(next_check_in(Some(1_000), 1_000 + day), Duration::ZERO);
|
||||
assert_eq!(
|
||||
next_check_in(Some(1_000), 999),
|
||||
Duration::ZERO,
|
||||
"a clock set back must not stop checks"
|
||||
);
|
||||
}
|
||||
|
||||
/// A unique temp directory removed on drop; avoids a dev-dependency, as in
|
||||
/// [`crate::syncignore`]'s tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static N: AtomicU64 = AtomicU64::new(0);
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"pdfs-update-{}-{}",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marker_next_to_bin_means_a_package_manager_updates() {
|
||||
let prefix = TempDir::new();
|
||||
let exe = prefix.0.join("bin/pdfs-app");
|
||||
assert!(!updates_managed(&exe));
|
||||
let marker = prefix.0.join(MANAGED_MARKER);
|
||||
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
|
||||
std::fs::write(&marker, "").unwrap();
|
||||
assert!(updates_managed(&exe));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::shutdown::Shutdown;
|
||||
use pdfs_core::error::session_revoked;
|
||||
|
||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||
@@ -402,6 +403,12 @@ pub(super) async fn run_event_sync(
|
||||
}
|
||||
break Some(c.clone());
|
||||
}
|
||||
// Refused for good: retrying would be refused the same way
|
||||
// for the life of the daemon. The next login restarts it.
|
||||
Err(e) if session_revoked(&e) => {
|
||||
core.mark_session_expired("seed event cursor", &e);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||
tokio::time::sleep(delay).await;
|
||||
@@ -502,6 +509,10 @@ pub(super) async fn run_photos_event_sync(core: Core) {
|
||||
debug!("no photos volume; photo event sync not started");
|
||||
return;
|
||||
}
|
||||
Err(error) if session_revoked(&error) => {
|
||||
core.mark_session_expired("read the photos root", &error);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(%error, "reading the photos root failed; retrying");
|
||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||
@@ -710,18 +721,26 @@ fn scan_local_once(
|
||||
|
||||
// The walk has no idea how many files it will find, so the job counts what it
|
||||
// has seen and stays indeterminate.
|
||||
let job = transfers.begin_job("Indexing this computer");
|
||||
let job = transfers.begin_background_job("Indexing this computer");
|
||||
job.detail("Scanning your files");
|
||||
let mut failed = false;
|
||||
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");
|
||||
failed = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Prune what this scan did not see, unless it did not see everything:
|
||||
// a path it never reached, or one whose batch failed to write, is not a
|
||||
// path that is gone (`docs/BUGS.md` B217).
|
||||
job.detail("Building the search index");
|
||||
match db.local_finish_scan(generation, now_secs()) {
|
||||
let finished = if failed || walked >= localindex::MAX_ENTRIES {
|
||||
db.local_finish_partial_scan(now_secs())
|
||||
} else {
|
||||
db.local_finish_scan(generation, now_secs())
|
||||
};
|
||||
match finished {
|
||||
Ok(indexed) => info!(
|
||||
walked,
|
||||
indexed,
|
||||
|
||||
@@ -29,6 +29,17 @@ 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);
|
||||
/// Answer a [`CtlRequest::FolderSize`].
|
||||
fn folder_size_reply(size: CoreResult<proton_drive_rs::FolderSize>) -> CtlResponse {
|
||||
match size {
|
||||
Ok(size) => CtlResponse::FolderSize {
|
||||
size: size.size,
|
||||
items: size.descendants,
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||
/// into an error that names what is still shared.
|
||||
fn stop_sharing_reply(
|
||||
@@ -334,6 +345,9 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
||||
budget: core.cache.budget(),
|
||||
pins,
|
||||
online: core.online.load(Ordering::Relaxed),
|
||||
session_expired: core.session_expired(),
|
||||
tokens_unsaved: pdfs_core::auth::tokens_unsaved(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
pending_uploads: queued.uploads.max(0) as u64 + landing,
|
||||
pending_changes: queued.changes.max(0) as u64,
|
||||
}
|
||||
@@ -369,11 +383,12 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
RefreshScope::Photos { .. } => &[],
|
||||
},
|
||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||
R::AddSyncFolder { .. }
|
||||
| R::RemoveSyncFolder { .. }
|
||||
| R::SetSyncFolderMode { .. }
|
||||
| R::SetSyncFolderPaused { .. }
|
||||
| R::ResetSyncFolder { .. }
|
||||
| R::RestoreSyncFolders { .. }
|
||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||
@@ -389,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||
| R::CreatePublicLinkByUid { .. }
|
||||
| R::RemovePublicLink { .. }
|
||||
| R::RemovePublicLinkByUid { .. }
|
||||
| R::UpdatePublicLink { .. }
|
||||
| R::UpdatePublicLinkByUid { .. }
|
||||
| R::StopSharing { .. }
|
||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||
R::LeaveShared { .. }
|
||||
@@ -908,10 +925,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
))),
|
||||
Ok((file, len)) => {
|
||||
let photos = core.photos();
|
||||
// Unless the caller knows better: the photo's own EXIF,
|
||||
// then the file's modification time, which a copy resets.
|
||||
let capture_time = capture_time.or_else(|| {
|
||||
let path = Path::new(&source_path);
|
||||
pdfs_core::exiftime::capture_time_of(path).or_else(|| {
|
||||
let modified =
|
||||
std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||
let since = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some(since.as_secs() as i64)
|
||||
})
|
||||
});
|
||||
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||
capture_time,
|
||||
..Default::default()
|
||||
};
|
||||
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||
let guard =
|
||||
core.transfers
|
||||
.begin(name.clone(), "", TransferDirection::Upload, len);
|
||||
@@ -921,7 +950,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
&media_type,
|
||||
reader,
|
||||
len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
metadata,
|
||||
false,
|
||||
)) {
|
||||
@@ -938,6 +967,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// The reply goes to the app's toast only; without
|
||||
// this the journal shows a failure and no reason.
|
||||
warn!(error = %e, "photo upload failed");
|
||||
CtlResponse::error(CoreError::from_api(&e, "upload photo failed"))
|
||||
}
|
||||
}
|
||||
@@ -1045,6 +1077,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Copy { path, new_parent }) => {
|
||||
match (
|
||||
rel_to_mount(mountpoint, &path),
|
||||
rel_to_mount(mountpoint, &new_parent),
|
||||
) {
|
||||
(Ok(rel), Ok(parent_rel)) => match core.copy_to(&rel, &parent_rel) {
|
||||
Ok(name) => {
|
||||
let dest = if new_parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
new_parent.clone()
|
||||
};
|
||||
core.log_activity(ActivityKind::Copy, &name, format!("to {dest}"), true);
|
||||
CtlResponse::Ok {
|
||||
message: format!("copied to {name}"),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
core.log_activity(ActivityKind::Copy, &path, &e, false);
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
(Err(e), _) | (_, Err(e)) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::FolderSize { path }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => folder_size_reply(core.folder_size(&rel)),
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::FolderSizeByUid { uid }) => folder_size_reply(core.folder_size_by_uid(&uid)),
|
||||
Ok(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.delete(&rel) {
|
||||
Ok(name) => {
|
||||
@@ -1297,6 +1359,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::MigrateDevice { uid }) => match core.migrate_device(&uid) {
|
||||
Ok(message) => CtlResponse::Ok { message },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||
// Registering the device and uploading a folder tree far outlasts the
|
||||
// socket read timeout, so ack immediately and work on a background
|
||||
@@ -1367,6 +1433,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::ResetSyncFolder { id }) => match core.reset_sync_folder(id) {
|
||||
Ok(message) => {
|
||||
core.log_activity(ActivityKind::Sync, &message, "", true);
|
||||
CtlResponse::Ok { message }
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SyncNow { id }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
@@ -1453,9 +1526,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
let core = core.clone();
|
||||
std::thread::spawn(move || {
|
||||
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||
Ok(message) => {
|
||||
Ok(outcome) => {
|
||||
core.touch_profile();
|
||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||
if !outcome.complete() {
|
||||
warn!(skipped = ?outcome.skipped, "restore skipped folders");
|
||||
}
|
||||
core.log_activity(
|
||||
ActivityKind::Download,
|
||||
outcome.summary(),
|
||||
outcome.skipped.join("; "),
|
||||
outcome.complete(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "restore failed");
|
||||
@@ -1608,6 +1689,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
CtlResponse::error(e)
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLink {
|
||||
path,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => {
|
||||
match core.update_public_link(&rel, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::UpdatePublicLinkByUid {
|
||||
uid,
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
}) => match core.update_public_link_by_uid(&uid, &id, role.as_deref(), password, expires) {
|
||||
Ok(link) => CtlResponse::PublicLink { link },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||
Ok(()) => {
|
||||
|
||||
+254
-22
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
||||
};
|
||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||
use pdfs_core::mounts::MountMode;
|
||||
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||
use proton_drive_rs::{DeviceType, Node};
|
||||
@@ -104,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option<String> {
|
||||
(!uid.is_empty()).then_some(uid)
|
||||
}
|
||||
|
||||
/// The paths no sync root may overlap, with the mountpoint as the config says
|
||||
/// now.
|
||||
pub(crate) fn sync_root_reserved() -> pdfs_core::syncroot::Reserved {
|
||||
let config = pdfs_core::config::AppDirs::new()
|
||||
.map(|dirs| dirs.load_config())
|
||||
.unwrap_or_default();
|
||||
pdfs_core::syncroot::Reserved::current(&config)
|
||||
}
|
||||
|
||||
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
|
||||
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
let dirs = pdfs_core::config::AppDirs::new()
|
||||
@@ -114,6 +124,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||
.map_err(|e| CoreError::internal(format!("write config: {e}")))
|
||||
}
|
||||
|
||||
/// The name a folder moved in from `device` takes when `name` is already
|
||||
/// taken under this device's root, or `None` when it can keep its own.
|
||||
fn migrated_name(name: &str, device: &str, taken: &[String]) -> Option<String> {
|
||||
let free = |candidate: &str| !taken.iter().any(|t| t == candidate);
|
||||
if free(name) {
|
||||
return None;
|
||||
}
|
||||
let base = format!("{name} ({device})");
|
||||
if free(&base) {
|
||||
return Some(base);
|
||||
}
|
||||
(2..)
|
||||
.map(|n| format!("{base} {n}"))
|
||||
.find(|candidate| free(candidate))
|
||||
}
|
||||
|
||||
impl Core {
|
||||
// ---- devices ----------------------------------------------------------
|
||||
|
||||
@@ -140,7 +166,7 @@ impl Core {
|
||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||
let devices = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||
// No cached device row yet means this machine syncs nothing, so none of
|
||||
// the listed devices is ours.
|
||||
@@ -197,7 +223,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let dev = remote
|
||||
.into_iter()
|
||||
@@ -240,7 +266,7 @@ impl Core {
|
||||
};
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
let d = remote
|
||||
.into_iter()
|
||||
@@ -260,6 +286,107 @@ impl Core {
|
||||
})
|
||||
}
|
||||
|
||||
/// Move every folder another device backs up into this machine's own
|
||||
/// device, so this machine carries on backing them up as itself. The other
|
||||
/// device stays registered, without those folders.
|
||||
///
|
||||
/// Device roots all live on the main volume, so each folder is one
|
||||
/// server-side move: nothing is downloaded or uploaded, and no quota is
|
||||
/// used twice. A folder whose name is taken here keeps its content under
|
||||
/// "<name> (<device>)". A failed folder does not stop the others, and a
|
||||
/// second run picks up whatever is still left on the other device.
|
||||
///
|
||||
/// Nothing is synced to this disk here. The moved folders are now this
|
||||
/// device's, so the ordinary restore offers them. Only folders that were
|
||||
/// already synced here from the other device are touched: their uid
|
||||
/// survives the move but their share does not.
|
||||
pub(crate) fn migrate_device(&self, uid: &str) -> CoreResult<String> {
|
||||
let source = self.restore_source(Some(uid))?;
|
||||
let target = self.ensure_device()?;
|
||||
if source.uid == target.uid {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is this computer's own device",
|
||||
source.name
|
||||
)));
|
||||
}
|
||||
let bad_root = |d: &StoredDevice| {
|
||||
parse_uid(&d.root_uid)
|
||||
.ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", d.root_uid)))
|
||||
};
|
||||
let (source_root, target_root) = (bad_root(&source)?, bad_root(&target)?);
|
||||
if source_root.volume_id != target_root.volume_id {
|
||||
return Err(CoreError::invalid(
|
||||
"the two devices are on different Proton Drive volumes; copy it instead",
|
||||
));
|
||||
}
|
||||
|
||||
let mut taken: Vec<String> = self
|
||||
.device_root_nodes(&target_root)?
|
||||
.into_iter()
|
||||
.filter(|n| !n.trashed)
|
||||
.map(|n| n.name)
|
||||
.collect();
|
||||
let synced = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let mut moved = 0usize;
|
||||
let mut renamed: Vec<String> = Vec::new();
|
||||
let mut left: Vec<String> = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
for node in self.device_root_nodes(&source_root)? {
|
||||
if node.trashed || node.name == PROFILE_DIR_NAME {
|
||||
continue;
|
||||
}
|
||||
// A device root only ever holds folders (B68); anything else was
|
||||
// put there by hand and is left for the user to deal with.
|
||||
if !node.is_folder() {
|
||||
left.push(node.name);
|
||||
continue;
|
||||
}
|
||||
let new_name = migrated_name(&node.name, &source.name, &taken);
|
||||
if let Err(e) = self.move_rename_remote(&node.uid, &target_root, new_name.as_deref()) {
|
||||
warn!(name = %node.name, error = %e, "migrate: could not move folder");
|
||||
failed.push(format!("{}: {e}", node.name));
|
||||
continue;
|
||||
}
|
||||
let landed = new_name.clone().unwrap_or_else(|| node.name.clone());
|
||||
if new_name.is_some() {
|
||||
renamed.push(format!("{} -> {landed}", node.name));
|
||||
}
|
||||
taken.push(landed);
|
||||
moved += 1;
|
||||
|
||||
let uid = node.uid.to_string();
|
||||
for folder in synced.iter().filter(|f| f.remote_uid == uid) {
|
||||
if let Err(e) = self.db.sync_folder_set_share(folder.id, &target.share_id) {
|
||||
warn!(id = folder.id, error = ?e, "migrate: could not re-point sync folder");
|
||||
}
|
||||
self.sync_now(Some(folder.id));
|
||||
}
|
||||
}
|
||||
self.invalidate_children_of(&source_root);
|
||||
self.invalidate_children_of(&target_root);
|
||||
|
||||
let mut message = format!("moved {moved} folder(s) from {}", source.name);
|
||||
if !renamed.is_empty() {
|
||||
message.push_str(&format!("; renamed: {}", renamed.join(", ")));
|
||||
}
|
||||
if !left.is_empty() {
|
||||
message.push_str(&format!("; left behind: {}", left.join(", ")));
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
message.push_str(&format!("; failed: {}", failed.join(", ")));
|
||||
}
|
||||
info!(from = %source.name, to = %target.name, moved, failed = failed.len(),
|
||||
"device migrated");
|
||||
if moved == 0 && !failed.is_empty() {
|
||||
return Err(CoreError::conflict(message));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
// ---- device folder sync -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
@@ -277,7 +404,7 @@ impl Core {
|
||||
// any cached record, and recover an existing device by name.
|
||||
let remote = self
|
||||
.rt
|
||||
.block_on(self.client.enumerate_devices())
|
||||
.block_on(self.drive.enumerate_devices())
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||
|
||||
// An adopted uid is an explicit instruction, so it outranks everything
|
||||
@@ -336,7 +463,7 @@ impl Core {
|
||||
None => {
|
||||
let d = self
|
||||
.rt
|
||||
.block_on(self.client.create_device(&name, DeviceType::Linux))
|
||||
.block_on(self.drive.create_device(&name, DeviceType::Linux))
|
||||
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||
StoredDevice {
|
||||
uid: d.uid.to_string(),
|
||||
@@ -360,21 +487,25 @@ impl Core {
|
||||
root_uid: &NodeUid,
|
||||
name: &str,
|
||||
) -> CoreResult<Option<NodeUid>> {
|
||||
Ok(self
|
||||
.device_root_nodes(root_uid)?
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// Everything directly under a device root, trashed nodes included.
|
||||
fn device_root_nodes(&self, root_uid: &NodeUid) -> CoreResult<Vec<Node>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
self.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
||||
.map(|n| n.uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||
}
|
||||
|
||||
/// Add a local folder to this device's sync set: register the device if
|
||||
@@ -395,18 +526,20 @@ impl Core {
|
||||
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
||||
let local_str = local.to_string_lossy().to_string();
|
||||
|
||||
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
|
||||
if self
|
||||
// `/`, the home folder, the mount, this app's own data, and a folder
|
||||
// inside or around another root are refused (`syncroot::problem`). A
|
||||
// duplicate is one of those, refused here for a clear error before
|
||||
// the UNIQUE constraint would catch it.
|
||||
let others = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.iter()
|
||||
.any(|f| f.local_path == local_str)
|
||||
{
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is already synced",
|
||||
local.display()
|
||||
)));
|
||||
.map(|f| PathBuf::from(&f.local_path))
|
||||
.collect::<Vec<_>>();
|
||||
let reserved = sync_root_reserved();
|
||||
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
|
||||
return Err(CoreError::invalid(problem));
|
||||
}
|
||||
|
||||
let name = local
|
||||
@@ -535,12 +668,16 @@ impl Core {
|
||||
} else {
|
||||
p.total.max(p.done) as u64
|
||||
},
|
||||
// Most passes find nothing; only one that queues work is
|
||||
// the drive syncing.
|
||||
background: true,
|
||||
},
|
||||
SyncPhase::Applying => JobItem {
|
||||
title: format!("Syncing {folder}"),
|
||||
detail: p.current.clone(),
|
||||
done: p.done as u64,
|
||||
total: p.total.max(p.done) as u64,
|
||||
background: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -842,6 +979,29 @@ impl Core {
|
||||
if folder.state != "idle" {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
// `idle` only says the last pass applied everything it tried.
|
||||
// It never tries an ignored path, a symlink or a folder it
|
||||
// cannot read, it defers a file open for writing, and a file can
|
||||
// change after it ends. The eviction below deletes all of those
|
||||
// for good, so it waits until nothing here is missing from Drive
|
||||
// (B198).
|
||||
let baseline = self
|
||||
.db
|
||||
.sync_entries(id)
|
||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?;
|
||||
let rules = self.ignore_rules(&local);
|
||||
// Emptied before any pass saw it, the folder is still `idle`, and
|
||||
// the check below would refuse the switch for good. The push pass
|
||||
// of a queued switch forgets those paths instead (`docs/BUGS.md`
|
||||
// B255).
|
||||
if sync::mirror_tree_gone(&local, &baseline, &rules) {
|
||||
return Err(SwitchBlocked::NotNow);
|
||||
}
|
||||
if let Some(reason) = sync::mirror_tree_unsynced(&local, &baseline, &rules) {
|
||||
return Err(SwitchBlocked::Failed(format!(
|
||||
"going online-only would delete what is only on this computer: {reason}"
|
||||
)));
|
||||
}
|
||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||
})?;
|
||||
@@ -926,6 +1086,50 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget what a mirror folder synced before, so its next pass compares the
|
||||
/// two sides as on the first sync: what one side has alone is copied to the
|
||||
/// other, and a file that differs is kept twice as a conflict. Nothing is
|
||||
/// deleted on either side.
|
||||
///
|
||||
/// This is the way out for a folder whose passes refuse to run because its
|
||||
/// whole local copy, or its whole copy on Drive, is gone (#46). The baseline
|
||||
/// is what makes that read as "delete everything", and it is what this
|
||||
/// drops. A queued switch to on-demand is dropped with it: the push pass
|
||||
/// that switch runs reads an unrecorded local file as new and would upload
|
||||
/// a second copy of every file Drive already has.
|
||||
pub(crate) fn reset_sync_folder(&self, id: i64) -> CoreResult<String> {
|
||||
let lock = self.sync_lock(id);
|
||||
let Some(_guard) = lock.try_lock() else {
|
||||
return Err(CoreError::conflict(
|
||||
"this folder is syncing; try again when the pass ends",
|
||||
));
|
||||
};
|
||||
let folder = self
|
||||
.db
|
||||
.sync_folder_get(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
if folder.mode != "mirror" {
|
||||
return Err(CoreError::invalid(
|
||||
"an online-only folder keeps nothing on this computer to sync from scratch",
|
||||
));
|
||||
}
|
||||
self.db
|
||||
.sync_entries_clear(id)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
if folder.pending_mode.as_deref() == Some("ondemand") {
|
||||
self.db
|
||||
.sync_folder_set_pending_mode(id, None)
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
info!(id, path = %folder.local_path, "sync folder reset");
|
||||
Ok(format!(
|
||||
"{} will sync from scratch",
|
||||
base_name(&folder.local_path)
|
||||
))
|
||||
}
|
||||
|
||||
/// Spawn a secondary FUSE session rooted at `root` over `local` from a
|
||||
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||
/// one, which would fail the fresh mount with EBUSY).
|
||||
@@ -1107,10 +1311,38 @@ mod tests {
|
||||
use pdfs_core::db::StoredSyncFolder;
|
||||
|
||||
use super::{
|
||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||
restore_snapshot_matches, select_mode_transition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_keeps_its_name_when_it_is_free() {
|
||||
let taken = vec!["Pictures".to_string()];
|
||||
assert_eq!(migrated_name("Documents", "DESKTOP-1", &taken), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_named_after_its_device_on_a_clash() {
|
||||
let taken = vec!["Documents".to_string()];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1)")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_folder_is_numbered_when_the_device_name_is_taken_too() {
|
||||
let taken = vec![
|
||||
"Documents".to_string(),
|
||||
"Documents (DESKTOP-1)".to_string(),
|
||||
"Documents (DESKTOP-1) 2".to_string(),
|
||||
];
|
||||
assert_eq!(
|
||||
migrated_name("Documents", "DESKTOP-1", &taken).as_deref(),
|
||||
Some("Documents (DESKTOP-1) 3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||
let mut unmounts = 0;
|
||||
|
||||
@@ -163,6 +163,7 @@ impl Core {
|
||||
control_handlers: super::control::active_handlers(),
|
||||
control_handler_limit: super::control::handler_limit(),
|
||||
pending_ops: self.pending_ops_quickly(),
|
||||
sync: Some(self.sync_engine.report()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+133
-42
@@ -347,17 +347,7 @@ impl Core {
|
||||
// its writer has to be noticed.
|
||||
self.sweep_parked_creates();
|
||||
}
|
||||
// Paused, nothing due changes that: sleep until a resume
|
||||
// wakes the worker (or the idle poll notices a timed pause
|
||||
// ran out), not until the next op falls due — that is
|
||||
// "now" for everything already queued.
|
||||
if paused {
|
||||
self.wait_for_drain_work();
|
||||
continue;
|
||||
}
|
||||
// A debounced or backed-off op may be waiting: sleep only
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
self.wait_for_drain_work_or_due();
|
||||
self.wait_for_drain_work_or_due(online && !paused);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -399,13 +389,7 @@ impl Core {
|
||||
// A name a change of ours is about to free is a wait, not a
|
||||
// failure: the trash or rename holding it lands in moments.
|
||||
let held = waits_for_name(e.as_ref());
|
||||
let backoff = if held {
|
||||
DRAIN_BACKOFF_MIN
|
||||
} else {
|
||||
DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.min(6))
|
||||
.min(DRAIN_BACKOFF_MAX)
|
||||
};
|
||||
let backoff = drain_backoff(attempts, held, op.id);
|
||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
||||
if let Err(e) = self.db.record_attempt_failure(
|
||||
op.id,
|
||||
@@ -732,14 +716,16 @@ impl Core {
|
||||
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
|
||||
/// checks whether a debounced or backed-off op is due before the idle-poll
|
||||
/// ceiling. A 2-second debounce must not sleep 30 seconds.
|
||||
fn wait_for_drain_work_or_due(&self) {
|
||||
let timeout = match self.db.earliest_due_at() {
|
||||
Ok(Some(ts)) => {
|
||||
let remaining = (ts - now_millis()).max(0) as u64;
|
||||
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL)
|
||||
}
|
||||
_ => DRAIN_IDLE_POLL,
|
||||
///
|
||||
/// `claiming` is false while the worker claims nothing (offline or
|
||||
/// paused); see [`idle_wait`].
|
||||
fn wait_for_drain_work_or_due(&self, claiming: bool) {
|
||||
let earliest = if claiming {
|
||||
self.db.earliest_due_at().ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let timeout = idle_wait(claiming, earliest, now_millis());
|
||||
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
|
||||
}
|
||||
|
||||
@@ -1126,7 +1112,7 @@ impl Core {
|
||||
_ => None,
|
||||
};
|
||||
let sent = self.create_sent_digests(op.id, current);
|
||||
Ok(adoptable(is_dir, &twin, op.created_at, &sent).then_some(twin.uid))
|
||||
Ok(adoptable(is_dir, &twin, self.first_sent(op), &sent).then_some(twin.uid))
|
||||
}
|
||||
|
||||
/// Record the name the remote actually gave a node, after a conflict forced
|
||||
@@ -1521,8 +1507,10 @@ impl Core {
|
||||
.as_deref()
|
||||
.is_some_and(|sha| holds_bytes(&twin, sha));
|
||||
let sent = self.create_sent_digests(op.id, current);
|
||||
Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at, &sent)
|
||||
.then_some((twin.uid, holds_blob)))
|
||||
Ok(
|
||||
adoptable(op.kind == OP_MKDIR, &twin, self.first_sent(op), &sent)
|
||||
.then_some((twin.uid, holds_blob)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The SHA-1s of the bytes queued create `id` may have made its file with:
|
||||
@@ -1537,6 +1525,22 @@ impl Core {
|
||||
sent
|
||||
}
|
||||
|
||||
/// When create `op` first went out, which is the earliest a node its lost
|
||||
/// answer left can have been made. An op queued offline may wait for days
|
||||
/// before that; measured from when it was queued, the adoption window let
|
||||
/// it take a node another device made in the meantime. Ops sent before the
|
||||
/// time was recorded fall back to when they were queued.
|
||||
fn first_sent(&self, op: &PendingOp) -> i64 {
|
||||
match self.db.create_first_sent(op.id) {
|
||||
Ok(Some(at)) => at.max(op.created_at),
|
||||
Ok(None) => op.created_at,
|
||||
Err(e) => {
|
||||
warn!(id = op.id, error = %e, "reading when a queued create was sent failed");
|
||||
op.created_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make one queued `create`/`mkdir` real under a given name, and hand back
|
||||
/// the API's own error so the caller can tell a name clash from a failure.
|
||||
///
|
||||
@@ -1549,7 +1553,7 @@ impl Core {
|
||||
name: &str,
|
||||
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||
self.db
|
||||
.note_create_target(op.id, &parent.to_string(), name)?;
|
||||
.note_create_target(op.id, &parent.to_string(), name, now_millis())?;
|
||||
match op.kind == OP_MKDIR {
|
||||
true => Ok(self.block_on_bounded(self.drive.create_folder(
|
||||
parent,
|
||||
@@ -1594,7 +1598,7 @@ impl Core {
|
||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||
let uid = self.block_on_within(
|
||||
link::upload_deadline(meta.len),
|
||||
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||
self.drive.upload_file_from(
|
||||
parent,
|
||||
name,
|
||||
@@ -1991,7 +1995,7 @@ impl Core {
|
||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||
self.block_on_within(
|
||||
link::upload_deadline(meta.len),
|
||||
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||
self.drive.upload_file_from(
|
||||
&parent,
|
||||
&alt,
|
||||
@@ -2244,7 +2248,7 @@ impl Core {
|
||||
.clone()
|
||||
.ok_or("pending op has no staged blob")?;
|
||||
let blob = PathBuf::from(blob);
|
||||
let meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
||||
let mut meta: StagedWrite = serde_json::from_str(op.meta_json.as_deref().unwrap_or(""))?;
|
||||
let uid = parse_node_uid(&meta.uid).ok_or("staged write has an unparseable uid")?;
|
||||
|
||||
if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? {
|
||||
@@ -2283,6 +2287,22 @@ impl Core {
|
||||
&written,
|
||||
)
|
||||
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
||||
// The blob is whole now. Recorded, so that a retry after a lost
|
||||
// answer can match these bytes against the server's revision
|
||||
// (`revision_conflict`) instead of forking a conflict copy of the
|
||||
// user's own write.
|
||||
meta.complete = true;
|
||||
match serde_json::to_string(&meta) {
|
||||
Ok(json) => {
|
||||
let path = op.blob_path.as_deref().unwrap_or_default();
|
||||
if let Err(e) = self.db.update_op_meta_for_blob(op.id, path, &json) {
|
||||
warn!(%uid, error = %e,
|
||||
"recording a gap-filled blob as whole failed; \
|
||||
a lost answer may land it as a conflict copy");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(%uid, error = %e, "serializing a gap-filled sidecar failed"),
|
||||
}
|
||||
}
|
||||
|
||||
let name = self.node_name(&uid);
|
||||
@@ -2303,7 +2323,7 @@ impl Core {
|
||||
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
||||
let started = Instant::now();
|
||||
let sent = self.block_on_within(
|
||||
link::upload_deadline(meta.len),
|
||||
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||
self.drive
|
||||
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
|
||||
);
|
||||
@@ -2678,7 +2698,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot
|
||||
/// is, or `Some(None)` for a failure that is not a refusal.
|
||||
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
||||
let proton = proton_error(e)?;
|
||||
if is_network_error(proton) {
|
||||
// A refused session refuses everything, so it says nothing about this
|
||||
// op either; the mount is offline for it (`Core::lost_link`).
|
||||
if is_network_error(proton) || pdfs_core::error::session_revoked(proton) {
|
||||
return None;
|
||||
}
|
||||
let ProtonError::Api(api) = proton else {
|
||||
@@ -2759,24 +2781,43 @@ fn waits_for_name(e: &(dyn std::error::Error + 'static)) -> bool {
|
||||
std::iter::successors(Some(e), |e| e.source()).any(|e| e.is::<NameHeld>())
|
||||
}
|
||||
|
||||
/// How long an op waits after its `attempts`-th failure: doubling from
|
||||
/// [`DRAIN_BACKOFF_MIN`] up to [`DRAIN_BACKOFF_MAX`], plus up to a fifth more,
|
||||
/// different for each op (`seed`), so that ops that failed together are not
|
||||
/// all retried in the same second. One that waits for a name (`held`) waits
|
||||
/// the minimum.
|
||||
///
|
||||
/// The doubling stopped at 2^6, 128 s, so the maximum was never reached.
|
||||
fn drain_backoff(attempts: i64, held: bool, seed: i64) -> Duration {
|
||||
if held {
|
||||
return DRAIN_BACKOFF_MIN;
|
||||
}
|
||||
let base = DRAIN_BACKOFF_MIN
|
||||
.saturating_mul(1u32 << attempts.clamp(0, 8))
|
||||
.min(DRAIN_BACKOFF_MAX);
|
||||
// A cheap, stable spread: the same op and attempt always wait the same.
|
||||
let spread = (seed.wrapping_mul(0x9E37_79B9).wrapping_add(attempts) as u64 % 21) as u32;
|
||||
base + base * spread / 100
|
||||
}
|
||||
|
||||
/// How many attempts an op waits for a rename or a create on the wire to free
|
||||
/// the name it wants. Two files swapping names each wait for the other, so the
|
||||
/// wait has to end; a conflict name ends it.
|
||||
const NAME_HOLD_ATTEMPTS: i64 = 4;
|
||||
|
||||
/// How much older than its op a remote node may be and still be the one an
|
||||
/// unanswered call made. The op is queued the moment the call gives up, so the
|
||||
/// node is at most one call's deadline older; the rest is clock skew between
|
||||
/// this machine and Proton.
|
||||
/// How much older than its op's first send a remote node may be and still be
|
||||
/// the one an unanswered call made. The node is made during that call, so it
|
||||
/// is at most one call's deadline older; the rest is clock skew between this
|
||||
/// machine and Proton.
|
||||
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
||||
|
||||
/// Whether `twin`, found holding the name a queued create wants, is the node an
|
||||
/// earlier, unanswered attempt at that create made: the same kind, created
|
||||
/// around when the op was queued, and for a file still empty, as the mount's
|
||||
/// earlier, unanswered attempt at that create made: the same kind, created no
|
||||
/// earlier than around when the op was first sent (`first_sent_ms`), and for a file still empty, as the mount's
|
||||
/// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is
|
||||
/// one of `sent`.
|
||||
fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64, sent: &[String]) -> bool {
|
||||
if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS {
|
||||
fn adoptable(is_dir: bool, twin: &Node, first_sent_ms: i64, sent: &[String]) -> bool {
|
||||
if twin.creation_time.saturating_mul(1000) < first_sent_ms - ADOPT_WINDOW_MS {
|
||||
return false;
|
||||
}
|
||||
match &twin.kind {
|
||||
@@ -2833,6 +2874,23 @@ fn sleep_for_drain_work(
|
||||
*woken = false;
|
||||
}
|
||||
|
||||
/// How long an idle drain worker sleeps: until the earliest queued op falls
|
||||
/// due, at most the idle poll.
|
||||
///
|
||||
/// A worker that claims nothing — offline or paused — sleeps the full idle
|
||||
/// poll whatever is due. Every op already queued is due "now" for it, so
|
||||
/// sleeping until then was a zero wait, and all the workers spun on the queue
|
||||
/// for as long as the outage lasted (`docs/BUGS.md` B200). A reconnect or a
|
||||
/// resume wakes the drain, so nothing waits the full poll for it.
|
||||
fn idle_wait(claiming: bool, earliest_due: Option<i64>, now: i64) -> Duration {
|
||||
match earliest_due {
|
||||
Some(ts) if claiming => {
|
||||
Duration::from_millis((ts - now).max(0) as u64).min(DRAIN_IDLE_POLL)
|
||||
}
|
||||
_ => DRAIN_IDLE_POLL,
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_in(st: &crate::state::State, uid: &NodeUid) -> Option<(String, Option<NodeUid>)> {
|
||||
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
|
||||
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
||||
@@ -2858,6 +2916,23 @@ mod tests {
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_worker_waits_the_idle_poll_for_a_past_due_op() {
|
||||
// The op fell due long ago, and offline nobody attempts it, so it
|
||||
// stays due: waiting for it was a zero wait, forever (B200).
|
||||
assert_eq!(idle_wait(false, Some(0), 10_000), DRAIN_IDLE_POLL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claiming_worker_waits_only_until_the_next_op_falls_due() {
|
||||
assert_eq!(
|
||||
idle_wait(true, Some(12_000), 10_000),
|
||||
Duration::from_secs(2)
|
||||
);
|
||||
assert_eq!(idle_wait(true, Some(0), 10_000), Duration::ZERO);
|
||||
assert_eq!(idle_wait(true, None, 10_000), DRAIN_IDLE_POLL);
|
||||
}
|
||||
|
||||
fn pending(kind: &str) -> PendingOp {
|
||||
PendingOp {
|
||||
id: 0,
|
||||
@@ -3324,6 +3399,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_op_backs_off_to_the_maximum_with_a_spread() {
|
||||
let at = |attempts| drain_backoff(attempts, false, 7);
|
||||
assert!(at(1) >= DRAIN_BACKOFF_MIN * 2);
|
||||
assert!(at(2) > at(1));
|
||||
// Reached, which the doubling capped at 2^6 never did.
|
||||
assert!(at(8) >= DRAIN_BACKOFF_MAX);
|
||||
assert!(at(30) <= DRAIN_BACKOFF_MAX + DRAIN_BACKOFF_MAX / 5);
|
||||
// Ops that failed together do not all come back together.
|
||||
let waits: std::collections::HashSet<_> =
|
||||
(1..=20).map(|id| drain_backoff(8, false, id)).collect();
|
||||
assert!(waits.len() > 5, "{waits:?}");
|
||||
// A wait for a name stays short however often it repeats.
|
||||
assert_eq!(drain_backoff(30, true, 7), DRAIN_BACKOFF_MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
|
||||
// The op was queued at t=1000 s, after a create that got no answer.
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
//! real client implements it by forwarding, and the simulation tests implement
|
||||
//! it with an in-memory Drive that injects Drive's known faults.
|
||||
//!
|
||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
||||
//! client (`Core::client`) until a test needs them faked.
|
||||
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||
//! stay on the concrete client (`Core::client`) until a test needs them faked.
|
||||
//! Listing and registering devices are here, so a simulated daemon can sync a
|
||||
//! local folder.
|
||||
//!
|
||||
//! The method names match the client's own, so a call site reads the same
|
||||
//! whichever it goes through. The client's generic methods (a reader or writer
|
||||
@@ -26,7 +28,8 @@ use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::error::Result;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeMoveItem, ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
AvailableNameTarget, Device, DeviceType, DriveEvent, DriveEventScopeId, Node, NodeMoveItem,
|
||||
ProtonDriveClient, RevisionReader, Thumbnail,
|
||||
};
|
||||
|
||||
/// One outcome per node of a batch request; see [`ProtonDriveClient::trash_nodes`].
|
||||
@@ -135,6 +138,22 @@ pub(crate) trait DriveApi: Send + Sync {
|
||||
|
||||
async fn move_node(&self, uid: &NodeUid, new_parent: &NodeUid) -> Result<()>;
|
||||
|
||||
/// See [`ProtonDriveClient::copy_node`].
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid>;
|
||||
|
||||
/// See [`ProtonDriveClient::get_available_name`].
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String>;
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
@@ -154,6 +173,12 @@ pub(crate) trait DriveApi: Send + Sync {
|
||||
/// The account's storage, across every Proton product.
|
||||
async fn quota(&self) -> Result<Quota>;
|
||||
|
||||
/// Every computer that backs up to the account.
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>>;
|
||||
|
||||
/// Register a computer, with a root folder of its own named `name`.
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device>;
|
||||
|
||||
/// Called by the drain once it has let go of the blob of an upload to
|
||||
/// `uid` that landed, or retired the create that made `uid`, before the
|
||||
/// tree has the node, so a simulation can hold it there. Blocks the drain
|
||||
@@ -439,6 +464,24 @@ impl DriveApi for ProtonDriveClient {
|
||||
ProtonDriveClient::move_node(self, uid, new_parent).await
|
||||
}
|
||||
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid> {
|
||||
ProtonDriveClient::copy_node(self, uid, new_parent, name).await
|
||||
}
|
||||
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String> {
|
||||
ProtonDriveClient::get_available_name(self, parent_uid, name, target).await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
@@ -470,4 +513,12 @@ impl DriveApi for ProtonDriveClient {
|
||||
async fn quota(&self) -> Result<Quota> {
|
||||
ProtonDriveClient::quota(self).await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Device>> {
|
||||
ProtonDriveClient::enumerate_devices(self).await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Device> {
|
||||
ProtonDriveClient::create_device(self, name, device_type).await
|
||||
}
|
||||
}
|
||||
@@ -235,6 +235,8 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
used,
|
||||
budget,
|
||||
online,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
..
|
||||
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||
);
|
||||
let key = format!(
|
||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||
{used}/{budget}/{online}"
|
||||
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||
);
|
||||
(key, queue)
|
||||
}
|
||||
@@ -325,6 +327,9 @@ mod tests {
|
||||
budget: 0,
|
||||
pins: Vec::new(),
|
||||
online: true,
|
||||
session_expired: false,
|
||||
tokens_unsaved: false,
|
||||
version: "3.3.0".into(),
|
||||
pending_uploads: 1,
|
||||
pending_changes: 0,
|
||||
};
|
||||
|
||||
+373
-126
@@ -261,10 +261,13 @@ impl Filesystem for ProtonFs {
|
||||
// in-flight (possibly unsaved) content: authored bytes come from the
|
||||
// scratch file, untouched bytes from the base.
|
||||
//
|
||||
// This path stays on the dispatch loop. `write` runs there too, so
|
||||
// keeping its reads there as well preserves the ordering the write path
|
||||
// has always had between a write and a read of the same handle; a mostly
|
||||
// local read is not worth reasoning about that for.
|
||||
// Answered on the dispatch loop when everything it needs is on this
|
||||
// machine, which keeps the order `write` has with reads of the same
|
||||
// handle. A base range that has to be downloaded goes to a worker
|
||||
// instead, with the handle as it was when the read came in: the kernel
|
||||
// issued the read before any write that comes after it, so that is
|
||||
// the content it asked for. On the loop, the download held every
|
||||
// request of the mount (`docs/BUGS.md` B227).
|
||||
let handle = {
|
||||
let st = self.core.state();
|
||||
st.active_writes.get(&ino.0).map(|h| {
|
||||
@@ -279,7 +282,7 @@ impl Filesystem for ProtonFs {
|
||||
})
|
||||
};
|
||||
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||
match self.core.serve_open_read(
|
||||
match self.core.serve_open_read_local(
|
||||
&file,
|
||||
len,
|
||||
&uid,
|
||||
@@ -289,7 +292,25 @@ impl Filesystem for ProtonFs {
|
||||
offset,
|
||||
size as u64,
|
||||
) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Ok(Some(bytes)) => reply.data(&bytes),
|
||||
Ok(None) => {
|
||||
let fs = self.clone();
|
||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||
match fs.core.serve_open_read(
|
||||
&file,
|
||||
len,
|
||||
&uid,
|
||||
base_mtime,
|
||||
base_size,
|
||||
&written,
|
||||
offset,
|
||||
size as u64,
|
||||
) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => reply.error(e),
|
||||
}
|
||||
return;
|
||||
@@ -409,58 +430,10 @@ impl Filesystem for ProtonFs {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let fh = fh.0;
|
||||
// Stage the bytes straight into the scratch file (no base download): only
|
||||
// the untouched remainder is pulled from the remote, and only at commit.
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(e) = file.write_all_at(data, offset) {
|
||||
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||
// application can say so (and so `cp`/editors take their own
|
||||
// out-of-space path), and free what the cache is holding — the
|
||||
// scratch file the next write goes to shares that filesystem.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
return;
|
||||
match self.stage_write(ino.0, fh.0, offset, data) {
|
||||
Ok(()) => reply.written(data.len() as u32),
|
||||
Err(error) => reply.error(error),
|
||||
}
|
||||
let new_len = {
|
||||
let mut st = self.core.state();
|
||||
let Some(aw) = st
|
||||
.handles
|
||||
.get(&fh)
|
||||
.copied()
|
||||
.and_then(|i| st.active_writes.get_mut(&i))
|
||||
else {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino.0, len);
|
||||
len
|
||||
};
|
||||
debug!(
|
||||
ino = ino.0,
|
||||
fh,
|
||||
offset,
|
||||
len = data.len(),
|
||||
new_len,
|
||||
"staged write"
|
||||
);
|
||||
reply.written(data.len() as u32);
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -556,48 +529,62 @@ impl Filesystem for ProtonFs {
|
||||
return;
|
||||
}
|
||||
let new_end = offset.saturating_add(length);
|
||||
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
|
||||
|
||||
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
||||
|
||||
let res = {
|
||||
// Only the modes whose effect on the scratch file this handle can
|
||||
// record. Anything else went through to the scratch file untracked:
|
||||
// a zeroed range was refilled from the base at commit, and a collapse
|
||||
// or insert shifted bytes that `len` and `written` did not follow
|
||||
// (`docs/BUGS.md` B224). `EOPNOTSUPP` is also what makes glibc's
|
||||
// `posix_fallocate` fall back to writing zeros.
|
||||
let Some(kind) = falloc_kind(mode) else {
|
||||
reply.error(Errno::EOPNOTSUPP);
|
||||
return;
|
||||
};
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&_fh.0).and_then(|i| st.active_writes.get(i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => {
|
||||
reply.error(if st.entries.contains_key(&ino.0) {
|
||||
Errno::EBADF
|
||||
} else {
|
||||
Errno::ENOENT
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// The syscall can take a while on a large range; the state lock is
|
||||
// not held across it, as `write` does not hold it across its I/O.
|
||||
let ret = {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
unsafe { libc::fallocate(file.as_raw_fd(), mode, offset as i64, length as i64) }
|
||||
};
|
||||
let res = if ret != 0 {
|
||||
Err(Errno::from(std::io::Error::last_os_error()))
|
||||
} else {
|
||||
let mut st = self.core.state();
|
||||
match st
|
||||
if let Some(aw) = st
|
||||
.handles
|
||||
.get(&_fh.0)
|
||||
.copied()
|
||||
.and_then(|i| st.active_writes.get_mut(&i))
|
||||
{
|
||||
Some(aw) => {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = aw.file.as_raw_fd();
|
||||
let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) };
|
||||
if ret == 0 {
|
||||
if is_punch_hole {
|
||||
// A punched range reads as zero. Mark it authored so
|
||||
// queue_revision does not refill it from the remote
|
||||
// baseline and silently undo the hole at commit.
|
||||
aw.written.add(offset, new_end.min(aw.len));
|
||||
aw.dirty = true;
|
||||
} else if !keep_size && new_end > aw.len {
|
||||
aw.written.add(aw.len, new_end);
|
||||
aw.len = new_end;
|
||||
aw.dirty = true;
|
||||
st.set_size(ino.0, new_end);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Errno::EIO)
|
||||
}
|
||||
let len = aw.len;
|
||||
let (authored, grown) = kind.effect(offset, new_end, len);
|
||||
if let Some((from, to)) = authored {
|
||||
// Zeroed or punched bytes read as zero. Marked authored so
|
||||
// queue_revision does not refill them from the remote
|
||||
// baseline and silently undo them at commit.
|
||||
aw.written.add(from, to);
|
||||
aw.dirty = true;
|
||||
}
|
||||
None => {
|
||||
if st.entries.contains_key(&ino.0) {
|
||||
Err(Errno::EBADF)
|
||||
} else {
|
||||
Err(Errno::ENOENT)
|
||||
}
|
||||
if let Some(end) = grown {
|
||||
aw.len = end;
|
||||
aw.dirty = true;
|
||||
st.set_size(ino.0, end);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match res {
|
||||
@@ -606,18 +593,47 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
}
|
||||
|
||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
||||
/// and performed in the background, so there is nothing
|
||||
/// to push here — the written bytes are already in the scratch file.
|
||||
/// `close(2)` calls flush before release, and flush is the only one of the
|
||||
/// two whose answer reaches the program: the kernel drops `release`'s. So
|
||||
/// the scratch file of a handle with writes on it is synced here, on a
|
||||
/// worker, and a full disk or a failing one is what `close` returns.
|
||||
///
|
||||
/// Before, flush answered `ok` at once and `release` synced the file, so an
|
||||
/// `ENOSPC` that delayed allocation held back until then was reported to
|
||||
/// nobody, and the program took the save as done (`docs/BUGS.md` B222).
|
||||
/// It also means `release`, which stays on the dispatch loop for ordering,
|
||||
/// finds the pages already on disk instead of writing back a whole file
|
||||
/// while every other request on the mount waits.
|
||||
fn flush(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino: INodeNo,
|
||||
_fh: FileHandle,
|
||||
fh: FileHandle,
|
||||
_lock_owner: LockOwner,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
reply.ok();
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
st.handles
|
||||
.get(&fh.0)
|
||||
.and_then(|ino| st.active_writes.get(ino))
|
||||
.filter(|aw| aw.dirty)
|
||||
.map(|aw| aw.file.clone())
|
||||
};
|
||||
let Some(file) = file else {
|
||||
reply.ok();
|
||||
return;
|
||||
};
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Transfer, "flush", move || match file.sync_data() {
|
||||
Ok(()) => reply.ok(),
|
||||
Err(e) => {
|
||||
error!(fh = fh.0, error = %e, "flush of scratch file failed");
|
||||
reply.error(fs.scratch_sync_errno(&e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Durability here means the scratch file, not the remote: a caller asking
|
||||
@@ -655,7 +671,7 @@ impl Filesystem for ProtonFs {
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
let ino = self.tree(ino);
|
||||
let (handle, unlinked_uid) = {
|
||||
let (handle, unlinked_uid, releasing) = {
|
||||
let mut st = self.core.state();
|
||||
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
||||
let ino = st.handles.remove(&fh.0);
|
||||
@@ -668,7 +684,11 @@ impl Filesystem for ProtonFs {
|
||||
None
|
||||
}
|
||||
});
|
||||
(h, unlinked_uid)
|
||||
let releasing = ino.filter(|_| h.is_some());
|
||||
if let Some(ino) = releasing {
|
||||
st.releasing.insert(ino);
|
||||
}
|
||||
(h, unlinked_uid, releasing)
|
||||
};
|
||||
// Deliberately **not** handed to a worker, unlike the other slow
|
||||
// handlers here. The kernel does not wait for a `release` reply before
|
||||
@@ -685,6 +705,9 @@ impl Filesystem for ProtonFs {
|
||||
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
||||
// insert, and at most a bounded copy out of the local cache.
|
||||
self.finish_release(handle, unlinked_uid, reply);
|
||||
if let Some(ino) = releasing {
|
||||
self.core.state().releasing.remove(&ino);
|
||||
}
|
||||
}
|
||||
|
||||
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||
@@ -952,20 +975,59 @@ impl Filesystem for ProtonFs {
|
||||
fn copy_file_range(
|
||||
&self,
|
||||
_req: &Request,
|
||||
_ino_in: INodeNo,
|
||||
ino_in: INodeNo,
|
||||
_fh_in: FileHandle,
|
||||
_offset_in: u64,
|
||||
_ino_out: INodeNo,
|
||||
_fh_out: FileHandle,
|
||||
_offset_out: u64,
|
||||
_len: u64,
|
||||
_flags: CopyFileRangeFlags,
|
||||
offset_in: u64,
|
||||
ino_out: INodeNo,
|
||||
fh_out: FileHandle,
|
||||
offset_out: u64,
|
||||
len: u64,
|
||||
flags: CopyFileRangeFlags,
|
||||
reply: ReplyWrite,
|
||||
) {
|
||||
// Also must stay ENOSYS: there is no server-side copy, and this is the
|
||||
// errno that sends coreutils (and the kernel) down the read/write path
|
||||
// instead of failing the copy outright.
|
||||
reply.error(Errno::ENOSYS);
|
||||
// Copied here when the source bytes are on this machine, as `write`
|
||||
// would stage them, minus a FUSE round trip per page: `cp` of a cached
|
||||
// 256 MiB file ran at about 100 MiB/s through read and write. Anything
|
||||
// that would have to be downloaded gets `EOPNOTSUPP`, which makes the
|
||||
// kernel copy that one call through read and write itself; `ENOSYS`
|
||||
// would turn the op off for the whole mount. There is no server-side
|
||||
// copy into a file `cp` has already created, so the copy still uploads.
|
||||
//
|
||||
// On the dispatch loop, like `write`, so it keeps its order with the
|
||||
// reads and writes of the same handle. [`COPY_RANGE_MAX`] bounds how
|
||||
// long one call holds the loop.
|
||||
let (ino_in, ino_out) = (self.tree(ino_in), self.tree(ino_out));
|
||||
if !flags.is_empty() || ino_in == ino_out {
|
||||
reply.error(Errno::EOPNOTSUPP);
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.core.require_writable(ino_out.0) {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
let bytes = match self
|
||||
.core
|
||||
.read_local(ino_in.0, offset_in, len.min(COPY_RANGE_MAX))
|
||||
{
|
||||
Ok(Some(bytes)) => bytes,
|
||||
Ok(None) => {
|
||||
debug!(ino_in = ino_in.0, offset_in, "copy range not local");
|
||||
reply.error(Errno::EOPNOTSUPP);
|
||||
return;
|
||||
}
|
||||
Err(error) => {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
reply.written(0);
|
||||
return;
|
||||
}
|
||||
match self.stage_write(ino_out.0, fh_out.0, offset_out, &bytes) {
|
||||
Ok(()) => reply.written(bytes.len() as u32),
|
||||
Err(error) => reply.error(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -993,6 +1055,11 @@ pub struct ProtonFs {
|
||||
/// worker busy indefinitely.
|
||||
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
||||
|
||||
/// The most one `copy_file_range` call copies; the caller loops for the rest.
|
||||
/// A local copy of this much takes milliseconds, which is how long it holds
|
||||
/// the dispatch loop.
|
||||
const COPY_RANGE_MAX: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
||||
/// the interned children in `children` order.
|
||||
///
|
||||
@@ -1022,6 +1089,52 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
||||
/// continues exactly where the last one stopped — which is only true because
|
||||
/// the listing behind those indexes is a snapshot (`docs/BUGS.md` B43).
|
||||
/// What an `fallocate` mode does to a write handle, for the modes it can
|
||||
/// record (`docs/BUGS.md` B224).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FallocKind {
|
||||
/// Reserve space; past the end it grows the file unless `keep_size`.
|
||||
Allocate { keep_size: bool },
|
||||
/// Deallocate a range inside the file, which then reads as zero.
|
||||
Punch,
|
||||
/// Zero a range; past the end it grows the file unless `keep_size`.
|
||||
Zero { keep_size: bool },
|
||||
}
|
||||
|
||||
fn falloc_kind(mode: i32) -> Option<FallocKind> {
|
||||
const KEEP: i32 = libc::FALLOC_FL_KEEP_SIZE;
|
||||
const PUNCH: i32 = libc::FALLOC_FL_PUNCH_HOLE;
|
||||
const ZERO: i32 = libc::FALLOC_FL_ZERO_RANGE;
|
||||
match mode {
|
||||
0 => Some(FallocKind::Allocate { keep_size: false }),
|
||||
KEEP => Some(FallocKind::Allocate { keep_size: true }),
|
||||
m if m == PUNCH | KEEP => Some(FallocKind::Punch),
|
||||
ZERO => Some(FallocKind::Zero { keep_size: false }),
|
||||
m if m == ZERO | KEEP => Some(FallocKind::Zero { keep_size: true }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl FallocKind {
|
||||
/// For `[offset, end)` on a file `len` bytes long: the range that now
|
||||
/// reads as zero and must count as written, and the new length if it grew.
|
||||
fn effect(self, offset: u64, end: u64, len: u64) -> (Option<(u64, u64)>, Option<u64>) {
|
||||
let grow = |keep_size: bool| (!keep_size && end > len).then_some(end);
|
||||
let zeroed = |to: u64| (offset < to).then_some((offset, to));
|
||||
match self {
|
||||
// Space past the old end reads as zero, and nothing before it moved.
|
||||
Self::Allocate { keep_size } => {
|
||||
(grow(keep_size).map(|end| (len, end)), grow(keep_size))
|
||||
}
|
||||
Self::Punch => (zeroed(end.min(len)), None),
|
||||
Self::Zero { keep_size } => {
|
||||
let to = if keep_size { end.min(len) } else { end };
|
||||
(zeroed(to), grow(keep_size))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reply_listing(listing: &DirListing, offset: u64, root: u64, mut reply: ReplyDirectory) {
|
||||
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||
let ft = if *is_dir {
|
||||
@@ -1267,8 +1380,12 @@ impl ProtonFs {
|
||||
//
|
||||
// Complete pending blobs are the common case and can be copied without
|
||||
// any network access. An incomplete blob still has gaps referring to
|
||||
// the remote base; stacking another write on it cannot be represented
|
||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||
// the remote base, and stacking another write on it cannot be
|
||||
// represented by WriteHandle, so the copy's gaps are filled from that
|
||||
// base first, as the drain fills them before an upload. Only when that
|
||||
// fails (offline, or the base is gone) does the open fail: refusing
|
||||
// every such open failed a create over a file whose partial edit had
|
||||
// not drained yet (`docs/BUGS.md` B204).
|
||||
//
|
||||
// Reading that blob and installing the handle cannot be done under one
|
||||
// lock — the copy is the size of the file — so the base is re-checked at
|
||||
@@ -1302,10 +1419,6 @@ impl ProtonFs {
|
||||
}
|
||||
}
|
||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||
error!(%uid, "refusing write over incomplete queued revision");
|
||||
break 'install Err(Errno::EIO);
|
||||
}
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
@@ -1321,10 +1434,37 @@ impl ProtonFs {
|
||||
// (`ContentCache::clone_or_copy`).
|
||||
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
// The drain lets go of the pending entry before it
|
||||
// discards the blob, so a blob that went away under the
|
||||
// copy is a revision that landed or was replaced: the
|
||||
// same race the re-check below catches, only earlier.
|
||||
if self.core.pending_blob(&uid).as_ref() != Some(&pending.path) {
|
||||
warn!(%uid, attempt, "queued revision went away while opening for write; retrying");
|
||||
continue;
|
||||
}
|
||||
error!(%uid, source = %pending.path.display(), error = %e,
|
||||
"copy queued revision into write scratch failed");
|
||||
break 'install Err(Errno::EIO);
|
||||
}
|
||||
if !pending.meta.complete {
|
||||
let mut authored = Intervals::default();
|
||||
for &(s, e) in &pending.meta.authored {
|
||||
authored.add(s, e);
|
||||
}
|
||||
if let Err(error) = self.core.fill_gaps(
|
||||
&uid,
|
||||
&file,
|
||||
pending.meta.len,
|
||||
pending.meta.base_mtime,
|
||||
pending.meta.base_size,
|
||||
&authored,
|
||||
) {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error!(%uid, ?error, "cannot complete the queued revision a write open is based on");
|
||||
break 'install Err(error);
|
||||
}
|
||||
debug!(%uid, "write open completed an incomplete queued revision");
|
||||
}
|
||||
initial_written.add(0, pending.meta.len);
|
||||
}
|
||||
// Sampled *before* the state lock, never under it: no site in
|
||||
@@ -1416,6 +1556,20 @@ impl ProtonFs {
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
||||
/// The errno to give a program for a failed sync of a scratch file.
|
||||
/// Delayed allocation means a full disk usually surfaces at the sync
|
||||
/// rather than at `write(2)`, and `ENOSPC` is what a program checks for.
|
||||
fn scratch_sync_errno(&self, e: &std::io::Error) -> Errno {
|
||||
match e.raw_os_error() {
|
||||
Some(libc::ENOSPC) => {
|
||||
self.core.cache.emergency_evict();
|
||||
Errno::ENOSPC
|
||||
}
|
||||
Some(libc::EDQUOT) => Errno::EDQUOT,
|
||||
_ => Errno::EIO,
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
||||
// Everything the durability sidecar needs, copied out so no lock is held
|
||||
// across the sync.
|
||||
@@ -1436,22 +1590,18 @@ impl ProtonFs {
|
||||
)
|
||||
})
|
||||
};
|
||||
// A handle opened for reading has nothing of its own to make durable,
|
||||
// and POSIX allows `fsync` on it; `EBADF` failed `sync FILE` and
|
||||
// backup tools that sync what they read (`docs/BUGS.md` B222).
|
||||
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||
handle
|
||||
else {
|
||||
reply.error(Errno::EBADF);
|
||||
reply.ok();
|
||||
return;
|
||||
};
|
||||
if let Err(e) = f.sync_all() {
|
||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||
// Delayed allocation means a full disk usually surfaces here rather
|
||||
// than at `write(2)`; `ENOSPC` is what an application checks for.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
reply.error(self.scratch_sync_errno(&e));
|
||||
return;
|
||||
}
|
||||
// A clean handle has nothing on it that the remote does not already
|
||||
@@ -1502,6 +1652,48 @@ impl ProtonFs {
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||
/// Stage `data` at `offset` of the write handle `fh` on `ino`, straight into
|
||||
/// its scratch file (no base download): only the untouched remainder is
|
||||
/// pulled from the remote, and only at commit.
|
||||
fn stage_write(&self, ino: u64, fh: u64, offset: u64, data: &[u8]) -> Result<(), Errno> {
|
||||
let file = {
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => return Err(Errno::EBADF),
|
||||
}
|
||||
};
|
||||
if let Err(e) = file.write_all_at(data, offset) {
|
||||
error!(ino, fh, error = %e, "scratch write failed");
|
||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||
// application can say so (and so `cp`/editors take their own
|
||||
// out-of-space path), and free what the cache is holding — the
|
||||
// scratch file the next write goes to shares that filesystem.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
return Err(Errno::ENOSPC);
|
||||
}
|
||||
return Err(Errno::EIO);
|
||||
}
|
||||
let new_len = {
|
||||
let mut st = self.core.state();
|
||||
let Some(aw) = st
|
||||
.handles
|
||||
.get(&fh)
|
||||
.copied()
|
||||
.and_then(|i| st.active_writes.get_mut(&i))
|
||||
else {
|
||||
return Err(Errno::EBADF);
|
||||
};
|
||||
aw.record_write(offset, offset + data.len() as u64);
|
||||
let len = aw.len;
|
||||
st.set_size(ino, len);
|
||||
len
|
||||
};
|
||||
debug!(ino, fh, offset, len = data.len(), new_len, "staged write");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
reply.error(error);
|
||||
@@ -1698,9 +1890,7 @@ impl ProtonFs {
|
||||
// empty, which it is, so reads stay offline-clean across a restart too.
|
||||
if local {
|
||||
st.children.insert(ino, Vec::new());
|
||||
if let Err(e) = self.core.db.set_listed(&uid, true) {
|
||||
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
|
||||
}
|
||||
st.record_listed(uid);
|
||||
}
|
||||
let entry = st.entries.get(&ino).unwrap();
|
||||
let attr = self.attr(ino, &entry.node, entry.access);
|
||||
@@ -2318,3 +2508,60 @@ impl ProtonFs {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod falloc_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_the_modes_a_handle_can_record_are_accepted() {
|
||||
let keep = libc::FALLOC_FL_KEEP_SIZE;
|
||||
assert_eq!(
|
||||
falloc_kind(0),
|
||||
Some(FallocKind::Allocate { keep_size: false })
|
||||
);
|
||||
assert_eq!(
|
||||
falloc_kind(keep),
|
||||
Some(FallocKind::Allocate { keep_size: true })
|
||||
);
|
||||
assert_eq!(
|
||||
falloc_kind(libc::FALLOC_FL_PUNCH_HOLE | keep),
|
||||
Some(FallocKind::Punch)
|
||||
);
|
||||
assert_eq!(
|
||||
falloc_kind(libc::FALLOC_FL_ZERO_RANGE),
|
||||
Some(FallocKind::Zero { keep_size: false })
|
||||
);
|
||||
for mode in [
|
||||
libc::FALLOC_FL_PUNCH_HOLE,
|
||||
libc::FALLOC_FL_COLLAPSE_RANGE,
|
||||
libc::FALLOC_FL_INSERT_RANGE,
|
||||
libc::FALLOC_FL_UNSHARE_RANGE,
|
||||
] {
|
||||
assert_eq!(falloc_kind(mode), None, "{mode:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A zeroed range was left unwritten, so the commit filled it back in
|
||||
/// from the remote base (`docs/BUGS.md` B224).
|
||||
#[test]
|
||||
fn a_zeroed_or_punched_range_counts_as_written() {
|
||||
let zero = FallocKind::Zero { keep_size: false };
|
||||
assert_eq!(zero.effect(10, 20, 100), (Some((10, 20)), None));
|
||||
assert_eq!(zero.effect(90, 120, 100), (Some((90, 120)), Some(120)));
|
||||
let zero_keep = FallocKind::Zero { keep_size: true };
|
||||
assert_eq!(zero_keep.effect(90, 120, 100), (Some((90, 100)), None));
|
||||
assert_eq!(
|
||||
FallocKind::Punch.effect(90, 120, 100),
|
||||
(Some((90, 100)), None)
|
||||
);
|
||||
assert_eq!(FallocKind::Punch.effect(120, 130, 100), (None, None));
|
||||
let alloc = FallocKind::Allocate { keep_size: false };
|
||||
assert_eq!(alloc.effect(0, 50, 100), (None, None));
|
||||
assert_eq!(alloc.effect(90, 120, 100), (Some((100, 120)), Some(120)));
|
||||
assert_eq!(
|
||||
FallocKind::Allocate { keep_size: true }.effect(90, 120, 100),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
+467
-75
@@ -74,8 +74,8 @@ use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, ShareId, VolumeId};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem, ProtonDriveClient,
|
||||
ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
||||
AvailableNameTarget, DriveEvent, DriveEventScopeId, MemberRole, Node, NodeKind, NodeMoveItem,
|
||||
ProtonDriveClient, ProtonPhotosClient, SharedWithMeItem, ThumbnailType,
|
||||
};
|
||||
|
||||
mod albums;
|
||||
@@ -116,6 +116,7 @@ mod upload;
|
||||
mod r#virtual;
|
||||
mod workers;
|
||||
use background::{forget_and_notify, run_event_sync, run_local_index, run_photos_event_sync};
|
||||
use link::BATCH_CALL_TIMEOUT;
|
||||
pub(crate) use mount::is_stale_mount;
|
||||
pub use mount::{MountOptions, MountOutcome, mount};
|
||||
use mount::{
|
||||
@@ -123,7 +124,7 @@ use mount::{
|
||||
};
|
||||
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
||||
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
||||
pub use systemd::idle_sleep;
|
||||
pub use systemd::{idle_sleep, status as notify_status};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||
use r#virtual::{
|
||||
@@ -727,6 +728,8 @@ struct Core {
|
||||
/// can say what a pass is doing rather than just "syncing". An entry exists
|
||||
/// only while that folder's reconcile pass is running.
|
||||
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
||||
/// Whether the folder-sync engine is alive, and which pass it is on.
|
||||
sync_engine: Arc<sync::EngineHealth>,
|
||||
/// Channel to the folder-sync engine: nudges it to
|
||||
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
||||
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
||||
@@ -744,6 +747,9 @@ struct Core {
|
||||
/// uploading that same tree — the engine would upload files as they vanish
|
||||
/// and then walk the FUSE mount as if it were local.
|
||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||
/// Per-sync-folder paths the last pass could not read, so the activity feed
|
||||
/// says so when the set changes rather than on every poll (B196).
|
||||
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
|
||||
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||
/// those changes the event feed still owes us.
|
||||
///
|
||||
@@ -1031,7 +1037,9 @@ impl Core {
|
||||
|
||||
let core = self.clone();
|
||||
std::thread::spawn(move || {
|
||||
let job = core.transfers.begin_job("Checking the cache database");
|
||||
let job = core
|
||||
.transfers
|
||||
.begin_background_job("Checking the cache database");
|
||||
job.detail("Verifying every page");
|
||||
let problems = match core.db.integrity_check() {
|
||||
Ok(problems) => problems,
|
||||
@@ -2115,12 +2123,39 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ino`'s listing is already in memory, i.e. whether
|
||||
/// [`Core::ensure_children`] would return without touching the network.
|
||||
/// Lets a handler decide between answering inline and handing off to a
|
||||
/// worker, at the cost of one uncontended map lookup.
|
||||
/// Whether [`Core::ensure_children`] would answer for `ino` without
|
||||
/// touching the network. Lets a handler decide between answering inline
|
||||
/// and handing off to a worker.
|
||||
///
|
||||
/// A resident listing is not enough on its own. A shared folder's, or the
|
||||
/// Shared with me root's, is fetched again once it is a minute old, and
|
||||
/// that fetch ran on the dispatch loop, so every request on the mount
|
||||
/// waited for Drive to answer (`docs/BUGS.md` B219).
|
||||
fn children_cached(&self, ino: u64) -> bool {
|
||||
self.state().children.contains_key(&ino)
|
||||
let (uid, resident) = {
|
||||
let st = self.state();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) => (e.uid.clone(), st.children.contains_key(&ino)),
|
||||
// `list_children` answers ENOENT without the network.
|
||||
None => return true,
|
||||
}
|
||||
};
|
||||
if !resident {
|
||||
return false;
|
||||
}
|
||||
let freshness = if is_virtual_uid(&uid) {
|
||||
Some(SHARED_WITH_ME_SYNCED_MS.to_string())
|
||||
} else if !self.is_own_or_virtual(&uid) && !is_local_uid(&uid) {
|
||||
Some(Self::shared_folder_freshness_key(&uid))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
freshness.is_none_or(|key| {
|
||||
!listing_needs_refresh(
|
||||
self.online.load(Ordering::Relaxed),
|
||||
self.shared_listing_stale(&key),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-apply the optimistic size of any queued write to `nodes`.
|
||||
@@ -2288,8 +2323,7 @@ impl Core {
|
||||
|
||||
let generation = self.shared_generation.load(Ordering::SeqCst);
|
||||
let accepted = accepted_share_provenance(
|
||||
self.rt
|
||||
.block_on(self.client.enumerate_shared_with_me())
|
||||
self.block_on_within(BATCH_CALL_TIMEOUT, self.client.enumerate_shared_with_me())
|
||||
.map_err(|error| {
|
||||
error!(%error, "enumerating shared roots failed");
|
||||
Errno::EIO
|
||||
@@ -2298,12 +2332,14 @@ impl Core {
|
||||
let nodes = if accepted.uids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.rt
|
||||
.block_on(self.drive.enumerate_nodes_light(&accepted.uids))
|
||||
.map_err(|error| {
|
||||
error!(%error, "materializing shared roots failed");
|
||||
Errno::EIO
|
||||
})?
|
||||
self.block_on_within(
|
||||
BATCH_CALL_TIMEOUT,
|
||||
self.drive.enumerate_nodes_light(&accepted.uids),
|
||||
)
|
||||
.map_err(|error| {
|
||||
error!(%error, "materializing shared roots failed");
|
||||
Errno::EIO
|
||||
})?
|
||||
};
|
||||
let virtual_uid = shared_with_me_uid();
|
||||
let published = prepare_shared_roots(&accepted, nodes, &virtual_uid);
|
||||
@@ -2682,6 +2718,7 @@ impl Core {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let preassigned = state::inos_for(&self.db, &filtered_nodes);
|
||||
let mut st = self.state();
|
||||
// Lost the race? Another thread already populated it.
|
||||
if st.children.contains_key(&ino) {
|
||||
@@ -2703,15 +2740,14 @@ impl Core {
|
||||
})
|
||||
.map(|n| n.uid.clone())
|
||||
.collect();
|
||||
let inos = st.intern_batch(ino, filtered_nodes);
|
||||
let inos = st.intern_batch_with_inos(ino, filtered_nodes, preassigned);
|
||||
child_inos.extend(inos);
|
||||
st.children.insert(ino, child_inos);
|
||||
let root_snapshot = primary_root.then(|| RootListingSnapshot::capture(&st, ino).unwrap());
|
||||
// Record the listing as complete so a later restart (or a trimmed hot
|
||||
// cache) can rebuild it from the DB without the API.
|
||||
if let Err(e) = self.db.set_listed(&folder_uid, true) {
|
||||
warn!(%folder_uid, error = %e, "db set_listed(true) failed");
|
||||
}
|
||||
// cache) can rebuild it from the DB without the API. Written with the
|
||||
// children's rows when the lock goes, not under it.
|
||||
st.record_listed(folder_uid.clone());
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
if let Some(snapshot) = root_snapshot {
|
||||
@@ -2874,7 +2910,8 @@ impl Core {
|
||||
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
||||
loop {
|
||||
for chunk in uids.chunks(SIZE_UPGRADE_CHUNK) {
|
||||
let result = self.rt.block_on(self.drive.enumerate_nodes(chunk));
|
||||
let result =
|
||||
self.block_on_within(BATCH_CALL_TIMEOUT, self.drive.enumerate_nodes(chunk));
|
||||
self.apply_size_upgrade(key, result);
|
||||
slot.chunk_done();
|
||||
}
|
||||
@@ -4222,8 +4259,10 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||
/// plaintext into the content cache; a folder records a recursive pin and
|
||||
/// downloads every descendant file (selective sync). Returns a human message.
|
||||
/// plaintext into the content cache before this returns. A folder records a
|
||||
/// recursive pin and downloads every descendant file on a background thread,
|
||||
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||
/// Returns a human message.
|
||||
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (name, is_folder, mtime, size) = {
|
||||
@@ -4240,34 +4279,93 @@ impl Core {
|
||||
)
|
||||
};
|
||||
if is_folder {
|
||||
// Offline, every download would fail and the folder would only look
|
||||
// kept. Refuse instead, as a file pin does.
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
// Record the recursive pin first so every descendant is eviction-
|
||||
// exempt before we start filling the cache with the subtree.
|
||||
self.cache
|
||||
.add_pin(&uid, rel, true)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
let n = self.pin_subtree(ino)?;
|
||||
return Ok(format!("{name} ({n} files)"));
|
||||
let core = self.clone();
|
||||
let folder = name.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("pin-folder".into())
|
||||
.spawn(move || core.pin_subtree(ino, &uid, &folder))
|
||||
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||
return Ok(format!("{name}: downloading its files in the background"));
|
||||
}
|
||||
let bytes = self
|
||||
.download_file_tracked(&uid, &name, size)
|
||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
||||
self.cache
|
||||
.store(&uid, mtime, size, &bytes)
|
||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
||||
self.fetch_content(&uid, &name, mtime, size)?;
|
||||
self.cache
|
||||
.add_pin(&uid, rel, false)
|
||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Download and cache every file in the subtree rooted at folder `ino`,
|
||||
/// returning the count cached (already-fresh blobs counted, not re-fetched).
|
||||
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
|
||||
/// fully discovered; the lock is dropped before each network download.
|
||||
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
|
||||
/// Download and cache every file in the subtree rooted at the pinned folder
|
||||
/// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
|
||||
/// tree depth-first, enumerating each folder so a cold subtree is fully
|
||||
/// discovered; the lock is dropped before each network download. Stops when
|
||||
/// the folder is unpinned or the daemon stops.
|
||||
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
|
||||
let job = self
|
||||
.transfers
|
||||
.begin_job(format!("Making {name} available offline"));
|
||||
match self.pin_subtree_files(ino) {
|
||||
Ok(files) => {
|
||||
job.set_total(files.len() as u64);
|
||||
let mut failed = 0usize;
|
||||
for (uid, file, mtime, size) in files {
|
||||
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||
return;
|
||||
}
|
||||
job.detail(file.clone());
|
||||
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||
warn!(%uid, error = %e, "pin folder: download failed");
|
||||
failed += 1;
|
||||
}
|
||||
job.step();
|
||||
}
|
||||
info!(folder = %name, failed, "pin folder: done");
|
||||
}
|
||||
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
|
||||
/// before the download, and a create landing or a remote edit can change
|
||||
/// them while it runs. The blob is then stored under the old ones and reads
|
||||
/// as stale, so the file is listed as kept but not available offline. Fetch
|
||||
/// it again under the new ones, a few times at most.
|
||||
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
|
||||
let mut fetched = (mtime, size);
|
||||
for _ in 0..3 {
|
||||
self.fetch_content(uid, name, fetched.0, fetched.1)?;
|
||||
let current = {
|
||||
let st = self.state();
|
||||
st.by_uid
|
||||
.get(uid)
|
||||
.and_then(|ino| st.entries.get(ino))
|
||||
.map(|e| (e.node.modification_time, node_size(&e.node)))
|
||||
};
|
||||
match current {
|
||||
Some(now) if now != fetched => fetched = now,
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file in the subtree rooted at folder `ino`.
|
||||
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||
let mut stack = vec![ino];
|
||||
while let Some(dir) = stack.pop() {
|
||||
if self.shutdown.is_stopping() {
|
||||
break;
|
||||
}
|
||||
self.ensure_children(dir)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let st = self.state();
|
||||
@@ -4288,22 +4386,7 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut count = 0;
|
||||
for (uid, name, mtime, size) in files {
|
||||
if self.cache.is_cached(&uid, mtime, size) {
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
match self.download_file_tracked(&uid, &name, size) {
|
||||
Ok(bytes) => {
|
||||
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
||||
@@ -4327,9 +4410,15 @@ impl Core {
|
||||
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A file manager asks for one per file in a folder, on the metadata
|
||||
// workers. Offline, or on a stalled link, each one held a worker for
|
||||
// the SDK's whole retry budget, and every cold lookup, create and
|
||||
// rename on the mount queued behind them (`docs/BUGS.md` B220).
|
||||
if !self.is_online() {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = self
|
||||
.rt
|
||||
.block_on(self.client.download_thumbnail(&uid, ttype))
|
||||
.block_on_bounded(self.client.download_thumbnail(&uid, ttype))
|
||||
.map_err(|e| {
|
||||
warn!(%uid, error = %e, "download thumbnail failed");
|
||||
Errno::EIO
|
||||
@@ -4349,6 +4438,8 @@ impl Core {
|
||||
|
||||
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
||||
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
||||
/// A node kept only because a folder above it is pinned is refused: there
|
||||
/// is no pin of its own to remove, and it would stay kept all the same.
|
||||
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (name, is_folder) = {
|
||||
@@ -4358,14 +4449,24 @@ impl Core {
|
||||
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if self.pinned_by_folder(ino) {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} is kept offline because a folder above it is; unpin that folder instead"
|
||||
)));
|
||||
}
|
||||
self.cache
|
||||
.remove_pin(&uid)
|
||||
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
||||
// A recursively-pinned folder's descendants were eviction-exempt; now
|
||||
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
||||
// for budget pressure. Descendants come from the DB node tree.
|
||||
// for budget pressure. Descendants come from the DB node tree. One
|
||||
// pinned on its own, or under a pinned subfolder, is still kept.
|
||||
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
||||
let still_pinned = self.pinned_set();
|
||||
for s in uids {
|
||||
if still_pinned.contains(&s) {
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = parse_uid(&s) {
|
||||
self.cache.evict(&u);
|
||||
self.evict_reader(&u);
|
||||
@@ -4375,6 +4476,20 @@ impl Core {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Whether the node at `ino` is kept offline because a folder above it is
|
||||
/// pinned, whether or not it is also pinned on its own.
|
||||
fn pinned_by_folder(&self, ino: u64) -> bool {
|
||||
let parent = {
|
||||
let st = self.state();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.filter(|e| e.parent != ino)
|
||||
.and_then(|e| st.entries.get(&e.parent))
|
||||
.map(|p| p.uid.clone())
|
||||
};
|
||||
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
|
||||
}
|
||||
|
||||
/// A Photos API handle sharing this Core's Drive client and session, so it
|
||||
/// reuses the daemon's single authenticated session rather than logging in
|
||||
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
||||
@@ -4387,10 +4502,13 @@ impl Core {
|
||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
||||
let (ino, _uid) = self.resolve(rel)?;
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
self.ensure_children(ino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
self.settle_listing_sizes(ino);
|
||||
// Every entry shares this folder as its parent, so one lookup says
|
||||
// whether they are all kept through it.
|
||||
let pinned_by_folder = self.cache.is_pinned(&uid);
|
||||
// Snapshot the listing, then drop the lock before touching the on-disk
|
||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
||||
@@ -4422,7 +4540,8 @@ impl Core {
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
pinned: self.cache.is_pinned(&uid),
|
||||
pinned: pinned_by_folder || self.cache.is_pinned(&uid),
|
||||
pinned_by_folder,
|
||||
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||
uid: uid.to_string(),
|
||||
// Listing entries live in the requested dir; the caller derives
|
||||
@@ -5089,6 +5208,133 @@ impl Core {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Copy a file or folder into `new_parent_rel` on Proton Drive, which copies
|
||||
/// the content there: nothing is downloaded or uploaded. Both paths are
|
||||
/// mountpoint-relative. A name already taken in the folder gets the next
|
||||
/// free ` (n)` suffix. Returns the copy's name.
|
||||
///
|
||||
/// Drive copies what it holds, so the copy is refused offline and while the
|
||||
/// mount still owes Drive part of it: a node or folder only queued locally,
|
||||
/// a file with a queued upload or unsaved writes, or — for a folder, whose
|
||||
/// queued changes could sit anywhere below it — any queued op at all.
|
||||
fn copy_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult<String> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let (pino, new_parent_uid) = self
|
||||
.resolve_path(new_parent_rel)
|
||||
.map_err(|e| self.errno_error(e, "resolve new parent"))?;
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
self.require_uid_writable(&new_parent_uid)
|
||||
.map_err(|error| self.errno_error(error, "copy access"))?;
|
||||
let (name, is_folder, unsaved) = {
|
||||
let st = self.state();
|
||||
let entry = st
|
||||
.entries
|
||||
.get(&ino)
|
||||
.ok_or_else(|| CoreError::not_found(format!("{} is gone", rel.display())))?;
|
||||
// Unsaved writes, or a release still queueing them, to the file or,
|
||||
// for a folder, to any file below it.
|
||||
let unsaved = st
|
||||
.active_writes
|
||||
.iter()
|
||||
.filter(|(_, w)| w.dirty)
|
||||
.map(|(&open, _)| open)
|
||||
.chain(st.releasing.iter().copied())
|
||||
.any(|open| relocate::ino_is_under(&st, open, ino));
|
||||
(entry.node.name.clone(), entry.node.is_folder(), unsaved)
|
||||
};
|
||||
// Unlike a move, a copy cannot wait in the queue: it needs the node as
|
||||
// Drive has it, so it goes now or not at all, local-first or not.
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
let owed = unsaved
|
||||
|| is_local_uid(&uid)
|
||||
|| is_local_uid(&new_parent_uid)
|
||||
|| self.db.has_any_op(&uid.to_string()).unwrap_or(true)
|
||||
|| (is_folder
|
||||
&& self
|
||||
.db
|
||||
.pending_op_counts()
|
||||
.map(|counts| counts.uploads + counts.changes > 0)
|
||||
.unwrap_or(true));
|
||||
if owed {
|
||||
return Err(CoreError::conflict(format!(
|
||||
"{name} has changes that have not reached Proton Drive yet; \
|
||||
copy it once they have synced"
|
||||
)));
|
||||
}
|
||||
let target = if is_folder {
|
||||
AvailableNameTarget::Folder
|
||||
} else {
|
||||
AvailableNameTarget::File
|
||||
};
|
||||
let copy_name = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.drive
|
||||
.get_available_name(&new_parent_uid, &name, target),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||
let new_uid = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.drive
|
||||
.copy_node(&uid, &new_parent_uid, Some(©_name)),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "copy"))?;
|
||||
let node = self
|
||||
.fetch_minted_node(&new_uid, ©_name)
|
||||
.ok_or_else(|| minted_but_unlisted(©_name))?;
|
||||
let mut st = self.state();
|
||||
let copy_ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
&& !kids.contains(©_ino)
|
||||
{
|
||||
kids.push(copy_ino);
|
||||
}
|
||||
drop(st);
|
||||
self.flush_access_changes();
|
||||
Ok(copy_name)
|
||||
}
|
||||
|
||||
/// What the folder at `rel` holds on Proton Drive: bytes on storage and the
|
||||
/// number of items below it. Drive adds it up, so nothing is listed here;
|
||||
/// trashed items count, and the bytes are encrypted ones.
|
||||
pub(crate) fn folder_size(&self, rel: &Path) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let (ino, uid) = self.resolve(rel)?;
|
||||
let is_folder = self
|
||||
.state()
|
||||
.entries
|
||||
.get(&ino)
|
||||
.is_some_and(|entry| entry.node.is_folder());
|
||||
if !is_folder {
|
||||
return Err(CoreError::invalid(format!(
|
||||
"{} is not a folder",
|
||||
rel.display()
|
||||
)));
|
||||
}
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
/// [`Core::folder_size`] for a folder addressed through any daemon location.
|
||||
pub(crate) fn folder_size_by_uid(&self, uid: &str) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.folder_size_for_uid(&uid)
|
||||
}
|
||||
|
||||
fn folder_size_for_uid(&self, uid: &NodeUid) -> CoreResult<proton_drive_rs::FolderSize> {
|
||||
if is_local_uid(uid) {
|
||||
return Err(CoreError::conflict("the folder is not on Proton Drive yet"));
|
||||
}
|
||||
if !self.is_online() {
|
||||
return Err(CoreError::offline());
|
||||
}
|
||||
self.rt
|
||||
.block_on(self.client.get_folder_size(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "folder size"))
|
||||
}
|
||||
|
||||
/// Trash a file or folder. `rel` is mountpoint-relative. Forgets the node,
|
||||
/// evicts any cached content, and invalidates the parent listing.
|
||||
fn delete(&self, rel: &Path) -> CoreResult<String> {
|
||||
@@ -5314,6 +5560,7 @@ impl Core {
|
||||
// A trashed node can't be pinned or served from the mount, so its
|
||||
// content is never current cache: report neither.
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: item.uid,
|
||||
path: String::new(),
|
||||
@@ -5744,6 +5991,7 @@ impl Core {
|
||||
if let Some(p) = self.sync_progress.lock().get_mut(&folder_id) {
|
||||
f(p);
|
||||
}
|
||||
self.sync_engine.moved();
|
||||
}
|
||||
|
||||
/// Set the number of items the scan expects to check, from the size of the
|
||||
@@ -6047,11 +6295,61 @@ fn is_gone(e: &(dyn std::error::Error + 'static)) -> bool {
|
||||
/// (`sync.rs`, `conflict_path`), so the two halves of the product name the same
|
||||
/// situation the same way and a user only has to learn it once.
|
||||
fn conflict_name(name: &str, stamp: i64) -> String {
|
||||
conflict_name_as(name, conflict_label(), stamp)
|
||||
}
|
||||
|
||||
/// [`conflict_name`] with the machine's label given.
|
||||
fn conflict_name_as(name: &str, label: &str, stamp: i64) -> String {
|
||||
let path = Path::new(name);
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name);
|
||||
let tag = conflict_tag(label, stamp, 0);
|
||||
match path.extension().and_then(|s| s.to_str()) {
|
||||
Some(ext) => format!("{stem} (sync-conflict {stamp}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {stamp})"),
|
||||
Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {tag})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// This machine's name as its conflict copies carry it (`docs/BUGS.md` B251).
|
||||
///
|
||||
/// Two computers syncing one folder that each keep a copy of the same file in
|
||||
/// the same second would otherwise make the same name on Drive, and nobody
|
||||
/// could tell which computer a copy came from. Read once, from the hostname.
|
||||
fn conflict_label() -> &'static str {
|
||||
static LABEL: OnceLock<String> = OnceLock::new();
|
||||
LABEL.get_or_init(|| conflict_label_of(&this_hostname()))
|
||||
}
|
||||
|
||||
/// `host` as one token: anything but ASCII letters, digits, `-`, `_` and `.`
|
||||
/// becomes `-`, so the label holds no space for `conflict_base_name` to trip
|
||||
/// on and no `/` or `)` to break the name.
|
||||
fn conflict_label_of(host: &str) -> String {
|
||||
let label: String = host
|
||||
.chars()
|
||||
.take(32)
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
label.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// The inside of a conflict copy's marker: `<label> <stamp>`, with `-<n>`
|
||||
/// when an earlier copy already holds that name. Without a label it is the
|
||||
/// bare stamp that copies had before B251.
|
||||
fn conflict_tag(label: &str, stamp: i64, suffix: u32) -> String {
|
||||
let suffix = if suffix == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{suffix}")
|
||||
};
|
||||
if label.is_empty() {
|
||||
format!("{stamp}{suffix}")
|
||||
} else {
|
||||
format!("{label} {stamp}{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6661,7 +6959,27 @@ fn fuse_name(name: &OsStr) -> Result<String, Errno> {
|
||||
name.to_str().map(str::to_owned).ok_or(Errno::EILSEQ)
|
||||
}
|
||||
|
||||
/// Whether a decrypted remote name can stand as one local path component.
|
||||
///
|
||||
/// Drive checks names only when a client creates or renames a node, and only
|
||||
/// for length, so a name made by another client, or by a server that should
|
||||
/// not be trusted, can hold anything. Joined onto a local path, a `/` or a `..`
|
||||
/// in it walks out of the folder it belongs to, and the mirror engine would
|
||||
/// then write or delete outside the sync root. In the mount, the kernel fails
|
||||
/// the whole listing with EIO on a single such name (`docs/BUGS.md` B215).
|
||||
pub(crate) fn remote_name_is_safe(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name != "."
|
||||
&& name != ".."
|
||||
&& name.len() <= 255
|
||||
&& !name.contains(['/', '\0'])
|
||||
}
|
||||
|
||||
fn node_visible(node: &Node, folder_uid: &NodeUid, hidden: &HashSet<NodeUid>) -> bool {
|
||||
if !remote_name_is_safe(&node.name) {
|
||||
warn!(uid = %node.uid, "a remote name cannot be a local file name; leaving it out");
|
||||
return false;
|
||||
}
|
||||
!node.trashed && node.uid != *folder_uid && !hidden.contains(&node.uid)
|
||||
}
|
||||
|
||||
@@ -6997,13 +7315,15 @@ mod size_upgrade_tests {
|
||||
fn a_listing_waits_for_the_whole_size_batch_but_not_forever() {
|
||||
let slot = Arc::new(SizeUpgrade::default());
|
||||
let worker = slot.clone();
|
||||
// Read before the spawn: the worker's sleeps can start before this
|
||||
// thread runs again, which made the bound below fail on a busy runner.
|
||||
let started = Instant::now();
|
||||
let t = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
worker.chunk_done();
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
worker.finish();
|
||||
});
|
||||
let started = Instant::now();
|
||||
slot.wait_finished(started + Duration::from_secs(5));
|
||||
// Past the first chunk, up to the end of the batch.
|
||||
assert!(started.elapsed() >= Duration::from_millis(90));
|
||||
@@ -7410,14 +7730,15 @@ mod tests {
|
||||
use super::{
|
||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||
off_the_runtime, orphan_past_retention, parse_node_uid, prepare_shared_roots,
|
||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
||||
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
||||
accepted_share_provenance, conflict_label, conflict_label_of, conflict_name,
|
||||
conflict_name_as, copy_pending_for_truncate, expand_restore, first_seen_in_trash,
|
||||
fuse_name, is_stale_mount, node_visible, note_self_change, off_the_runtime,
|
||||
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||
resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
|
||||
virtual_node,
|
||||
};
|
||||
use super::{Db, WriteAuthority};
|
||||
use std::time::Duration;
|
||||
@@ -8372,15 +8693,38 @@ mod tests {
|
||||
#[test]
|
||||
fn conflict_name_keeps_the_extension() {
|
||||
assert_eq!(
|
||||
conflict_name("notes.txt", 1700),
|
||||
"notes (sync-conflict 1700).txt"
|
||||
conflict_name_as("notes.txt", "laptop", 1700),
|
||||
"notes (sync-conflict laptop 1700).txt"
|
||||
);
|
||||
assert_eq!(conflict_name("README", 42), "README (sync-conflict 42)");
|
||||
assert_eq!(
|
||||
conflict_name("archive.tar.gz", 7),
|
||||
"archive.tar (sync-conflict 7).gz",
|
||||
conflict_name_as("README", "laptop", 42),
|
||||
"README (sync-conflict laptop 42)"
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_name_as("archive.tar.gz", "laptop", 7),
|
||||
"archive.tar (sync-conflict laptop 7).gz",
|
||||
"only the last extension is one, as everywhere else"
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_name_as("notes.txt", "", 1700),
|
||||
"notes (sync-conflict 1700).txt"
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_name("notes.txt", 1700),
|
||||
conflict_name_as("notes.txt", conflict_label(), 1700)
|
||||
);
|
||||
}
|
||||
|
||||
/// A hostname goes into a file name, so it is cut down to one safe token
|
||||
/// (B251).
|
||||
#[test]
|
||||
fn a_hostname_becomes_one_safe_token() {
|
||||
assert_eq!(conflict_label_of("laptop"), "laptop");
|
||||
assert_eq!(conflict_label_of("my-pc.local"), "my-pc.local");
|
||||
assert_eq!(conflict_label_of("Linux device"), "Linux-device");
|
||||
assert_eq!(conflict_label_of("a/b) c"), "a-b--c");
|
||||
assert_eq!(conflict_label_of(" "), "");
|
||||
assert_eq!(conflict_label_of(&"x".repeat(40)).len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -8572,6 +8916,28 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_name_that_is_not_one_path_component_is_left_out() {
|
||||
let folder_uid = NodeUid::new(VolumeId::from("vol"), LinkId::from("folder"));
|
||||
let none = std::collections::HashSet::new();
|
||||
for name in [
|
||||
"a/b",
|
||||
"../../.bashrc",
|
||||
"..",
|
||||
".",
|
||||
"",
|
||||
"a\0b",
|
||||
&"x".repeat(256),
|
||||
] {
|
||||
let node = node_helper("child", "folder", name, false);
|
||||
assert!(!node_visible(&node, &folder_uid, &none), "{name:?}");
|
||||
}
|
||||
for name in [".hidden", "a..b", "unicodé-文件", &"x".repeat(255)] {
|
||||
let node = node_helper("child", "folder", name, false);
|
||||
assert!(node_visible(&node, &folder_uid, &none), "{name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_queue_guard_preserves_accepted_bytes_and_pending_count() {
|
||||
use std::cell::Cell;
|
||||
@@ -9077,6 +9443,32 @@ mod tests {
|
||||
assert!(online.contains("self.relist_offline_listings();"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_shared_listing_is_fetched_off_the_dispatch_loop() {
|
||||
// A resident listing that `list_children` would fetch again anyway
|
||||
// must not be answered inline (B219).
|
||||
let cached = function_source(include_str!("lib.rs"), "fn children_cached(");
|
||||
assert!(cached.contains("SHARED_WITH_ME_SYNCED_MS"));
|
||||
assert!(cached.contains("Self::shared_folder_freshness_key(&uid)"));
|
||||
assert!(cached.contains("listing_needs_refresh("));
|
||||
assert!(cached.contains("self.shared_listing_stale(&key)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handler_does_not_wait_out_the_sdk_retry_budget() {
|
||||
// Thumbnails, size upgrades and the share list held mount threads for
|
||||
// minutes on a stalled link (B220).
|
||||
let lib = include_str!("lib.rs");
|
||||
let thumbnail = function_source(lib, "fn thumbnail(");
|
||||
assert_before(thumbnail, "if !self.is_online()", "download_thumbnail");
|
||||
assert!(thumbnail.contains(".block_on_bounded(self.client.download_thumbnail("));
|
||||
let upgrade = function_source(lib, "fn run_size_upgrade(");
|
||||
assert!(upgrade.contains("block_on_within(BATCH_CALL_TIMEOUT"));
|
||||
let shared = function_source(lib, "fn ensure_shared_children(");
|
||||
assert!(shared.contains("block_on_within(BATCH_CALL_TIMEOUT"));
|
||||
assert!(!shared.contains("self.rt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_read_gives_up_at_its_deadline() {
|
||||
// The SDK bounds requests, not reads; a stalled link held one `read`
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use pdfs_core::error::session_revoked;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -28,6 +29,13 @@ use crate::{Core, ONLINE_PROBE_MAX, ONLINE_PROBE_MIN};
|
||||
/// keep waiting for; queueing the change is the better answer.
|
||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// How long a mount thread waits on a remote read that answers a batch: a
|
||||
/// chunk of file sizes, or the list of shares. Longer than an interactive call,
|
||||
/// since its answer grows with the batch, but well short of the SDK's own retry
|
||||
/// budget, which held a worker for minutes on a stalled link
|
||||
/// (`docs/BUGS.md` B220).
|
||||
pub(crate) const BATCH_CALL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long one probe may wait for an answer. A probe is a single small request,
|
||||
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||
/// back by as long as it waits.
|
||||
@@ -54,6 +62,26 @@ pub(crate) struct Link {
|
||||
changed: Condvar,
|
||||
/// Folders listed from the DB alone while offline, to relist once back.
|
||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
||||
/// Proton refused the session itself ([`session_revoked`]). The mount is
|
||||
/// offline then, but no probe brings it back: only a new login does, and
|
||||
/// that restarts the daemon. Read by front-ends through `Response::Status`,
|
||||
/// so they can ask the user to sign in again rather than say "offline".
|
||||
pub(crate) session_expired: AtomicBool,
|
||||
/// Proton refused the session, but only because another process rotated
|
||||
/// it (`pdfs unlock`): the keyring holds the live tokens. The mount loop
|
||||
/// ends the mount for the daemon to mount again from the keyring.
|
||||
pub(crate) resume_stored: AtomicBool,
|
||||
}
|
||||
|
||||
impl Link {
|
||||
/// The link of a mount that comes up with Proton refusing its session, or
|
||||
/// not.
|
||||
pub(crate) fn starting(session_expired: bool) -> Self {
|
||||
Self {
|
||||
session_expired: AtomicBool::new(session_expired),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed call failed because Proton could not be reached, rather
|
||||
@@ -91,10 +119,25 @@ pub(crate) fn no_answer(after: Duration) -> ProtonError {
|
||||
/// transfer that has stopped moving runs into it.
|
||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
||||
|
||||
/// How many uploads can share the upload cap at once: one per drain worker
|
||||
/// and one per mirror sync task.
|
||||
const UPLOAD_SLOTS: u64 = (crate::DRAIN_WORKERS + crate::sync::SYNC_CONCURRENCY) as u64;
|
||||
|
||||
/// How long an upload of `len` bytes may take before the link is treated as
|
||||
/// down: the budget of a metadata call, plus the bytes at [`SLOWEST_UPLOAD`].
|
||||
pub(crate) fn upload_deadline(len: u64) -> Duration {
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / SLOWEST_UPLOAD)
|
||||
///
|
||||
/// An upload cap of `cap` bytes per second (`0` for none) is shared by every
|
||||
/// upload running, so each may get as little as its share of it. Below
|
||||
/// [`SLOWEST_UPLOAD`] a share counts as the slowest rate instead: measured
|
||||
/// against the fixed rate, a large file under a low cap ran out of time on
|
||||
/// every attempt, took the mount offline each time, and started over from
|
||||
/// its first byte.
|
||||
pub(crate) fn upload_deadline(len: u64, cap: u64) -> Duration {
|
||||
let slowest = match cap {
|
||||
0 => SLOWEST_UPLOAD,
|
||||
cap => SLOWEST_UPLOAD.min((cap / UPLOAD_SLOTS).max(1)),
|
||||
};
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(len / slowest)
|
||||
}
|
||||
|
||||
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||
@@ -161,10 +204,20 @@ impl Core {
|
||||
self.rt.block_on(bounded_by(limit, call))
|
||||
}
|
||||
|
||||
/// Whether Proton refused the session itself, so nothing reaches it until
|
||||
/// the user signs in again.
|
||||
pub(crate) fn session_expired(&self) -> bool {
|
||||
self.link.session_expired.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Report a failed remote call. Returns whether it failed for want of a
|
||||
/// network, in which case the mount is now offline and the caller should
|
||||
/// take its queued path instead of failing.
|
||||
/// network, or of a session, in which case the mount is now offline and the
|
||||
/// caller should take its queued path instead of failing.
|
||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
||||
if session_revoked(e) {
|
||||
self.mark_session_expired(what, e);
|
||||
return true;
|
||||
}
|
||||
if !is_network_error(e) {
|
||||
return false;
|
||||
}
|
||||
@@ -172,6 +225,29 @@ impl Core {
|
||||
true
|
||||
}
|
||||
|
||||
/// Flip the mount offline for good: Proton refused the session, and only a
|
||||
/// new login brings it back. Changes stay queued for the daemon that login
|
||||
/// starts. Only the first caller logs it.
|
||||
///
|
||||
/// Unless the keyring holds a newer session than the refused one: then
|
||||
/// the mount ends, for the daemon to mount again on that one.
|
||||
pub(crate) fn mark_session_expired(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
if self.link.resume_stored.load(Ordering::Relaxed) {
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if pdfs_core::auth::stored_session_is_newer() {
|
||||
info!(during = what, %error,
|
||||
"Proton refused a session another process has since refreshed; resuming it");
|
||||
self.link.resume_stored.store(true, Ordering::Relaxed);
|
||||
return self.mark_offline(what, error);
|
||||
}
|
||||
if !self.link.session_expired.swap(true, Ordering::Relaxed) {
|
||||
warn!(during = what, %error,
|
||||
"Proton refused the session; run `pdfs login` to sign in again");
|
||||
}
|
||||
self.mark_offline(what, error);
|
||||
}
|
||||
|
||||
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||
/// outage logs it; the rest are the same outage.
|
||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||
@@ -204,8 +280,9 @@ impl Core {
|
||||
self.relist_offline_listings();
|
||||
// Anything written while offline is queued and waiting on exactly this,
|
||||
// including ops that failed on the way down and are sitting out a
|
||||
// backoff sized for a server that keeps refusing.
|
||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
||||
// backoff sized for a server that keeps refusing. Ops Drive refused
|
||||
// keep theirs: the link was not why.
|
||||
if let Err(e) = self.db.retry_unrefused_ops_now(crate::now_millis()) {
|
||||
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||
}
|
||||
self.wake_drain();
|
||||
@@ -276,6 +353,9 @@ impl Core {
|
||||
|
||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||
/// if the daemon is stopping.
|
||||
///
|
||||
/// A refused session is not probed: every probe would be refused the same
|
||||
/// way, forever (issue #35). The thread waits for the stop instead.
|
||||
fn probe_until_online(&self) -> bool {
|
||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||
@@ -287,6 +367,10 @@ impl Core {
|
||||
if !self.shutdown.sleep(delay) {
|
||||
return false;
|
||||
}
|
||||
if self.session_expired() {
|
||||
delay = ONLINE_PROBE_MAX;
|
||||
continue;
|
||||
}
|
||||
let probe = self.rt.block_on(async {
|
||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||
Ok(result) => result,
|
||||
@@ -308,6 +392,7 @@ impl Core {
|
||||
self.mark_online();
|
||||
return true;
|
||||
}
|
||||
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||
Err(e) => {
|
||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||
delay = next_probe_delay(delay, started.elapsed());
|
||||
@@ -341,13 +426,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
||||
assert_eq!(upload_deadline(0), INTERACTIVE_CALL_TIMEOUT);
|
||||
assert_eq!(upload_deadline(0, 0), INTERACTIVE_CALL_TIMEOUT);
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30),
|
||||
upload_deadline(1 << 30, 0),
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upload_under_a_low_cap_gets_the_time_its_share_of_the_cap_needs() {
|
||||
// A generous cap leaves the deadline as it was.
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30, 100 << 20),
|
||||
upload_deadline(1 << 30, 0)
|
||||
);
|
||||
// 512 KiB/s shared by every upload slot is below the slowest rate.
|
||||
let share = (512 << 10) / UPLOAD_SLOTS;
|
||||
assert!(share < SLOWEST_UPLOAD);
|
||||
assert_eq!(
|
||||
upload_deadline(1 << 30, 512 << 10),
|
||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs((1 << 30) / share)
|
||||
);
|
||||
// A cap below one byte per slot still gives a finite deadline.
|
||||
assert!(upload_deadline(1 << 20, 1) > upload_deadline(1 << 20, 512 << 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
|
||||
+125
-33
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||
/// The caller may want to remount.
|
||||
Unmounted,
|
||||
/// Proton refused the session, and the keyring holds a newer one: another
|
||||
/// process (`pdfs unlock`) refreshed it, or a new login replaced it. We
|
||||
/// unmounted ourselves; the caller should mount again, which resumes it.
|
||||
ResumeStored,
|
||||
}
|
||||
|
||||
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
|
||||
@@ -210,30 +214,60 @@ fn fetch_or_recall_root(
|
||||
drive: &dyn DriveApi,
|
||||
rt: &tokio::runtime::Handle,
|
||||
db: &Db,
|
||||
) -> std::io::Result<(Node, bool)> {
|
||||
) -> std::io::Result<(Node, Start)> {
|
||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||
Ok(root) => {
|
||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||
warn!(error = %e, "persist root uid failed");
|
||||
}
|
||||
return Ok((root, true));
|
||||
return Ok((root, Start::Online));
|
||||
}
|
||||
Err(e) => e,
|
||||
};
|
||||
// Still mounted from the cache, so cached files stay readable and changes
|
||||
// queue for the daemon the next login starts.
|
||||
let start = if pdfs_core::error::session_revoked(&err) {
|
||||
Start::SessionExpired
|
||||
} else {
|
||||
Start::Offline
|
||||
};
|
||||
if start == Start::SessionExpired && pdfs_core::auth::stored_session_is_newer() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Proton refused a session another process has since refreshed ({err}); resuming it"
|
||||
)));
|
||||
}
|
||||
let cached = db
|
||||
.state_str(ROOT_UID_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||
match cached {
|
||||
Some(root) if start == Start::SessionExpired => {
|
||||
warn!(error = %err,
|
||||
"Proton refused the session; mounting from cache until `pdfs login`");
|
||||
Ok((root, start))
|
||||
}
|
||||
Some(root) => {
|
||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
||||
Ok((root, false))
|
||||
Ok((root, start))
|
||||
}
|
||||
None if start == Start::SessionExpired => Err(std::io::Error::other(format!(
|
||||
"Proton refused the session ({err}); run `pdfs login`"
|
||||
))),
|
||||
None => Err(std::io::Error::other(format!("fetch My Files root: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a mount comes up, from what fetching its root said.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Start {
|
||||
Online,
|
||||
/// Proton could not be reached: serve the cache and probe for the link.
|
||||
Offline,
|
||||
/// Proton refused the session: serve the cache until a new login.
|
||||
SessionExpired,
|
||||
}
|
||||
|
||||
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||
/// caller, so `mount` itself never reads config or the environment.
|
||||
pub struct MountOptions {
|
||||
@@ -393,7 +427,8 @@ pub(crate) fn mount_with(
|
||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||
// so the age it reports is the daemon's own.
|
||||
super::diagnostics::start_clock();
|
||||
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||
let online = start == Start::Online;
|
||||
let scope = root.tree_event_scope_id();
|
||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||
@@ -429,7 +464,7 @@ pub(crate) fn mount_with(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
link: Arc::new(Default::default()),
|
||||
link: Arc::new(link::Link::starting(start == Start::SessionExpired)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -472,9 +507,11 @@ pub(crate) fn mount_with(
|
||||
events: Arc::new(Default::default()),
|
||||
indexing: Arc::new(AtomicBool::new(false)),
|
||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_engine: Arc::default(),
|
||||
sync_tx,
|
||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||
states: Arc::new(StateRegistry::default()),
|
||||
};
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
@@ -504,12 +541,11 @@ pub(crate) fn mount_with(
|
||||
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
for worker in 0..DRAIN_WORKERS {
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pdfs-drain-{worker}"))
|
||||
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||
);
|
||||
let drain_core = core.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name(format!("pdfs-drain-{worker}"))
|
||||
.spawn(move || drain_core.run_pending_drain(worker == 0));
|
||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||
}
|
||||
|
||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||
@@ -529,24 +565,22 @@ pub(crate) fn mount_with(
|
||||
info!("conflict sweep disabled by configuration");
|
||||
} else {
|
||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || core.run_conflict_sweep_loop())?,
|
||||
);
|
||||
let sweep_core = core.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-conflict-sweep".into())
|
||||
.spawn(move || sweep_core.run_conflict_sweep_loop());
|
||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||
}
|
||||
|
||||
// Watch the link for the life of the mount: a mount from the cache waits for
|
||||
// the network to come back, and an online one can lose it at any moment and
|
||||
// must notice it coming back just the same.
|
||||
{
|
||||
let core = core.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || core.run_online_probe())?,
|
||||
);
|
||||
let probe_core = core.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-online-probe".into())
|
||||
.spawn(move || probe_core.run_online_probe());
|
||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||
}
|
||||
|
||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||
@@ -557,11 +591,10 @@ pub(crate) fn mount_with(
|
||||
let transfers = core.transfers.clone();
|
||||
let mountpoint = mountpoint.to_path_buf();
|
||||
let shutdown = core.shutdown.clone();
|
||||
workers.push(
|
||||
std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||
);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfs-localindex".into())
|
||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown));
|
||||
keep_or_abandon(&core, control_socket, &mut workers, spawned)?;
|
||||
}
|
||||
|
||||
// Bind the control socket before the FUSE session takes over the thread. A
|
||||
@@ -570,12 +603,18 @@ pub(crate) fn mount_with(
|
||||
let old_umask = unsafe { libc::umask(0o77) };
|
||||
let listener_res = UnixListener::bind(control_socket);
|
||||
unsafe { libc::umask(old_umask) };
|
||||
let listener = listener_res?;
|
||||
let listener = match listener_res {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
abandon_workers(&core, control_socket, workers);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
// 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);
|
||||
abandon_workers(&core, control_socket, workers);
|
||||
return Err(std::io::Error::other(format!(
|
||||
"control socket permissions: {e}"
|
||||
)));
|
||||
@@ -586,7 +625,7 @@ pub(crate) fn mount_with(
|
||||
let bg = match spawn_session(&core, mountpoint, root) {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let _ = std::fs::remove_file(control_socket);
|
||||
abandon_workers(&core, control_socket, workers);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
@@ -606,7 +645,7 @@ pub(crate) fn mount_with(
|
||||
Err(error) => {
|
||||
// A failure here abandons the mount, so the background threads
|
||||
// started above have to be told to stop with it.
|
||||
stop_workers(&core, control_socket, workers);
|
||||
abandon_workers(&core, control_socket, workers);
|
||||
let secondaries = core.mounts.lock().close_and_drain();
|
||||
for (id, mount) in secondaries {
|
||||
if let Err(unmount_error) = mount.teardown() {
|
||||
@@ -656,8 +695,12 @@ pub(crate) fn mount_with(
|
||||
}
|
||||
}
|
||||
// The mount is live and the control socket answers: this is the moment the
|
||||
// unit is actually usable, which is what `Type=notify` reports.
|
||||
// unit is actually usable, which is what `Type=notify` reports. Whatever
|
||||
// failure an earlier attempt recorded for the front ends is over.
|
||||
systemd::ready();
|
||||
if let Some(state_dir) = control_socket.parent() {
|
||||
pdfs_core::mountfail::clear(state_dir);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -718,6 +761,18 @@ pub(crate) fn mount_with(
|
||||
}
|
||||
break MountOutcome::Shutdown;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if core.link.resume_stored.load(Ordering::Relaxed) =>
|
||||
{
|
||||
info!("unmounting to resume the stored session");
|
||||
core.mounts.lock().close();
|
||||
if let Err(e) = teardown_session(&core.session_live, || {
|
||||
umount_session_unblocked(bg, main_conn)
|
||||
}) {
|
||||
warn!(error = %e, "umount_and_join failed");
|
||||
}
|
||||
break MountOutcome::ResumeStored;
|
||||
}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||
if bg.guard.is_finished() {
|
||||
info!("mount ended externally");
|
||||
@@ -811,6 +866,43 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
||||
// Say so before the joins: a teardown that takes a while must not be read as
|
||||
// a missed watchdog ping and turned into a kill.
|
||||
systemd::stopping();
|
||||
halt_workers(core, control_socket, workers);
|
||||
}
|
||||
|
||||
/// Stop the threads a mount attempt started before it failed.
|
||||
///
|
||||
/// The early returns used to leave them running: every retry started another
|
||||
/// set beside the last one's, and their `Core` kept the database open, so the
|
||||
/// next attempt could fail on its lock instead of the real error
|
||||
/// (`docs/BUGS.md` B264). Unlike [`stop_workers`] this does not tell systemd
|
||||
/// the daemon is stopping, because it is about to try again, and a manager
|
||||
/// told so would wait for the process to exit and then kill it.
|
||||
fn abandon_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||
halt_workers(core, control_socket, workers);
|
||||
}
|
||||
|
||||
/// Keep a worker the mount just spawned, or, if the spawn failed, stop the
|
||||
/// ones already running and hand the error back.
|
||||
fn keep_or_abandon(
|
||||
core: &Core,
|
||||
control_socket: &Path,
|
||||
workers: &mut Vec<std::thread::JoinHandle<()>>,
|
||||
spawned: std::io::Result<std::thread::JoinHandle<()>>,
|
||||
) -> std::io::Result<()> {
|
||||
match spawned {
|
||||
Ok(handle) => {
|
||||
workers.push(handle);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
abandon_workers(core, control_socket, std::mem::take(workers));
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal every worker to stop, then join them up to [`WORKER_JOIN_DEADLINE`].
|
||||
fn halt_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||
core.shutdown.stop();
|
||||
core.wake_drain();
|
||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||
|
||||
@@ -226,11 +226,7 @@ fn scale_thumbnail_with_exiftool(
|
||||
exiftool: &OsStr,
|
||||
timeout: Duration,
|
||||
) -> ScaleAttempt {
|
||||
let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|reader| reader.decode().ok());
|
||||
let image = match direct {
|
||||
let image = match decode_upright(bytes) {
|
||||
Some(image) => image,
|
||||
None if is_raw_image_name(name) => {
|
||||
let (preview, orientation) =
|
||||
@@ -256,6 +252,27 @@ fn scale_thumbnail_with_exiftool(
|
||||
encode_thumb(&image)
|
||||
}
|
||||
|
||||
/// Decode `bytes` and turn the picture the way its EXIF orientation says.
|
||||
///
|
||||
/// `ImageReader::decode` does not: a phone stores a portrait photo as landscape
|
||||
/// pixels plus an orientation tag, so every such thumbnail came out on its
|
||||
/// side, and the drain uploaded it to Drive that way for every client to show
|
||||
/// (`docs/BUGS.md` B226).
|
||||
fn decode_upright(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
use image::ImageDecoder as _;
|
||||
let mut decoder = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.into_decoder()
|
||||
.ok()?;
|
||||
let orientation = decoder
|
||||
.orientation()
|
||||
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||
let mut image = image::DynamicImage::from_decoder(decoder).ok()?;
|
||||
image.apply_orientation(orientation);
|
||||
Some(image)
|
||||
}
|
||||
|
||||
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||||
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||||
let (width, height) = (image.width(), image.height());
|
||||
@@ -417,6 +434,16 @@ fn exiftool_query(
|
||||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
||||
}
|
||||
|
||||
/// Whether [`upload_thumbnails`] can make a thumbnail for a file `name` of
|
||||
/// `size` bytes, asked before the file is on disk: an image small enough to
|
||||
/// decode, or a video while ffmpeg is installed.
|
||||
pub(crate) fn can_make_thumbnail(name: &str, size: u64) -> bool {
|
||||
if is_video_name(name) {
|
||||
return ffmpeg_available();
|
||||
}
|
||||
is_thumbnail_image_name(name) && size <= UPLOAD_IMAGE_MAX_BYTES
|
||||
}
|
||||
|
||||
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||||
/// than a picture the image decoder can scale.
|
||||
fn is_video_name(name: &str) -> bool {
|
||||
@@ -2092,9 +2119,10 @@ impl Core {
|
||||
#[cfg(test)]
|
||||
mod thumb_tests {
|
||||
use super::{
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||||
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
|
||||
ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool, similar_hashes,
|
||||
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||
};
|
||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||
use proton_drive_rs::ThumbnailType;
|
||||
@@ -2149,6 +2177,64 @@ mod thumb_tests {
|
||||
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||||
}
|
||||
|
||||
/// `jpeg` with an EXIF APP1 segment that carries only `orientation`.
|
||||
fn jpeg_with_orientation(width: u32, height: u32, orientation: u16) -> Vec<u8> {
|
||||
let plain = jpeg(width, height);
|
||||
let mut tiff = Vec::new();
|
||||
tiff.extend_from_slice(b"II*\0");
|
||||
tiff.extend_from_slice(&8u32.to_le_bytes());
|
||||
tiff.extend_from_slice(&1u16.to_le_bytes());
|
||||
tiff.extend_from_slice(&0x0112u16.to_le_bytes());
|
||||
tiff.extend_from_slice(&3u16.to_le_bytes());
|
||||
tiff.extend_from_slice(&1u32.to_le_bytes());
|
||||
tiff.extend_from_slice(&orientation.to_le_bytes());
|
||||
tiff.extend_from_slice(&[0, 0]);
|
||||
tiff.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut app1 = b"Exif\0\0".to_vec();
|
||||
app1.extend_from_slice(&tiff);
|
||||
let mut out = plain[..2].to_vec();
|
||||
out.extend_from_slice(&[0xFF, 0xE1]);
|
||||
out.extend_from_slice(&u16::try_from(app1.len() + 2).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(&app1);
|
||||
out.extend_from_slice(&plain[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// A phone stores a portrait photo as landscape pixels and an EXIF
|
||||
/// orientation; its thumbnail came out on its side (`docs/BUGS.md` B226).
|
||||
/// A Takeout photo is copied out of its archive only when a thumbnail can
|
||||
/// be made from the copy.
|
||||
#[test]
|
||||
fn only_a_photo_that_can_have_a_thumbnail_is_worth_copying_out() {
|
||||
assert!(can_make_thumbnail("IMG_1.jpg", 4 << 20));
|
||||
assert!(can_make_thumbnail("Screenshot.png", 4 << 20));
|
||||
assert!(!can_make_thumbnail("IMG_1.jpg", UPLOAD_IMAGE_MAX_BYTES + 1));
|
||||
assert!(!can_make_thumbnail("notes.txt", 10));
|
||||
assert_eq!(can_make_thumbnail("clip.mp4", 1 << 30), ffmpeg_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thumbnail_is_turned_the_way_its_exif_says() {
|
||||
let ScaleAttempt::Made(thumb) = scale_thumbnail(
|
||||
&jpeg_with_orientation(2000, 1000, 6),
|
||||
"portrait.jpg",
|
||||
&std::env::temp_dir(),
|
||||
) else {
|
||||
panic!("a JPEG scales");
|
||||
};
|
||||
assert!(thumb.ratio < 1.0, "turned upright: {}", thumb.ratio);
|
||||
assert_eq!(ratio_of(&thumb.bytes).map(|r| r < 1.0), Some(true));
|
||||
|
||||
let ScaleAttempt::Made(level) = scale_thumbnail(
|
||||
&jpeg_with_orientation(2000, 1000, 1),
|
||||
"landscape.jpg",
|
||||
&std::env::temp_dir(),
|
||||
) else {
|
||||
panic!("a JPEG scales");
|
||||
};
|
||||
assert!(level.ratio > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_bytes_are_not_a_thumbnail() {
|
||||
// What a photo in a format this build has no decoder for looks like: the
|
||||
|
||||
+158
-56
@@ -15,6 +15,7 @@ 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,
|
||||
is_profile_file_name, profile_file_name, saved_folder,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
@@ -66,9 +67,14 @@ impl Core {
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||
/// one.
|
||||
/// Serialize this machine's arrangement and upload it to its own
|
||||
/// `profile-<machine id>.json` inside the device's `.proton-drive-linux`
|
||||
/// folder, replacing any existing one.
|
||||
///
|
||||
/// Each machine writes its own file because several computers may sync
|
||||
/// into one device. They used to share `profile.json`, and each save
|
||||
/// replaced the other computers' folders with its own (B252). That file is
|
||||
/// left as it is, so a client before 3.5.0 still finds it.
|
||||
///
|
||||
/// Does nothing when no device is registered: a machine that has never
|
||||
/// synced a folder has nothing to describe, and registering a device just to
|
||||
@@ -85,7 +91,11 @@ impl Core {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
|
||||
let profile = self.build_profile(&device.uid)?;
|
||||
let machine_id = pdfs_core::config::AppDirs::new()
|
||||
.and_then(|dirs| dirs.machine_id())
|
||||
.map_err(|e| CoreError::internal(format!("machine id: {e}")))?;
|
||||
let file_name = profile_file_name(&machine_id);
|
||||
let profile = self.build_profile(&device.uid, &machine_id)?;
|
||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||
let len = bytes.len() as i64;
|
||||
|
||||
@@ -97,7 +107,7 @@ impl Core {
|
||||
// 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)?;
|
||||
let existing = self.find_device_child_file(&dir_uid, &file_name)?;
|
||||
match existing {
|
||||
Some(uid) => self
|
||||
.rt
|
||||
@@ -114,7 +124,7 @@ impl Core {
|
||||
self.rt
|
||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||
&dir_uid,
|
||||
PROFILE_FILE_NAME,
|
||||
&file_name,
|
||||
"application/json",
|
||||
Cursor::new(bytes),
|
||||
len,
|
||||
@@ -130,7 +140,7 @@ impl Core {
|
||||
}
|
||||
|
||||
/// Snapshot the local arrangement into a [`Profile`].
|
||||
fn build_profile(&self, device_uid: &str) -> CoreResult<Profile> {
|
||||
fn build_profile(&self, device_uid: &str, machine_id: &str) -> CoreResult<Profile> {
|
||||
let folders = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
@@ -162,6 +172,7 @@ impl Core {
|
||||
version: PROFILE_VERSION,
|
||||
device_uid: device_uid.to_string(),
|
||||
hostname: this_hostname(),
|
||||
machine_id: Some(machine_id.to_string()),
|
||||
saved_at: now_secs(),
|
||||
folders,
|
||||
pins,
|
||||
@@ -197,6 +208,31 @@ impl Core {
|
||||
.map(|n| n.uid))
|
||||
}
|
||||
|
||||
/// The untrashed files directly under `parent_uid` whose name passes
|
||||
/// `keep`.
|
||||
fn device_child_files(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
keep: impl Fn(&str) -> bool,
|
||||
) -> CoreResult<Vec<NodeUid>> {
|
||||
let uids = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_folder_children_node_uids(parent_uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let nodes = self
|
||||
.rt
|
||||
.block_on(self.drive.enumerate_nodes(&uids))
|
||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
||||
Ok(nodes
|
||||
.into_iter()
|
||||
.filter(|n| !n.is_folder() && !n.trashed && keep(&n.name))
|
||||
.map(|n| n.uid)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||
/// first save on this device.
|
||||
///
|
||||
@@ -218,32 +254,43 @@ impl Core {
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Download and parse this device's profile, if there is one.
|
||||
/// Download and parse the profiles of every computer that syncs this
|
||||
/// device.
|
||||
///
|
||||
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
||||
/// 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,
|
||||
/// Reads every profile in `.proton-drive-linux/`: one per machine, and the
|
||||
/// shared `profile.json` of clients before 3.5.0. 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. A profile that cannot be read is left out and
|
||||
/// logged; the others still count.
|
||||
fn load_profiles(&self, root_uid: &NodeUid) -> CoreResult<Vec<Profile>> {
|
||||
let mut uids = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||
Some(dir_uid) => self.device_child_files(&dir_uid, is_profile_file_name)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let uid = match current {
|
||||
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
|
||||
if uids.is_empty() {
|
||||
uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?;
|
||||
if !uids.is_empty() {
|
||||
info!("reading legacy profile from the device root");
|
||||
}
|
||||
}
|
||||
let mut profiles = Vec::new();
|
||||
for uid in uids {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let parsed = self
|
||||
.rt
|
||||
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||
.map_err(|e| CoreError::from_api(&e, "download profile"))
|
||||
.and_then(|()| Profile::parse(&buf).map_err(CoreError::invalid));
|
||||
match parsed {
|
||||
Ok(profile) => profiles.push(profile),
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e.message, "could not read a profile; leaving it out")
|
||||
}
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
self.rt
|
||||
.block_on(self.drive.download_file_to(&uid, &mut buf))
|
||||
.map_err(|e| CoreError::from_api(&e, "download profile"))?;
|
||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
||||
}
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
// ---- restore ----------------------------------------
|
||||
@@ -265,15 +312,16 @@ impl Core {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
|
||||
let profile = match self.load_profile(&root_uid) {
|
||||
let profiles = match self.load_profiles(&root_uid) {
|
||||
Ok(p) => p,
|
||||
// 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
|
||||
warn!(error = %e.message, "could not read profiles; proposing defaults");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let hostname = this_hostname();
|
||||
|
||||
let uids = self
|
||||
.rt
|
||||
@@ -304,11 +352,12 @@ impl Core {
|
||||
// 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)
|
||||
// The name becomes a folder under the home folder: one with a `/`
|
||||
// or a `..` in it would put the restore somewhere else.
|
||||
.filter(|n| crate::remote_name_is_safe(&n.name))
|
||||
.map(|n| {
|
||||
let uid = n.uid.to_string();
|
||||
let saved = profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
|
||||
let saved = saved_folder(&profiles, &uid, &hostname);
|
||||
let proposed = saved
|
||||
.map(|f| PathBuf::from(&f.local_path))
|
||||
.filter(|p| path_is_usable_here(p))
|
||||
@@ -339,7 +388,7 @@ impl Core {
|
||||
&self,
|
||||
items: &[pdfs_core::control::RestoreItem],
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<String> {
|
||||
) -> CoreResult<RestoreOutcome> {
|
||||
if items.is_empty() {
|
||||
return Err(CoreError::invalid("nothing to restore"));
|
||||
}
|
||||
@@ -349,23 +398,29 @@ impl Core {
|
||||
.sync_folder_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||
|
||||
let reserved = crate::devices::sync_root_reserved();
|
||||
let mut roots: Vec<PathBuf> = existing
|
||||
.iter()
|
||||
.map(|f| PathBuf::from(&f.local_path))
|
||||
.collect();
|
||||
let mut restored = 0usize;
|
||||
let mut 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;
|
||||
}
|
||||
// The path is proposed from the profile another machine wrote, and
|
||||
// may hold a folder name from Drive: checked like any new root.
|
||||
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
||||
Ok(local) => local,
|
||||
Err(e) => {
|
||||
skipped.push(format!("{}: {e}", 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));
|
||||
if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) {
|
||||
skipped.push(format!("{}: {problem}", item.local_path));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = std::fs::create_dir_all(&local) {
|
||||
@@ -379,10 +434,19 @@ impl Core {
|
||||
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:?}")))?;
|
||||
let id = match self.db.sync_folder_add(
|
||||
&local.to_string_lossy(),
|
||||
&item.remote_uid,
|
||||
&device.share_id,
|
||||
) {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
// The folders restored before this one still need their
|
||||
// watches.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
return Err(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.
|
||||
@@ -390,16 +454,44 @@ impl Core {
|
||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||
}
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||
roots.push(local);
|
||||
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(", ")));
|
||||
// The reply is gone once the app has shown it; the log keeps why.
|
||||
for reason in &skipped {
|
||||
info!(reason, "restore skipped a folder");
|
||||
}
|
||||
info!(restored, skipped = skipped.len(), "restore complete");
|
||||
Ok(message)
|
||||
Ok(RestoreOutcome { restored, skipped })
|
||||
}
|
||||
}
|
||||
|
||||
/// What a restore did: how many folders it attached, and why it left out the
|
||||
/// others.
|
||||
pub(crate) struct RestoreOutcome {
|
||||
pub(crate) restored: usize,
|
||||
pub(crate) skipped: Vec<String>,
|
||||
}
|
||||
|
||||
impl RestoreOutcome {
|
||||
/// The activity entry's headline.
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
match (self.restored, self.skipped.len()) {
|
||||
(n, 0) => format!("restored {n} folder(s)"),
|
||||
(0, k) => format!("restored no folders; skipped {k}"),
|
||||
(n, k) => format!("restored {n} folder(s); skipped {k}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the restore did all it was asked. A skipped folder is a
|
||||
/// failure the user has to see: the reply went out as "restoring folders"
|
||||
/// before any work, and a restore that skipped every folder used to be
|
||||
/// logged as a success, so nothing anywhere said that nothing would
|
||||
/// download (`docs/BUGS.md` B269).
|
||||
pub(crate) fn complete(&self) -> bool {
|
||||
self.skipped.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +574,7 @@ mod tests {
|
||||
#[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(");
|
||||
let load = function_source(source, "fn load_profiles(");
|
||||
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||
assert!(
|
||||
load.find("PROFILE_DIR_NAME").unwrap()
|
||||
@@ -491,6 +583,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Several computers may share a device, so each saves to a file named
|
||||
/// after its own machine id and never to the shared `profile.json`
|
||||
/// (B252).
|
||||
#[test]
|
||||
fn each_machine_saves_its_own_profile() {
|
||||
let save = function_source(include_str!("profile.rs"), "fn save_profile(");
|
||||
assert!(save.contains("profile_file_name(&machine_id)"));
|
||||
assert!(!save.contains("PROFILE_FILE_NAME"));
|
||||
}
|
||||
|
||||
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||
#[test]
|
||||
|
||||
+206
-28
@@ -1070,37 +1070,121 @@ impl Core {
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Result<Vec<u8>, Errno> {
|
||||
if offset >= len || size == 0 {
|
||||
return Ok(Vec::new());
|
||||
let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
|
||||
self.read_range(uid, base_mtime, base_size, s, n, true)
|
||||
.map(Some)
|
||||
})?;
|
||||
Ok(read.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// [`Core::serve_open_read`] from bytes already on this machine only:
|
||||
/// `Ok(None)` the moment a range of the base would have to be downloaded.
|
||||
///
|
||||
/// The dispatch loop answers a read on a write handle with this, and hands
|
||||
/// it to a worker only when it says `None`. Fetching the base there held
|
||||
/// every request of the mount for as long as the download took, up to the
|
||||
/// read deadline (`docs/BUGS.md` B227).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn serve_open_read_local(
|
||||
&self,
|
||||
file: &Arc<File>,
|
||||
len: u64,
|
||||
uid: &NodeUid,
|
||||
base_mtime: i64,
|
||||
base_size: u64,
|
||||
written: &Intervals,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Result<Option<Vec<u8>>, Errno> {
|
||||
assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
|
||||
Ok(self.cached_range(uid, base_mtime, base_size, s, n))
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes `[offset, offset + size)` of file `ino` as `read` would serve
|
||||
/// them, but only from this machine: an open write handle, a queued write,
|
||||
/// or the caches. `Ok(None)` the moment any of it would have to be
|
||||
/// downloaded, and for a size still provisional (B100), which only the
|
||||
/// network settles. Short or empty past the end of the file.
|
||||
///
|
||||
/// `copy_file_range` answers with this and leaves anything else to the
|
||||
/// kernel's read/write copy.
|
||||
pub(super) fn read_local(
|
||||
&self,
|
||||
ino: u64,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
) -> Result<Option<Vec<u8>>, Errno> {
|
||||
let handle = self.state().active_writes.get(&ino).map(|h| {
|
||||
(
|
||||
h.file.clone(),
|
||||
h.len,
|
||||
h.uid.clone(),
|
||||
h.base_mtime,
|
||||
h.base_size,
|
||||
h.written.clone(),
|
||||
)
|
||||
});
|
||||
if let Some((file, len, uid, base_mtime, base_size, written)) = handle {
|
||||
return self.serve_open_read_local(
|
||||
&file, len, &uid, base_mtime, base_size, &written, offset, size,
|
||||
);
|
||||
}
|
||||
let end = offset.saturating_add(size).min(len);
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
for (s, e, authored) in written.segments(offset, end) {
|
||||
if authored {
|
||||
let mut buf = vec![0u8; (e - s) as usize];
|
||||
file.read_exact_at(&mut buf, s).map_err(|err| {
|
||||
warn!(%uid, error = %err, "scratch read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
out.extend_from_slice(&buf);
|
||||
} else {
|
||||
let bend = e.min(base_size);
|
||||
if s < bend {
|
||||
out.extend_from_slice(&self.read_range(
|
||||
uid,
|
||||
base_mtime,
|
||||
base_size,
|
||||
s,
|
||||
bend - s,
|
||||
true,
|
||||
)?);
|
||||
}
|
||||
// Anything past the base is a hole: zero-fill.
|
||||
let zeros = e.saturating_sub(s.max(base_size));
|
||||
out.resize(out.len() + zeros as usize, 0);
|
||||
let (uid, mtime, fsize, provisional) = {
|
||||
let st = self.state();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) if e.node.is_file() => (
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
matches!(
|
||||
&e.node.kind,
|
||||
NodeKind::File {
|
||||
claimed_size: None,
|
||||
..
|
||||
}
|
||||
),
|
||||
),
|
||||
Some(_) => return Err(Errno::EISDIR),
|
||||
None => return Err(Errno::ENOENT),
|
||||
}
|
||||
};
|
||||
let uid = match is_local_uid(&uid) {
|
||||
true => self.landed_stand_in_uid(&uid).unwrap_or(uid),
|
||||
false => uid,
|
||||
};
|
||||
if let Some(pending) = self.pending.lock().get(&uid).cloned() {
|
||||
let m = &pending.meta;
|
||||
let file = File::open(&pending.path).map_err(|e| {
|
||||
error!(%uid, path = %pending.path.display(), error = %e, "open staged blob failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let mut written = Intervals::default();
|
||||
for &(s, e) in &m.authored {
|
||||
written.add(s, e);
|
||||
}
|
||||
return self.serve_open_read_local(
|
||||
&Arc::new(file),
|
||||
m.len,
|
||||
&uid,
|
||||
m.base_mtime,
|
||||
m.base_size,
|
||||
&written,
|
||||
offset,
|
||||
size,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
// Created here and never written: an empty file.
|
||||
if is_local_uid(&uid) {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
if provisional {
|
||||
return Ok(None);
|
||||
}
|
||||
if offset >= fsize || size == 0 {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
Ok(self.cached_range(&uid, mtime, fsize, offset, size.min(fsize - offset)))
|
||||
}
|
||||
|
||||
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||
@@ -1242,6 +1326,49 @@ impl Core {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes `[offset, offset + size)` of a file open for writing: authored
|
||||
/// ranges from its scratch `file`, the rest from its base through `base`,
|
||||
/// and zeros past the base. `Ok(None)` when `base` says it cannot answer.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn assemble_open_read(
|
||||
file: &File,
|
||||
len: u64,
|
||||
uid: &NodeUid,
|
||||
base_size: u64,
|
||||
written: &Intervals,
|
||||
offset: u64,
|
||||
size: u64,
|
||||
mut base: impl FnMut(u64, u64) -> Result<Option<Vec<u8>>, Errno>,
|
||||
) -> Result<Option<Vec<u8>>, Errno> {
|
||||
if offset >= len || size == 0 {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let end = offset.saturating_add(size).min(len);
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
for (s, e, authored) in written.segments(offset, end) {
|
||||
if authored {
|
||||
let mut buf = vec![0u8; (e - s) as usize];
|
||||
file.read_exact_at(&mut buf, s).map_err(|err| {
|
||||
warn!(%uid, error = %err, "scratch read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
out.extend_from_slice(&buf);
|
||||
} else {
|
||||
let bend = e.min(base_size);
|
||||
if s < bend {
|
||||
match base(s, bend - s)? {
|
||||
Some(bytes) => out.extend_from_slice(&bytes),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
// Anything past the base is a hole: zero-fill.
|
||||
let zeros = e.saturating_sub(s.max(base_size));
|
||||
out.resize(out.len() + zeros as usize, 0);
|
||||
}
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_ring_tests {
|
||||
use super::*;
|
||||
@@ -1394,3 +1521,54 @@ mod prefetch_tests {
|
||||
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod open_read_tests {
|
||||
use super::*;
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||
|
||||
fn scratch(bytes: &[u8]) -> (File, PathBuf) {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-open-read-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let mut file = File::options()
|
||||
.create_new(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap();
|
||||
file.write_all(bytes).unwrap();
|
||||
(file, path)
|
||||
}
|
||||
|
||||
/// The dispatch loop answers from what is on this machine and says so
|
||||
/// when the base would have to be downloaded, for a worker to do it
|
||||
/// (`docs/BUGS.md` B227).
|
||||
#[test]
|
||||
fn a_read_needing_the_base_says_so_instead_of_fetching() {
|
||||
let uid = NodeUid::new(VolumeId::from("v"), LinkId::from("f"));
|
||||
let (file, path) = scratch(b"ABCDEFGH");
|
||||
let mut written = Intervals::default();
|
||||
written.add(0, 4);
|
||||
|
||||
let authored = assemble_open_read(&file, 8, &uid, 8, &written, 0, 4, |_, _| {
|
||||
panic!("an authored range never asks the base")
|
||||
});
|
||||
assert_eq!(authored.unwrap().as_deref(), Some(&b"ABCD"[..]));
|
||||
|
||||
let missing = assemble_open_read(&file, 8, &uid, 8, &written, 2, 6, |_, _| Ok(None));
|
||||
assert_eq!(missing.unwrap(), None);
|
||||
|
||||
let base = assemble_open_read(&file, 10, &uid, 8, &written, 2, 8, |s, n| {
|
||||
assert_eq!((s, n), (4, 4));
|
||||
Ok(Some(b"efgh".to_vec()))
|
||||
});
|
||||
assert_eq!(base.unwrap().as_deref(), Some(&b"CDefgh\0\0"[..]));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,7 @@ impl Core {
|
||||
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||
.len();
|
||||
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||
let thumbnails = self.upload_thumbnails(&path, &candidate.name);
|
||||
|
||||
let guard =
|
||||
self.transfers
|
||||
@@ -206,7 +207,7 @@ impl Core {
|
||||
media_type,
|
||||
reader,
|
||||
size as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
metadata,
|
||||
false,
|
||||
));
|
||||
@@ -291,7 +292,14 @@ impl Core {
|
||||
let mut state = REDATE.lock();
|
||||
state.running = false;
|
||||
state.cancel = None;
|
||||
state.last = outcome.as_ref().ok().cloned();
|
||||
state.last = Some(match &outcome {
|
||||
Ok(summary) => summary.clone(),
|
||||
Err(e) => RedateSummary {
|
||||
error: Some(e.to_string()),
|
||||
dry_run,
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
match &outcome {
|
||||
Ok(summary) => info!(
|
||||
candidates = summary.candidates,
|
||||
|
||||
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::state::State;
|
||||
use super::sync::mirror_subtree_unsynced;
|
||||
use super::{Core, is_local_uid, parse_uid};
|
||||
|
||||
@@ -130,6 +131,24 @@ fn rename_noreplace(from: &Path, to: &Path) -> std::io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether inode `ino` is `root` or lies under it in the tree. Bounded, so a
|
||||
/// parent cycle in corrupt data cannot hang the caller.
|
||||
pub(crate) fn ino_is_under(st: &State, mut ino: u64, root: u64) -> bool {
|
||||
for _ in 0..MAX_TREE_DEPTH {
|
||||
if ino == root {
|
||||
return true;
|
||||
}
|
||||
match st.entries.get(&ino) {
|
||||
Some(entry) if entry.parent != ino => ino = entry.parent,
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// How deep [`ino_is_under`] walks before it gives up.
|
||||
const MAX_TREE_DEPTH: usize = 1024;
|
||||
|
||||
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||
let root = root.to_string();
|
||||
@@ -336,7 +355,7 @@ impl Core {
|
||||
core.require_uid_writable(authority)
|
||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
||||
}
|
||||
if self.has_pending_work_under(&uid)? {
|
||||
if self.has_pending_work_under(ino, &uid)? {
|
||||
return Err(CoreError::conflict(
|
||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||
));
|
||||
@@ -410,10 +429,11 @@ impl Core {
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
||||
/// file under it is open for writing. Moving such a node away would strand
|
||||
/// the pending change against a path that no longer leads to it.
|
||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
||||
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||
/// under it, or a file under it is open for writing. Moving such a node away
|
||||
/// would strand the pending change against a path that no longer leads to
|
||||
/// it.
|
||||
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||
let ops = self
|
||||
.db
|
||||
.pending_ops()
|
||||
@@ -427,12 +447,19 @@ impl Core {
|
||||
}) {
|
||||
return Ok(true);
|
||||
}
|
||||
// Collected first so no State lock is held across the nodes-table reads.
|
||||
let mut writing = Vec::new();
|
||||
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||
// table. A file opened just after its create landed holds the
|
||||
// placeholder uid until the tree catches up, and the nodes table no
|
||||
// longer has that uid, so the move went ahead under an open write.
|
||||
let mut writing = false;
|
||||
self.for_each_state(|st| {
|
||||
writing.extend(st.active_writes.values().map(|w| w.uid.to_string()));
|
||||
writing = st
|
||||
.active_writes
|
||||
.keys()
|
||||
.chain(&st.releasing)
|
||||
.any(|&open| ino_is_under(st, open, ino));
|
||||
});
|
||||
Ok(writing.iter().any(|w| under(self, uid, w)))
|
||||
Ok(writing)
|
||||
}
|
||||
|
||||
/// Bring the source location level with a move Drive has made.
|
||||
|
||||
+106
-13
@@ -9,7 +9,8 @@
|
||||
use std::path::Path;
|
||||
|
||||
use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
BookmarkInfo, DirEntry, InvitationInfo, LinkChange, PublicLinkInfo, ShareEntry, ShareEntryKind,
|
||||
SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
@@ -66,6 +67,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
size,
|
||||
modified: n.modification_time,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
@@ -366,6 +368,89 @@ impl Core {
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Change the public link `id` on the node at `rel`, keeping its URL.
|
||||
pub(crate) fn update_public_link(
|
||||
&self,
|
||||
rel: &Path,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// [`Core::update_public_link`] for a node addressed through any daemon
|
||||
/// location.
|
||||
pub(crate) fn update_public_link_by_uid(
|
||||
&self,
|
||||
uid: &str,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let uid = self.resolve_anywhere(uid)?;
|
||||
self.update_public_link_for_uid(&uid, id, role, password, expires)
|
||||
}
|
||||
|
||||
/// The SDK sets all three options on every update, so each `Keep` is
|
||||
/// filled in from the link as it stands.
|
||||
fn update_public_link_for_uid(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
id: &str,
|
||||
role: Option<&str>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> CoreResult<PublicLinkInfo> {
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(uid))
|
||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||
.filter(|l| l.public_link_id == id)
|
||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||
let role = match role {
|
||||
Some(role) => role_from_str(role)?,
|
||||
None => link.role,
|
||||
};
|
||||
let unchanged = role == link.role
|
||||
&& matches!(password, LinkChange::Keep)
|
||||
&& matches!(expires, LinkChange::Keep);
|
||||
if unchanged {
|
||||
return Ok(public_link_info(link));
|
||||
}
|
||||
let password = match password {
|
||||
LinkChange::Keep if link.has_custom_password => {
|
||||
// Sending `None` here would quietly remove the password.
|
||||
Some(link.custom_password.clone().ok_or_else(|| {
|
||||
CoreError::conflict(
|
||||
"the link's password could not be read; set a new one or remove it",
|
||||
)
|
||||
})?)
|
||||
}
|
||||
LinkChange::Keep | LinkChange::Remove => None,
|
||||
LinkChange::Set(password) if password.is_empty() => {
|
||||
return Err(CoreError::invalid("the new password is empty"));
|
||||
}
|
||||
LinkChange::Set(password) => Some(password),
|
||||
};
|
||||
let expires = match expires {
|
||||
LinkChange::Keep => link.expiration_time,
|
||||
LinkChange::Remove => None,
|
||||
LinkChange::Set(at) => Some(at),
|
||||
};
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(
|
||||
self.client
|
||||
.update_public_link(uid, role, password.as_deref(), expires),
|
||||
)
|
||||
.map_err(|e| CoreError::from_api(&e, "update public link"))?;
|
||||
Ok(public_link_info(link))
|
||||
}
|
||||
|
||||
/// Remove the public link `id` from the node at `rel`.
|
||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||
let (_ino, uid) = self.resolve(rel)?;
|
||||
@@ -660,6 +745,12 @@ impl Core {
|
||||
/// the shared uids; the per-node detail is then gathered best-effort — a single
|
||||
/// node racing with an unshare drops from the list rather than failing the whole
|
||||
/// request.
|
||||
///
|
||||
/// A share with no member, no invitation and no link is left out: it is
|
||||
/// shared with no one. Stop Sharing leaves exactly that behind, since the SDK
|
||||
/// cannot delete a share, and Drive keeps listing it until its backend gets
|
||||
/// round to removing it, minutes later. A share whose details could not all
|
||||
/// be read stays listed, so a failed lookup never hides a live share.
|
||||
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||
let uids = self
|
||||
.rt
|
||||
@@ -678,24 +769,26 @@ impl Core {
|
||||
let members = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_members(&uid))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
.map(|m| m.len());
|
||||
let proton_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_share_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
.map(|i| i.len());
|
||||
let external_invites = self
|
||||
.rt
|
||||
.block_on(self.client.list_external_invitations(&uid))
|
||||
.map(|i| i.len())
|
||||
.unwrap_or(0);
|
||||
let link = self
|
||||
.rt
|
||||
.block_on(self.client.get_public_link(&uid))
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(public_link_info);
|
||||
.map(|i| i.len());
|
||||
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||
(&members, &proton_invites, &external_invites, &link)
|
||||
{
|
||||
debug!(%uid, "shared by me: skipping an emptied share");
|
||||
continue;
|
||||
}
|
||||
let members = members.unwrap_or(0);
|
||||
let proton_invites = proton_invites.unwrap_or(0);
|
||||
let external_invites = external_invites.unwrap_or(0);
|
||||
let link = link.ok().flatten().map(public_link_info);
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
|
||||
+1044
-23
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,9 @@
|
||||
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
||||
//! `InsufficientQuota`.
|
||||
//!
|
||||
//! It also holds the account's computers, each with a root folder outside My
|
||||
//! files, so a simulated daemon can register its own and sync a local folder.
|
||||
//!
|
||||
//! A test can also hold the next read of one node on its way to Drive
|
||||
//! ([`FakeClient::hold_next_read`]), to make a change while the daemon waits
|
||||
//! for the answer, or on its way back ([`FakeClient::hold_answer_to_next_read`]),
|
||||
@@ -54,9 +57,12 @@ use parking_lot::Mutex;
|
||||
use proton_drive_rs::proton_sdk::account::Quota;
|
||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::proton_sdk::ids::{
|
||||
DeviceUid, DriveEventId, LinkId, NodeUid, ShareId, VolumeId,
|
||||
};
|
||||
use proton_drive_rs::{
|
||||
DriveEvent, DriveEventScopeId, Node, NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
||||
AvailableNameTarget, Device as Computer, DeviceType, DriveEvent, DriveEventScopeId, Node,
|
||||
NodeKind, NodeMoveItem, RevisionState, Thumbnail,
|
||||
};
|
||||
use sha1::{Digest as _, Sha1};
|
||||
|
||||
@@ -111,6 +117,9 @@ struct Server {
|
||||
block_pattern: Vec<u64>,
|
||||
/// The account's storage limit in bytes.
|
||||
max_space: i64,
|
||||
/// The account's computers, oldest first, as `(name, type, root folder)`.
|
||||
/// [`Computer`] is not `Clone`, so each answer builds them afresh.
|
||||
devices: Vec<(String, DeviceType, NodeUid)>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -162,6 +171,53 @@ impl Server {
|
||||
false
|
||||
}
|
||||
|
||||
/// The `n`th computer of the account, as Drive lists it.
|
||||
fn computer(&self, n: usize) -> Computer {
|
||||
let (name, device_type, root) = &self.devices[n];
|
||||
Computer {
|
||||
uid: DeviceUid::new(format!("device{}", n + 1)),
|
||||
device_type: *device_type,
|
||||
name: Ok(name.clone()),
|
||||
root_folder_uid: root.clone(),
|
||||
creation_time: self.current(root).map_or(0, |remote| remote.created),
|
||||
last_sync_time: None,
|
||||
share_id: ShareId::new(format!("device-share{}", n + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `uid` and, for a folder, everything live below it under `parent` as
|
||||
/// `name`, the way Drive's copy endpoint does. Returns the copy's uid.
|
||||
fn copy_subtree(&mut self, uid: &NodeUid, parent: &NodeUid, name: String) -> NodeUid {
|
||||
let source = self
|
||||
.current(uid)
|
||||
.cloned()
|
||||
.expect("the caller checked the source");
|
||||
let copy = self.mint_uid();
|
||||
let now = now_secs();
|
||||
self.put(
|
||||
©,
|
||||
Some(Remote {
|
||||
parent: Some(parent.clone()),
|
||||
name,
|
||||
created: now,
|
||||
..source
|
||||
}),
|
||||
);
|
||||
let children: Vec<(NodeUid, String)> = self
|
||||
.nodes
|
||||
.keys()
|
||||
.filter_map(|child| {
|
||||
let remote = self.current(child)?;
|
||||
(remote.parent.as_ref() == Some(uid) && !remote.trashed)
|
||||
.then(|| (child.clone(), remote.name.clone()))
|
||||
})
|
||||
.collect();
|
||||
for (child, child_name) in children {
|
||||
self.copy_subtree(&child, ©, child_name);
|
||||
}
|
||||
copy
|
||||
}
|
||||
|
||||
fn mint_uid(&mut self) -> NodeUid {
|
||||
self.next_link += 1;
|
||||
uid(&format!("L{}", self.next_link))
|
||||
@@ -305,6 +361,7 @@ impl FakeDrive {
|
||||
events: Vec::new(),
|
||||
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
||||
max_space: 1 << 40,
|
||||
devices: Vec::new(),
|
||||
};
|
||||
let now = now_secs();
|
||||
server.nodes.insert(
|
||||
@@ -338,6 +395,8 @@ impl FakeDrive {
|
||||
hold: Mutex::new(None),
|
||||
hold_answer: Mutex::new(None),
|
||||
hold_trash: Mutex::new(None),
|
||||
hold_download: Mutex::new(None),
|
||||
hold_any_landing: AtomicBool::new(false),
|
||||
hold_landing: Mutex::new(None),
|
||||
drop_after_create: AtomicBool::new(false),
|
||||
lose_create_reply: AtomicBool::new(false),
|
||||
@@ -616,8 +675,13 @@ struct ClientInner {
|
||||
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The download [`FakeClient::hold_next_download`] is waiting for.
|
||||
hold_download: Mutex<Option<Arc<HoldState>>>,
|
||||
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
||||
/// Set by [`FakeClient::hold_next_landing_of_any`]: hold whichever node
|
||||
/// lands next.
|
||||
hold_any_landing: AtomicBool,
|
||||
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||
drop_after_create: AtomicBool,
|
||||
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||
@@ -755,6 +819,18 @@ impl FakeClient {
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// Hold the next download of `uid`'s content once Drive has answered it,
|
||||
/// before the bytes reach the daemon, until the answer is dropped.
|
||||
pub(crate) fn hold_next_download(&self, uid: &NodeUid) -> Held {
|
||||
let state = Arc::new(HoldState {
|
||||
uid: uid.clone(),
|
||||
reached: AtomicBool::new(false),
|
||||
released: AtomicBool::new(false),
|
||||
});
|
||||
*self.0.hold_download.lock() = Some(state.clone());
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// Hold the drain once the next upload to `uid` has landed and its blob
|
||||
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||
/// the answer is dropped.
|
||||
@@ -768,6 +844,14 @@ impl FakeClient {
|
||||
Held(state)
|
||||
}
|
||||
|
||||
/// [`FakeClient::hold_next_landing`] for whichever node lands next, such
|
||||
/// as a new file, whose uid Drive has not minted yet.
|
||||
pub(crate) fn hold_next_landing_of_any(&self) -> Held {
|
||||
let held = self.hold_next_landing(&uid("*"));
|
||||
self.0.hold_any_landing.store(true, Ordering::SeqCst);
|
||||
held
|
||||
}
|
||||
|
||||
/// Make the next file create on Drive, then hold its answer until the
|
||||
/// answer is dropped, as a slow link does.
|
||||
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
||||
@@ -1396,6 +1480,8 @@ impl DriveApi for FakeClient {
|
||||
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
||||
})
|
||||
.await?;
|
||||
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
||||
.await;
|
||||
output.write_all(&content).map_err(io_error)
|
||||
}
|
||||
|
||||
@@ -1437,6 +1523,61 @@ impl DriveApi for FakeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn copy_node(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
new_parent: &NodeUid,
|
||||
name: Option<&str>,
|
||||
) -> Result<NodeUid> {
|
||||
self.request(true, |server| {
|
||||
if !server.is_folder(new_parent) {
|
||||
return Err(api(ResponseCode::DoesNotExist, "parent folder not found"));
|
||||
}
|
||||
let source = server
|
||||
.current(uid)
|
||||
.filter(|remote| !remote.trashed)
|
||||
.cloned()
|
||||
.ok_or_else(|| api(ResponseCode::DoesNotExist, "node not found"))?;
|
||||
if server.is_below(new_parent, uid) {
|
||||
return Err(api(
|
||||
ResponseCode::InvalidRequirements,
|
||||
"cannot copy a folder into itself",
|
||||
));
|
||||
}
|
||||
let name = name.map_or(source.name.clone(), str::to_owned);
|
||||
if server.live_child_named(new_parent, &name).is_some() {
|
||||
return Err(api(
|
||||
ResponseCode::AlreadyExists,
|
||||
"a file or folder with that name already exists",
|
||||
));
|
||||
}
|
||||
Ok(server.copy_subtree(uid, new_parent, name))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_available_name(
|
||||
&self,
|
||||
parent_uid: &NodeUid,
|
||||
name: &str,
|
||||
target: AvailableNameTarget,
|
||||
) -> Result<String> {
|
||||
self.request(false, |server| {
|
||||
let (stem, extension) = match (target, name.rsplit_once('.')) {
|
||||
(AvailableNameTarget::File, Some((stem, ext))) if !stem.is_empty() => {
|
||||
(stem, format!(".{ext}"))
|
||||
}
|
||||
_ => (name, String::new()),
|
||||
};
|
||||
let free = std::iter::once(name.to_owned())
|
||||
.chain((1..).map(|n| format!("{stem} ({n}){extension}")))
|
||||
.find(|candidate| server.live_child_named(parent_uid, candidate).is_none())
|
||||
.expect("an unbounded sequence has a free name");
|
||||
Ok(free)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn move_nodes_streaming<'a>(
|
||||
&'a self,
|
||||
items: Vec<NodeMoveItem>,
|
||||
@@ -1484,8 +1625,12 @@ impl DriveApi for FakeClient {
|
||||
fn landing(&self, uid: &NodeUid) {
|
||||
let state = {
|
||||
let mut hold = self.0.hold_landing.lock();
|
||||
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
|
||||
match hold.as_ref() {
|
||||
Some(state) if state.uid == *uid => hold.take(),
|
||||
Some(state) if any || state.uid == *uid => {
|
||||
self.0.hold_any_landing.store(false, Ordering::SeqCst);
|
||||
hold.take()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
@@ -1506,6 +1651,42 @@ impl DriveApi for FakeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enumerate_devices(&self) -> Result<Vec<Computer>> {
|
||||
self.request(false, |server| {
|
||||
Ok((0..server.devices.len())
|
||||
.map(|n| server.computer(n))
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_device(&self, name: &str, device_type: DeviceType) -> Result<Computer> {
|
||||
self.request(true, |server| {
|
||||
// A device root has no parent: like on Drive, it is not in My files.
|
||||
// It is listed at once, as the My files root is: the listing lag is
|
||||
// about children appearing, and the daemon lists the new root next.
|
||||
let root = server.mint_uid();
|
||||
let now = now_secs();
|
||||
server.nodes.insert(
|
||||
root.clone(),
|
||||
vec![(
|
||||
Instant::now() - HISTORY,
|
||||
Some(Remote {
|
||||
parent: None,
|
||||
name: name.to_owned(),
|
||||
trashed: false,
|
||||
created: now,
|
||||
modified: now,
|
||||
file: None,
|
||||
}),
|
||||
)],
|
||||
);
|
||||
server.devices.push((name.to_owned(), device_type, root));
|
||||
Ok(server.computer(server.devices.len() - 1))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
self.request(false, |server| {
|
||||
Ok(server
|
||||
|
||||
@@ -212,6 +212,31 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
|
||||
lid.unsigned_abs() + crate::ROOT_INO
|
||||
}
|
||||
|
||||
/// The inodes of the stored nodes among `nodes`, from their local ids, for
|
||||
/// [`State::intern_batch_with_inos`]. Called before the state lock is taken;
|
||||
/// a node that already has a row keeps its id. Empty when the database fails,
|
||||
/// which leaves the allocation to the lock holder, as before.
|
||||
pub(crate) fn inos_for(db: &Db, nodes: &[Node]) -> HashMap<NodeUid, u64> {
|
||||
let stored: Vec<&Node> = nodes
|
||||
.iter()
|
||||
.filter(|node| !crate::r#virtual::is_virtual_uid(&node.uid))
|
||||
.collect();
|
||||
if stored.is_empty() {
|
||||
return HashMap::new();
|
||||
}
|
||||
match db.lids_for(&stored) {
|
||||
Ok(lids) => stored
|
||||
.iter()
|
||||
.zip(lids)
|
||||
.map(|(node, lid)| (node.uid.clone(), lid_ino(lid)))
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
warn!(%error, "no local ids before the listing; allocating them under the lock");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||
/// `None` for the root and a fallback inode.
|
||||
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||
@@ -241,6 +266,11 @@ pub(crate) struct State {
|
||||
pub(crate) next_ino: u64,
|
||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
||||
/// Inodes whose last write handle `release` has taken out of
|
||||
/// [`State::active_writes`] and is still queueing. `close(2)` returns
|
||||
/// before that, and in between the edit is in neither the handles nor the
|
||||
/// queue, so a check for changes not on Drive yet looks here too.
|
||||
pub(crate) releasing: HashSet<u64>,
|
||||
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
|
||||
/// have no entry here.
|
||||
pub(crate) handles: HashMap<u64, u64>,
|
||||
@@ -403,6 +433,7 @@ impl State {
|
||||
roots: HashSet::new(),
|
||||
next_ino: FALLBACK_INO,
|
||||
active_writes: HashMap::new(),
|
||||
releasing: HashSet::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
dir_snapshots: HashMap::new(),
|
||||
@@ -1074,20 +1105,34 @@ impl State {
|
||||
///
|
||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||
/// listing, rather than one autocommit — and one fsync — per child.
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, mut nodes: Vec<Node>) -> Vec<u64> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||
self.intern_batch_with_inos(parent, nodes, HashMap::new())
|
||||
}
|
||||
|
||||
/// [`State::intern_batch`], with inodes for new entries that the caller
|
||||
/// allocated before it took the lock, from [`inos_for`]. Allocating them
|
||||
/// is a write transaction, and under the lock every inline handler of
|
||||
/// every mount waited for it (`docs/BUGS.md` B221). A node missing from
|
||||
/// `inos` still gets one here.
|
||||
pub(crate) fn intern_batch_with_inos(
|
||||
&mut self,
|
||||
parent: u64,
|
||||
mut nodes: Vec<Node>,
|
||||
mut inos: HashMap<NodeUid, u64>,
|
||||
) -> Vec<u64> {
|
||||
for node in &mut nodes {
|
||||
self.keep_revision_mtime(node);
|
||||
}
|
||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||
let fresh: Vec<&Node> = nodes
|
||||
.iter()
|
||||
.filter(|node| !self.by_uid.contains_key(&node.uid))
|
||||
.filter(|node| !self.by_uid.contains_key(&node.uid) && !inos.contains_key(&node.uid))
|
||||
.collect();
|
||||
let mut inos = if fresh.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.new_inos(&fresh)
|
||||
};
|
||||
if !fresh.is_empty() {
|
||||
let allocated = self.new_inos(&fresh);
|
||||
inos.extend(allocated);
|
||||
}
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| {
|
||||
@@ -1098,6 +1143,13 @@ impl State {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record `uid`'s listing as complete once the lock is released, after the
|
||||
/// rows of its children: written the other way round, a crash between the
|
||||
/// two left a folder marked complete with no children to show.
|
||||
pub(crate) fn record_listed(&mut self, uid: NodeUid) {
|
||||
self.outbox.push(DbWrite::SetListed(uid, true));
|
||||
}
|
||||
|
||||
/// Check if a directory inode has any child nodes in memory or in the database.
|
||||
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
||||
// A resident listing is authoritative even when empty. Falling through
|
||||
@@ -1467,6 +1519,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A listing's inodes are allocated before the lock is taken, and are the
|
||||
/// ones a node would get under it (`docs/BUGS.md` B221).
|
||||
#[test]
|
||||
fn inodes_allocated_before_the_lock_are_the_ones_the_node_keeps() {
|
||||
let (mut st, _dir) = state();
|
||||
let db = st.db.clone();
|
||||
let listing = vec![
|
||||
node("a", "root", "a.txt", false),
|
||||
node("b", "root", "b.txt", false),
|
||||
];
|
||||
let early = inos_for(&db, &listing);
|
||||
assert_eq!(early.len(), 2);
|
||||
let inos = st.intern_batch_with_inos(0, listing, early.clone());
|
||||
assert_eq!(inos, [early[&uid("a")], early[&uid("b")]]);
|
||||
st.flush_outbox();
|
||||
|
||||
let mut restarted = State::new(db, HashMap::new());
|
||||
assert_eq!(
|
||||
restarted.intern(0, node("b", "root", "b.txt", false)),
|
||||
early[&uid("b")]
|
||||
);
|
||||
}
|
||||
|
||||
/// The listing is recorded complete with its children's rows, when the
|
||||
/// lock goes, never ahead of them.
|
||||
#[test]
|
||||
fn a_listing_is_recorded_complete_after_its_children() {
|
||||
let (mut st, _dir) = state();
|
||||
let root = st.intern(0, node("root", "none", "root", true));
|
||||
st.flush_outbox();
|
||||
st.intern_batch(root, vec![node("a", "root", "a.txt", false)]);
|
||||
st.record_listed(uid("root"));
|
||||
assert!(st.db.children_if_listed(&uid("root")).unwrap().is_none());
|
||||
st.flush_outbox();
|
||||
let kids = st.db.children_if_listed(&uid("root")).unwrap().unwrap();
|
||||
assert_eq!(kids.len(), 1);
|
||||
}
|
||||
|
||||
/// Audit A5. Moving a file out of a folder leaves that folder's DB row
|
||||
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
||||
|
||||
@@ -36,6 +36,11 @@ const TICK: Duration = Duration::from_secs(15);
|
||||
/// would train everyone to ignore the real one.
|
||||
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How long a sync pass may go without reporting progress before it is called
|
||||
/// out. Much longer than [`STALL_AFTER`]: one large file in a pass reports
|
||||
/// nothing until its whole transfer is done.
|
||||
const SYNC_STALL_AFTER: Duration = Duration::from_secs(900);
|
||||
|
||||
/// How often resident size goes into the log.
|
||||
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||
|
||||
@@ -137,6 +142,26 @@ fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64,
|
||||
"the transfer lane has not completed a job since the last check"
|
||||
);
|
||||
}
|
||||
// The engine is one thread for every synced folder, so a dead or stuck one
|
||||
// stops all of them while the worker pool looks idle (B259).
|
||||
if let Some(sync) = &report.sync {
|
||||
if !sync.running {
|
||||
stalled = true;
|
||||
warn!(
|
||||
"the sync engine is not running; synced folders do not sync until the daemon restarts"
|
||||
);
|
||||
} else if let Some(folder) = sync.pass_folder
|
||||
&& sync.pass_idle_secs >= SYNC_STALL_AFTER.as_secs()
|
||||
{
|
||||
stalled = true;
|
||||
warn!(
|
||||
folder,
|
||||
age_secs = sync.pass_age_secs,
|
||||
idle_secs = sync.pass_idle_secs,
|
||||
"a sync pass has not reported progress for a long time"
|
||||
);
|
||||
}
|
||||
}
|
||||
stalled
|
||||
}
|
||||
|
||||
@@ -169,7 +194,7 @@ fn probe(control_socket: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||
use pdfs_core::control::{Diagnostics, InflightRequest, SyncEngineState, WorkerState};
|
||||
|
||||
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||
WorkerState {
|
||||
@@ -226,4 +251,45 @@ mod tests {
|
||||
assert!(!report_stalls(&report, (0, 30)));
|
||||
assert!(report_stalls(&report, (0, 40)));
|
||||
}
|
||||
|
||||
fn with_sync(sync: SyncEngineState) -> Diagnostics {
|
||||
Diagnostics {
|
||||
sync: Some(sync),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A sync engine that has ended stops every synced folder while the
|
||||
/// workers look idle, which is what issue #53 looked like (B259).
|
||||
#[test]
|
||||
fn a_stopped_sync_engine_is_a_stall() {
|
||||
assert!(report_stalls(
|
||||
&with_sync(SyncEngineState::default()),
|
||||
(0, 0)
|
||||
));
|
||||
}
|
||||
|
||||
/// A pass that keeps reporting progress is working, however long it runs;
|
||||
/// one that has gone quiet past the threshold is not.
|
||||
#[test]
|
||||
fn only_a_quiet_sync_pass_is_a_stall() {
|
||||
let busy = SyncEngineState {
|
||||
running: true,
|
||||
pass_folder: Some(3),
|
||||
pass_age_secs: SYNC_STALL_AFTER.as_secs() * 4,
|
||||
pass_idle_secs: 5,
|
||||
panics: 0,
|
||||
};
|
||||
assert!(!report_stalls(&with_sync(busy.clone()), (0, 0)));
|
||||
let quiet = SyncEngineState {
|
||||
pass_idle_secs: SYNC_STALL_AFTER.as_secs(),
|
||||
..busy
|
||||
};
|
||||
assert!(report_stalls(&with_sync(quiet), (0, 0)));
|
||||
let idle = SyncEngineState {
|
||||
running: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!report_stalls(&with_sync(idle), (0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -37,10 +37,12 @@ const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||
/// 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.
|
||||
/// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
|
||||
/// `name`, returning the original file name it was a copy of. The label names
|
||||
/// the computer that made the copy (B251); copies made before it have the bare
|
||||
/// stamp. `None` when `name` is not a conflict copy. Inverse of the
|
||||
/// `conflict_name` / `conflict_path_with_suffix` formats, so the two halves of
|
||||
/// the product agree on what a conflict is.
|
||||
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||
const MARKER: &str = " (sync-conflict ";
|
||||
let start = name.find(MARKER)?;
|
||||
@@ -48,6 +50,20 @@ pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||
let close_rel = name[after..].find(')')?;
|
||||
let close = after + close_rel;
|
||||
let inner = &name[after..close];
|
||||
// A label is one token of the characters `conflict_label_of` keeps.
|
||||
let inner = match inner.rsplit_once(' ') {
|
||||
Some((label, token)) => {
|
||||
let label_ok = !label.is_empty()
|
||||
&& label
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
|
||||
if !label_ok {
|
||||
return None;
|
||||
}
|
||||
token
|
||||
}
|
||||
None => inner,
|
||||
};
|
||||
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||
let mut parts = inner.splitn(2, '-');
|
||||
@@ -486,6 +502,24 @@ mod tests {
|
||||
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
// Named after the computer that made it (B251).
|
||||
assert_eq!(
|
||||
conflict_base_name("notes (sync-conflict laptop 1700).txt").as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_base_name("notes (sync-conflict my-pc.local 1700-2).txt").as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_base_name(&crate::conflict_name("notes.txt", 1700)).as_deref(),
|
||||
Some("notes.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_base_name("x (sync-conflict two words 1).txt"),
|
||||
None
|
||||
);
|
||||
assert_eq!(conflict_base_name("x (sync-conflict 1).txt"), None);
|
||||
// Not a conflict copy.
|
||||
assert_eq!(conflict_base_name("report.xml"), None);
|
||||
// Looks close but the token is not a stamp.
|
||||
|
||||
+1385
-244
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,11 @@
|
||||
//! Filesystem watcher, polling, and debounce control plane.
|
||||
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use pdfs_core::control::SyncEngineState;
|
||||
use tracing::error;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// How long the watcher must go quiet before a burst counts as finished.
|
||||
@@ -22,7 +28,10 @@ const POLL_INTERVAL: Duration = Duration::from_secs(120);
|
||||
/// 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;
|
||||
pub(crate) const SYNC_CONCURRENCY: usize = 8;
|
||||
/// How long after a failed watch the engine tries the watches again. Each try
|
||||
/// walks the folder's whole tree, so not on every burst.
|
||||
const REWATCH_RETRY: Duration = Duration::from_secs(600);
|
||||
|
||||
/// A message to the sync engine's control thread.
|
||||
pub(crate) enum SyncMsg {
|
||||
@@ -53,30 +62,159 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the engine is alive and which pass it is on, for
|
||||
/// [`Core::diagnostics`] and the supervisor.
|
||||
///
|
||||
/// A panic in a pass used to end the engine thread. Every later add, sync-now,
|
||||
/// file event and poll then went into a channel nobody read, the folder stayed
|
||||
/// `syncing`, and nothing said so until a restart (`docs/BUGS.md` B259).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct EngineHealth {
|
||||
running: AtomicBool,
|
||||
pass: Mutex<Option<PassClock>>,
|
||||
panics: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PassClock {
|
||||
folder: i64,
|
||||
started: Instant,
|
||||
moved: Instant,
|
||||
}
|
||||
|
||||
impl EngineHealth {
|
||||
/// Note that the running pass reported progress.
|
||||
pub(crate) fn moved(&self) {
|
||||
if let Some(pass) = self.pass.lock().as_mut() {
|
||||
pass.moved = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's state now. `try_lock`, like the rest of the diagnostics:
|
||||
/// a report must not wait on the thing it reports about.
|
||||
pub(crate) fn report(&self) -> SyncEngineState {
|
||||
let pass = self.pass.try_lock().and_then(|pass| *pass);
|
||||
SyncEngineState {
|
||||
running: self.running.load(Ordering::Relaxed),
|
||||
pass_folder: pass.map(|p| p.folder),
|
||||
pass_age_secs: pass.map_or(0, |p| p.started.elapsed().as_secs()),
|
||||
pass_idle_secs: pass.map_or(0, |p| p.moved.elapsed().as_secs()),
|
||||
panics: self.panics.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `pass` over `folder` as the current pass. Returns `false` when it
|
||||
/// panicked: the panic ends that pass, not the engine.
|
||||
pub(super) fn contain(&self, folder: i64, pass: impl FnOnce()) -> bool {
|
||||
let now = Instant::now();
|
||||
*self.pass.lock() = Some(PassClock {
|
||||
folder,
|
||||
started: now,
|
||||
moved: now,
|
||||
});
|
||||
let finished = std::panic::catch_unwind(AssertUnwindSafe(pass)).is_ok();
|
||||
*self.pass.lock() = None;
|
||||
if !finished {
|
||||
self.panics.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
finished
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the engine running for as long as it lives.
|
||||
pub(super) struct Running<'a>(&'a EngineHealth);
|
||||
|
||||
impl<'a> Running<'a> {
|
||||
pub(super) fn start(health: &'a EngineHealth) -> Self {
|
||||
health.running.store(true, Ordering::Relaxed);
|
||||
Self(health)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.running.store(false, Ordering::Relaxed);
|
||||
if std::thread::panicking() {
|
||||
error!("sync engine stopped by a panic; nothing syncs until the daemon restarts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One folder's pass, contained: a panic in it sets the folder to `error` and
|
||||
/// leaves the engine running for the next one.
|
||||
fn run_pass(core: &Core, folder: &StoredSyncFolder) {
|
||||
if core
|
||||
.sync_engine
|
||||
.contain(folder.id, || core.reconcile_folder(folder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
core.progress_end(folder.id);
|
||||
let _ = core
|
||||
.db
|
||||
.sync_folder_set_state(folder.id, "error", folder.last_sync);
|
||||
error!(
|
||||
id = folder.id,
|
||||
path = %folder.local_path,
|
||||
"sync: pass panicked; the folder is set to error and tried again on the next poll"
|
||||
);
|
||||
core.log_activity(
|
||||
ActivityKind::Sync,
|
||||
format!("sync failed for {}", base_name(&folder.local_path)),
|
||||
"an internal error stopped the pass; it is tried again on the next poll",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// The pass a `Reconcile(id)` asked for. A folder removed since, or one the
|
||||
/// database cannot read, is said so rather than dropped in silence.
|
||||
fn run_pass_by_id(core: &Core, id: i64) {
|
||||
match core.db.sync_folder_get(id) {
|
||||
Ok(Some(folder)) => run_pass(core, &folder),
|
||||
Ok(None) => debug!(id, "sync: folder removed before its pass; skipping"),
|
||||
Err(e) => warn!(id, error = ?e, "sync: cannot read folder; skipping its pass"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine control loop. Single-threaded so reconcile passes never overlap.
|
||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
let health = core.sync_engine.clone();
|
||||
let _running = Running::start(&health);
|
||||
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||
// path can be resolved back to the folder it belongs to.
|
||||
let watched: Mutex<Vec<(PathBuf, i64)>> = Mutex::new(Vec::new());
|
||||
let watched: Arc<Mutex<Vec<(PathBuf, i64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let tx_events = core.sync_tx.clone();
|
||||
let 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,
|
||||
let watched_events = watched.clone();
|
||||
let handler = move |res: notify::Result<notify::Event>| {
|
||||
let event = match res {
|
||||
Ok(event) => event,
|
||||
// Running out of inotify watches for a new subfolder, say: that
|
||||
// subfolder's changes are then found by the poll only (B262).
|
||||
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);
|
||||
warn!(error = %e, "sync: filesystem watcher error");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Ignore pure metadata/access noise; content changes are what matter.
|
||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||
return;
|
||||
}
|
||||
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||
let _ = tx_events.send(msg);
|
||||
};
|
||||
// The local walk skips symlinks, so following them here would only watch
|
||||
// trees outside the folder, and run passes over it for changes made there.
|
||||
let config = notify::Config::default().with_follow_symlinks(false);
|
||||
let mut watcher = match notify::RecommendedWatcher::new(handler, config) {
|
||||
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.
|
||||
{
|
||||
@@ -94,8 +232,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
.ok();
|
||||
}
|
||||
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
reconcile_all(&core);
|
||||
let mut unwatched = rewatch(&core, &mut watcher, &watched);
|
||||
let mut last_rewatch = Instant::now();
|
||||
run_burst(&core, &HashSet::new(), true);
|
||||
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
@@ -108,21 +247,16 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A folder whose watch failed is tried again now and then, not only
|
||||
// when the folder set changes.
|
||||
if unwatched && last_rewatch.elapsed() >= REWATCH_RETRY {
|
||||
do_rewatch = true;
|
||||
}
|
||||
if do_rewatch {
|
||||
rewatch(&core, &mut watcher, &watched);
|
||||
}
|
||||
if all {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
}
|
||||
unwatched = rewatch(&core, &mut watcher, &watched);
|
||||
last_rewatch = Instant::now();
|
||||
}
|
||||
run_burst(&core, &ids, all);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +273,7 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
reconcile_all(core);
|
||||
run_burst(core, &HashSet::new(), true);
|
||||
while let Ok(msg) = rx.recv() {
|
||||
let mut ids: HashSet<i64> = HashSet::new();
|
||||
let mut all = false;
|
||||
@@ -150,21 +284,59 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
info!("sync engine stopping");
|
||||
return;
|
||||
}
|
||||
if all {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
run_burst(core, &ids, all);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the passes one burst asked for: the folders named by id, then, when the
|
||||
/// burst also holds a `ReconcileAll`, every other folder.
|
||||
///
|
||||
/// The named folders go first. A poll's `ReconcileAll` in the same burst used
|
||||
/// to take their place, and a folder just added, which has the highest id,
|
||||
/// waited behind a full pass over every other folder (`docs/BUGS.md` B260).
|
||||
///
|
||||
/// Skipped while sync is paused, and cut short when a pause lands between
|
||||
/// passes: the resume sends a fresh [`SyncMsg::ReconcileAll`], which walks
|
||||
/// everything the watcher reported meanwhile.
|
||||
fn run_burst(core: &Core, ids: &HashSet<i64>, all: bool) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = if all {
|
||||
match core.db.sync_folder_list() {
|
||||
Ok(folders) => folders,
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let listed: Vec<i64> = folders.iter().map(|f| f.id).collect();
|
||||
for id in burst_order(ids, &listed) {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
// A folder named by id is read again: it may have been added or
|
||||
// changed after the list was read.
|
||||
match folders.iter().find(|f| f.id == id) {
|
||||
Some(folder) if !ids.contains(&id) => run_pass(core, folder),
|
||||
_ => run_pass_by_id(core, id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The order of a burst's passes: the folders named by id, lowest first, then
|
||||
/// the rest of `listed` in its own order.
|
||||
pub(super) fn burst_order(ids: &HashSet<i64>, listed: &[i64]) -> Vec<i64> {
|
||||
let mut order: Vec<i64> = ids.iter().copied().collect();
|
||||
order.sort_unstable();
|
||||
order.extend(listed.iter().copied().filter(|id| !ids.contains(id)));
|
||||
order
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -236,33 +408,37 @@ pub(super) fn classify(
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||
/// watcher reported meanwhile.
|
||||
fn reconcile_all(core: &Core) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
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);
|
||||
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||
/// out of date), falls back to every folder.
|
||||
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||
let mut ids = paths.iter().map(|path| {
|
||||
watched
|
||||
.iter()
|
||||
.filter(|(root, _)| path.starts_with(root))
|
||||
// The deepest root, should one mirror folder sit inside another.
|
||||
.max_by_key(|(root, _)| root.components().count())
|
||||
.map(|(_, id)| *id)
|
||||
});
|
||||
match ids.next() {
|
||||
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||
_ => SyncMsg::ReconcileAll,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
||||
/// watch newly-added folders, drop removed ones.
|
||||
/// watch newly-added folders, drop removed ones. Returns whether a folder is
|
||||
/// left unwatched because its watch failed.
|
||||
///
|
||||
/// The watch calls run on a copy, outside the lock: the watcher's event
|
||||
/// callback takes the same lock, and a backend that hands a watch request to
|
||||
/// its event thread and waits for the answer would deadlock against it.
|
||||
fn rewatch(
|
||||
core: &Core,
|
||||
watcher: &mut notify::RecommendedWatcher,
|
||||
watched: &Mutex<Vec<(PathBuf, i64)>>,
|
||||
) {
|
||||
) -> bool {
|
||||
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
|
||||
Ok(f) => f
|
||||
.into_iter()
|
||||
@@ -271,29 +447,60 @@ fn rewatch(
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!(error = ?e, "sync: cannot list folders to watch");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let mut have = watched.lock();
|
||||
let mut have = watched.lock().clone();
|
||||
// Drop watches no longer wanted.
|
||||
have.retain(|(path, _)| {
|
||||
if want.iter().any(|(p, _)| p == path) {
|
||||
true
|
||||
} else {
|
||||
let _ = watcher.unwatch(path);
|
||||
false
|
||||
}
|
||||
});
|
||||
for path in keep_wanted(&mut have, &want) {
|
||||
let _ = watcher.unwatch(&path);
|
||||
}
|
||||
// Add newly wanted watches.
|
||||
let mut failed = false;
|
||||
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"),
|
||||
Err(e) => {
|
||||
// A recursive watch that fails partway keeps the watches it had
|
||||
// added. Their events would match no folder and run a pass over
|
||||
// every one, and they hold watches the next folder needs (B262).
|
||||
let _ = watcher.unwatch(path);
|
||||
failed = true;
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"sync: watch failed; the poll finds this folder's changes until a retry \
|
||||
works. A low fs.inotify.max_user_watches is the usual cause"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
*watched.lock() = have;
|
||||
failed
|
||||
}
|
||||
|
||||
/// Keep the watches in `have` that `want` still asks for, and return the paths
|
||||
/// of the others, to unwatch.
|
||||
///
|
||||
/// A path is matched alone, but takes the id `want` gives it: a folder removed
|
||||
/// and added again in one burst keeps its watch, and its events go to the new
|
||||
/// row rather than to the removed one (`docs/BUGS.md` B272).
|
||||
pub(super) fn keep_wanted(have: &mut Vec<(PathBuf, i64)>, want: &[(PathBuf, i64)]) -> Vec<PathBuf> {
|
||||
let mut dropped = Vec::new();
|
||||
have.retain_mut(|(path, id)| match want.iter().find(|(p, _)| p == path) {
|
||||
Some((_, wanted)) => {
|
||||
*id = *wanted;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
dropped.push(path.clone());
|
||||
false
|
||||
}
|
||||
});
|
||||
dropped
|
||||
}
|
||||
|
||||
// ---- reconcile ------------------------------------------------------------
|
||||
@@ -83,6 +83,46 @@ pub(super) struct RemoteItem {
|
||||
pub(super) is_dir: bool,
|
||||
pub(super) mtime: i64,
|
||||
pub(super) size: i64,
|
||||
/// The active revision's id. The light walk carries it for every file, so
|
||||
/// it is the signal that tells another client's new revision from a link
|
||||
/// merely re-stamped.
|
||||
pub(super) revision: Option<String>,
|
||||
/// The active revision's content SHA-1, read only for files the walk
|
||||
/// enumerated in full (no baseline, or a moved mtime). `None` is never
|
||||
/// proof of anything.
|
||||
pub(super) sha1: Option<String>,
|
||||
}
|
||||
|
||||
/// What a pass saw of a remote file: the signature its baseline records once
|
||||
/// the file is synced, and what an upload checks Drive still holds before it
|
||||
/// supersedes it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(super) struct RemoteSeen {
|
||||
pub(super) mtime: i64,
|
||||
pub(super) size: i64,
|
||||
pub(super) revision: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteSeen {
|
||||
/// Whether `node` is still the revision this was taken from. The revision
|
||||
/// id decides it when both sides have one; otherwise the modification time
|
||||
/// and size, which is all a baseline row from before schema 44 holds.
|
||||
pub(super) fn still_current(&self, node: &Node) -> bool {
|
||||
match (&self.revision, crate::node_revision_id(node)) {
|
||||
(Some(seen), Some(now)) => *seen == now,
|
||||
_ => (self.mtime, self.size) == remote_file_sig(node),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&RemoteItem> for RemoteSeen {
|
||||
fn from(item: &RemoteItem) -> Self {
|
||||
Self {
|
||||
mtime: item.mtime,
|
||||
size: item.size,
|
||||
revision: item.revision.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a reconcile pass: what it moved, how many paths were kept as
|
||||
@@ -98,6 +138,11 @@ pub(super) struct Outcome {
|
||||
pub(super) errors: usize,
|
||||
/// Files skipped because another process held them open for writing.
|
||||
pub(super) deferred: usize,
|
||||
/// Paths the local scan could not read, left out of the pass. Not part of
|
||||
/// [`is_empty`](Self::is_empty) or the summary: an unreadable folder stays
|
||||
/// unreadable from one poll to the next, so the pass reports it once, when
|
||||
/// the set changes, rather than on every poll.
|
||||
pub(super) unscanned: Vec<String>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
@@ -108,7 +153,7 @@ impl Outcome {
|
||||
Applied::Uploaded => self.uploaded += 1,
|
||||
Applied::Downloaded => self.downloaded += 1,
|
||||
Applied::Conflict => self.conflicts += 1,
|
||||
Applied::Vanished => {}
|
||||
Applied::Vanished | Applied::Adopted => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,10 +197,16 @@ pub(super) enum Pending {
|
||||
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.
|
||||
/// Upload a changed local file as a new revision of `uid`, if Drive still
|
||||
/// holds the revision `seen` describes (B249).
|
||||
UploadRevision {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
seen: RemoteSeen,
|
||||
},
|
||||
/// Download remote `uid` to the local path, stamping `seen.mtime`.
|
||||
/// `seen.size` is the remote's reported size, used as the transfer's
|
||||
/// expected total.
|
||||
///
|
||||
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||
/// was there. A download can run for minutes, so the apply step revalidates
|
||||
@@ -165,16 +216,26 @@ pub(super) enum Pending {
|
||||
Download {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
seen: RemoteSeen,
|
||||
local: Option<LocalSig>,
|
||||
},
|
||||
/// First sync of a path present on both sides at the same size, with a
|
||||
/// digest on Drive: hash the local file, and record the baseline if it holds
|
||||
/// the same bytes. A different file of the same size goes the way of
|
||||
/// [`Pending::Conflict`]: two machines syncing one folder must not read as
|
||||
/// in step while they hold different content.
|
||||
Adopt {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
seen: RemoteSeen,
|
||||
sha1: String,
|
||||
local: LocalSig,
|
||||
},
|
||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||
Conflict {
|
||||
rel: String,
|
||||
uid: NodeUid,
|
||||
mtime: i64,
|
||||
size: i64,
|
||||
seen: RemoteSeen,
|
||||
},
|
||||
/// 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.
|
||||
@@ -189,6 +250,7 @@ impl Pending {
|
||||
| Pending::UploadNew { rel, .. }
|
||||
| Pending::UploadRevision { rel, .. }
|
||||
| Pending::Download { rel, .. }
|
||||
| Pending::Adopt { rel, .. }
|
||||
| Pending::Conflict { rel, .. }
|
||||
| Pending::PushConflict { rel, .. } => rel,
|
||||
}
|
||||
@@ -201,7 +263,9 @@ impl Pending {
|
||||
Pending::UploadNew { .. }
|
||||
| Pending::UploadRevision { .. }
|
||||
| Pending::PushConflict { .. } => ActivityKind::Upload,
|
||||
Pending::Download { .. } | Pending::Conflict { .. } => ActivityKind::Download,
|
||||
Pending::Download { .. } | Pending::Adopt { .. } | Pending::Conflict { .. } => {
|
||||
ActivityKind::Download
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +276,7 @@ impl Pending {
|
||||
Pending::UploadNew { .. } => "new file",
|
||||
Pending::UploadRevision { .. } => "new version",
|
||||
Pending::Download { .. } => "from Drive",
|
||||
Pending::Adopt { .. } => "already the same on Drive",
|
||||
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
||||
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
||||
}
|
||||
|
||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The paths a local scan left out because it could not read them: a folder
|
||||
/// it has no permission for, an entry that vanished between the listing and
|
||||
/// its `stat`, a name that is not UTF-8.
|
||||
///
|
||||
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||
/// covers from classification, exactly as it drops ignored paths, so a
|
||||
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||
/// stopping every other file in the tree from syncing (B196).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Unscanned {
|
||||
covered: HashSet<String>,
|
||||
/// The subset worth telling the user about, with why: an entry that only
|
||||
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||
unreadable: Vec<String>,
|
||||
}
|
||||
|
||||
impl Unscanned {
|
||||
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||
pub(super) fn vanished(&mut self, rel: String) {
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Leave out `rel`, which cannot be read for `reason`.
|
||||
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||
self.unreadable.push(format!("{rel} ({reason})"));
|
||||
self.covered.insert(rel);
|
||||
}
|
||||
|
||||
/// Whether `rel` is a left-out path or lies under one.
|
||||
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||
if self.covered.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut at = rel;
|
||||
loop {
|
||||
if self.covered.contains(at) {
|
||||
return true;
|
||||
}
|
||||
match at.rfind('/') {
|
||||
Some(i) => at = &at[..i],
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||
pub(super) fn report(&self) -> Vec<String> {
|
||||
let mut lines = self.unreadable.clone();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||
/// checks and the set a push pass reads deletions from, since a path outside
|
||||
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||
pub(super) fn tracked_baseline<'a, B>(
|
||||
baseline: &'a HashMap<String, B>,
|
||||
rules: &IgnoreRules,
|
||||
unscanned: &Unscanned,
|
||||
) -> HashMap<String, &'a B> {
|
||||
let mut tracked = filter_baseline(baseline, rules);
|
||||
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||
tracked
|
||||
}
|
||||
|
||||
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||
/// children with it.
|
||||
///
|
||||
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||
/// it raw trashed every synced path that later became ignored, because the walk
|
||||
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||
/// deleted the local copy too (B197).
|
||||
pub(super) fn local_deletions<B, L>(
|
||||
tracked: &HashMap<String, &B>,
|
||||
local: &HashMap<String, L>,
|
||||
) -> Vec<String> {
|
||||
let mut missing: Vec<String> = tracked
|
||||
.keys()
|
||||
.filter(|rel| !local.contains_key(*rel))
|
||||
.cloned()
|
||||
.collect();
|
||||
missing.sort_by_key(|p| p.matches('/').count());
|
||||
missing
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||
///
|
||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||
@@ -102,21 +192,180 @@ pub(super) fn guard_local_wipe<B, L>(
|
||||
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.",
|
||||
them on Drive. Check that the folder is mounted and readable; to download \
|
||||
them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse to run a pass whose remote side has vanished in its entirety: the
|
||||
/// remote twin of [`guard_local_wipe`].
|
||||
///
|
||||
/// An empty or truncated remote walk reads to the planner as "Drive deleted
|
||||
/// everything", which a pass answers by deleting the local copies. When not
|
||||
/// one synced path is left on Drive, a failed listing is far likelier than a
|
||||
/// deliberate wipe of the whole folder, so the pass stops instead.
|
||||
pub(super) fn guard_remote_wipe<B, R>(
|
||||
baseline: &HashMap<String, B>,
|
||||
remote: &HashMap<String, R>,
|
||||
) -> Result<(), String> {
|
||||
if !baseline.is_empty() && baseline.keys().all(|rel| !remote.contains_key(rel)) {
|
||||
return Err(format!(
|
||||
"every one of the {} synced paths is missing on Proton Drive; refusing to \
|
||||
delete them here. If they were deleted on purpose, remove the folder from sync; \
|
||||
to upload them again, sync the folder from scratch.",
|
||||
baseline.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a remote walk saw but could not read: a node the SDK left out because
|
||||
/// it would not decrypt, or a file whose size could not be read.
|
||||
///
|
||||
/// A path missing from the walk is read as deleted on Drive, so a pass must
|
||||
/// not classify one it only failed to read. Since a node that failed to decrypt
|
||||
/// has no name, all a walk can say is that a folder's listing came back short;
|
||||
/// every path under that folder that the walk did not return is then left out,
|
||||
/// as [`Unscanned`] leaves out what the local scan could not read.
|
||||
#[derive(Default)]
|
||||
pub(super) struct RemoteGaps {
|
||||
/// Folders whose listing came back with fewer nodes than it named.
|
||||
short: HashSet<String>,
|
||||
/// Paths seen, but whose entry could not be read.
|
||||
unread: HashSet<String>,
|
||||
/// Folders whose listing failed outright, after the SDK's retries. Nothing
|
||||
/// under one is known, so all of it is left out of the pass (B271).
|
||||
unlisted: HashSet<String>,
|
||||
}
|
||||
|
||||
impl RemoteGaps {
|
||||
/// The listing of `folder` named nodes that did not come back.
|
||||
pub(super) fn short_listing(&mut self, folder: &str) {
|
||||
self.short.insert(folder.to_string());
|
||||
}
|
||||
|
||||
/// `rel` is on Drive, but the walk could not read what it needs of it.
|
||||
pub(super) fn unread(&mut self, rel: String) {
|
||||
self.unread.insert(rel);
|
||||
}
|
||||
|
||||
/// The listing of `folder` failed, so nothing under it was walked.
|
||||
pub(super) fn unlisted(&mut self, folder: &str) {
|
||||
self.unlisted.insert(folder.to_string());
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.short.is_empty() && self.unread.is_empty() && self.unlisted.is_empty()
|
||||
}
|
||||
|
||||
/// Whether `rel` may be missing from `remote` only because the walk could
|
||||
/// not read it. A path the walk returned is never hidden.
|
||||
pub(super) fn hides<R>(
|
||||
&self,
|
||||
rel: &str,
|
||||
remote: &HashMap<String, R>,
|
||||
remote_dirs: &HashMap<String, NodeUid>,
|
||||
) -> bool {
|
||||
if self.is_empty() || remote.contains_key(rel) {
|
||||
return false;
|
||||
}
|
||||
if self.unread.contains(rel) {
|
||||
return true;
|
||||
}
|
||||
// The deepest folder the walk did list decides: under a short listing,
|
||||
// anything not returned may be one of the nodes it could not read.
|
||||
let mut at = parent_rel(rel);
|
||||
loop {
|
||||
if remote_dirs.contains_key(at) {
|
||||
return self.short.contains(at) || self.unlisted.contains(at);
|
||||
}
|
||||
if at.is_empty() {
|
||||
return false;
|
||||
}
|
||||
at = parent_rel(at);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders to name to the user, sorted.
|
||||
pub(super) fn report(&self) -> Vec<String> {
|
||||
let mut lines: Vec<String> = self
|
||||
.short
|
||||
.iter()
|
||||
.map(|rel| {
|
||||
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||
format!("{shown} (items on Proton Drive that cannot be read)")
|
||||
})
|
||||
.chain(
|
||||
self.unread
|
||||
.iter()
|
||||
.map(|rel| format!("{rel} (cannot be read on Proton Drive)")),
|
||||
)
|
||||
.chain(self.unlisted.iter().map(|rel| {
|
||||
format!("{rel} (could not be listed on Proton Drive; tried again next pass)")
|
||||
}))
|
||||
.collect();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the remote folder at `rel` must not be trashed although it was deleted
|
||||
/// here: something under it is new or changed on Drive since the last sync,
|
||||
/// so it is another device's work that this computer has never had.
|
||||
///
|
||||
/// Trashing the folder would take that work with it, and the next pass would
|
||||
/// then delete the copy this pass just downloaded. `None` when everything under
|
||||
/// it is exactly what the baseline recorded.
|
||||
pub(super) fn remote_subtree_changed(
|
||||
rel: &str,
|
||||
remote: &HashMap<String, RemoteItem>,
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
) -> Option<String> {
|
||||
let prefix = format!("{rel}/");
|
||||
let mut changed: Vec<&String> = remote
|
||||
.iter()
|
||||
.filter(|(path, _)| path.starts_with(&prefix))
|
||||
.filter(|(path, item)| match baseline.get(*path) {
|
||||
None => true,
|
||||
Some(_) if item.is_dir => false,
|
||||
Some(base) => {
|
||||
remote_sig(base) != Some((item.mtime, item.size))
|
||||
|| revision_moved(Some(base), item.revision.as_deref())
|
||||
}
|
||||
})
|
||||
.map(|(path, _)| path)
|
||||
.collect();
|
||||
changed.sort_unstable();
|
||||
changed
|
||||
.first()
|
||||
.map(|path| format!("{path} is new or changed on Proton Drive"))
|
||||
}
|
||||
|
||||
/// The size the baseline recorded for `rel`, if its remote signature's mtime
|
||||
/// still matches `mtime`.
|
||||
/// still matches `mtime` and its revision is still `revision`.
|
||||
///
|
||||
/// A row without a revision id, while Drive reports one, cannot vouch for the
|
||||
/// revision, so the size is read for real. Such a row comes from before
|
||||
/// schema 44, or from an upload Drive did not list yet when it was read back.
|
||||
/// Another computer's revision in the same second as that upload kept the
|
||||
/// modification time, and only the size could show it (B254). The pass then
|
||||
/// records the id on the row, so this costs one read per row.
|
||||
pub(super) fn unchanged_remote_size(
|
||||
baseline: &HashMap<String, StoredSyncEntry>,
|
||||
rel: &str,
|
||||
mtime: i64,
|
||||
revision: Option<&str>,
|
||||
) -> Option<i64> {
|
||||
match baseline.get(rel).and_then(remote_sig) {
|
||||
let base = baseline.get(rel)?;
|
||||
if revision_moved(Some(base), revision)
|
||||
|| (base.remote_revision_id.is_none() && revision.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match remote_sig(base) {
|
||||
Some((recorded, size)) if recorded == mtime => Some(size),
|
||||
_ => None,
|
||||
}
|
||||
@@ -131,6 +380,17 @@ pub(super) fn join_rel(prefix: &str, name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Drive's active revision `now` is not the one the baseline row was
|
||||
/// synced at. Only a revision id on both sides can say so; a row from before
|
||||
/// schema 44, or a node read without one, leaves it to the modification time
|
||||
/// and size.
|
||||
pub(super) fn revision_moved(base: Option<&StoredSyncEntry>, now: Option<&str>) -> bool {
|
||||
match (base.and_then(|b| b.remote_revision_id.as_deref()), now) {
|
||||
(Some(recorded), Some(now)) => recorded != now,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored remote signature of a baseline row, if it has one.
|
||||
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
|
||||
match (&e.remote_rev, &e.remote_hash) {
|
||||
@@ -161,7 +421,8 @@ 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`.
|
||||
/// The name for a conflict copy of `path`, e.g.
|
||||
/// `notes (sync-conflict laptop 1700000000).txt`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||
conflict_path_with_suffix(path, stamp, 0)
|
||||
@@ -170,16 +431,17 @@ pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||
/// 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 {
|
||||
conflict_path_as(path, crate::conflict_label(), stamp, suffix)
|
||||
}
|
||||
|
||||
/// [`conflict_path_with_suffix`] with the machine's label given.
|
||||
pub(super) fn conflict_path_as(path: &Path, label: &str, stamp: i64, suffix: u32) -> PathBuf {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||
let ext = path.extension().and_then(|s| s.to_str());
|
||||
let suffix = if suffix == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{suffix}")
|
||||
};
|
||||
let tag = crate::conflict_tag(label, stamp, suffix);
|
||||
let name = match ext {
|
||||
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
|
||||
Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
|
||||
None => format!("{stem} (sync-conflict {tag})"),
|
||||
};
|
||||
match path.parent() {
|
||||
Some(dir) => dir.join(name),
|
||||
@@ -200,11 +462,14 @@ pub(super) enum FilePlan {
|
||||
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,
|
||||
/// First sync of a file that exists on both sides at the same size, with a
|
||||
/// digest on Drive: record the baseline without transferring anything if
|
||||
/// the local file hashes the same, else keep a conflict copy. Without this,
|
||||
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
||||
/// pre-existing file, because a missing baseline makes both sides read as
|
||||
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
|
||||
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md). Size
|
||||
/// alone is not enough: a second machine joining the folder would read a
|
||||
/// different file of the same size as in step (B248).
|
||||
AdoptBaseline,
|
||||
}
|
||||
|
||||
@@ -223,8 +488,13 @@ pub(super) fn plan_file(
|
||||
// left the file the same length, so it was never uploaded.
|
||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||
});
|
||||
// Another client's new revision shows in its id even when the link's
|
||||
// modification time and the size come out the same.
|
||||
let remote_changed = remote.is_some_and(|item| {
|
||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||
baseline.is_none_or(|base| {
|
||||
remote_sig(base) != Some((item.mtime, item.size))
|
||||
|| revision_moved(Some(base), item.revision.as_deref())
|
||||
})
|
||||
});
|
||||
|
||||
match (local, remote) {
|
||||
@@ -234,10 +504,13 @@ pub(super) fn plan_file(
|
||||
(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,
|
||||
// this is the first sync and there was nothing to compare to: when the
|
||||
// sizes match and Drive has a digest to compare against, the apply
|
||||
// step hashes the local file before adopting it. Divergent sizes, or
|
||||
// no digest, stay a real conflict: a missing digest proves nothing.
|
||||
(true, true) if baseline.is_none() && l.size == r.size && r.sha1.is_some() => {
|
||||
FilePlan::AdoptBaseline
|
||||
}
|
||||
(true, true) => FilePlan::Conflict,
|
||||
},
|
||||
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||
@@ -269,6 +542,8 @@ mod file_plan_tests {
|
||||
is_dir: false,
|
||||
mtime,
|
||||
size,
|
||||
revision: None,
|
||||
sha1: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +561,7 @@ mod file_plan_tests {
|
||||
local_size,
|
||||
remote_rev: Some(remote_mtime.to_string()),
|
||||
remote_hash: Some(remote_size.to_string()),
|
||||
remote_revision_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,12 +683,28 @@ mod file_plan_tests {
|
||||
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.
|
||||
// conflict copy of every file in the folder. The apply step still
|
||||
// hashes the local file before it records anything.
|
||||
let local = local(10, 40);
|
||||
let remote = RemoteItem {
|
||||
sha1: Some("da39a3ee".into()),
|
||||
..remote(30, 40)
|
||||
};
|
||||
assert_eq!(
|
||||
plan_file(Some(&local), Some(&remote), None),
|
||||
FilePlan::AdoptBaseline
|
||||
);
|
||||
}
|
||||
|
||||
/// A file on Drive without a digest cannot be compared without a download,
|
||||
/// and the same size is not the same content (B248).
|
||||
#[test]
|
||||
fn first_sync_without_a_remote_digest_is_a_conflict() {
|
||||
let local = local(10, 40);
|
||||
let remote = remote(30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&local), Some(&remote), None),
|
||||
FilePlan::AdoptBaseline
|
||||
FilePlan::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,6 +720,73 @@ mod file_plan_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Another computer's new revision can leave the modification time and
|
||||
/// the size as they were. The revision id still tells it apart (B249).
|
||||
#[test]
|
||||
fn a_new_remote_revision_is_a_change_even_at_the_same_mtime_and_size() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
base.remote_revision_id = Some("r1".into());
|
||||
let unedited = local(10, 20);
|
||||
let same = RemoteItem {
|
||||
revision: Some("r1".into()),
|
||||
..remote(30, 40)
|
||||
};
|
||||
let newer = RemoteItem {
|
||||
revision: Some("r2".into()),
|
||||
..remote(30, 40)
|
||||
};
|
||||
assert_eq!(
|
||||
plan_file(Some(&unedited), Some(&same), Some(&base)),
|
||||
FilePlan::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
plan_file(Some(&unedited), Some(&newer), Some(&base)),
|
||||
FilePlan::Download
|
||||
);
|
||||
let edited = local(11, 20);
|
||||
assert_eq!(
|
||||
plan_file(Some(&edited), Some(&newer), Some(&base)),
|
||||
FilePlan::Conflict
|
||||
);
|
||||
|
||||
// A row from before schema 44 has no id to compare, and a node read
|
||||
// without one says nothing: both fall back to the signature.
|
||||
let old = baseline(10, 20, 30, 40);
|
||||
assert_eq!(
|
||||
plan_file(Some(&unedited), Some(&newer), Some(&old)),
|
||||
FilePlan::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
plan_file(Some(&unedited), Some(&remote(30, 40)), Some(&base)),
|
||||
FilePlan::Unchanged
|
||||
);
|
||||
}
|
||||
|
||||
/// The walk reuses the recorded size only while the revision is the one
|
||||
/// the baseline holds.
|
||||
#[test]
|
||||
fn a_moved_revision_makes_the_walk_read_the_size_again() {
|
||||
let mut base = baseline(10, 20, 30, 40);
|
||||
base.remote_revision_id = Some("r1".into());
|
||||
let rows = HashMap::from([("file".to_string(), base)]);
|
||||
assert_eq!(
|
||||
unchanged_remote_size(&rows, "file", 30, Some("r1")),
|
||||
Some(40)
|
||||
);
|
||||
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
|
||||
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r2")), None);
|
||||
assert_eq!(unchanged_remote_size(&rows, "file", 31, Some("r1")), None);
|
||||
}
|
||||
|
||||
/// A row without a revision id reuses its size only while Drive gives no
|
||||
/// id either (B254).
|
||||
#[test]
|
||||
fn a_row_without_a_revision_makes_the_walk_read_the_size_again() {
|
||||
let rows = HashMap::from([("file".to_string(), baseline(10, 20, 30, 40))]);
|
||||
assert_eq!(unchanged_remote_size(&rows, "file", 30, Some("r1")), None);
|
||||
assert_eq!(unchanged_remote_size(&rows, "file", 30, None), Some(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||
let local = local(10, 20);
|
||||
@@ -440,4 +799,122 @@ mod file_plan_tests {
|
||||
FilePlan::Download
|
||||
);
|
||||
}
|
||||
|
||||
fn uid(id: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("v"), LinkId::from(id))
|
||||
}
|
||||
|
||||
fn folder() -> RemoteItem {
|
||||
RemoteItem {
|
||||
is_dir: true,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
..remote(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty or failed remote listing must not read as "Drive deleted
|
||||
/// everything" and delete the local copies.
|
||||
#[test]
|
||||
fn the_remote_wipe_guard_blocks_only_a_total_disappearance() {
|
||||
let base = HashMap::from([("a".to_string(), ()), ("b".to_string(), ())]);
|
||||
let none: HashMap<String, RemoteItem> = HashMap::new();
|
||||
assert!(guard_remote_wipe(&base, &none).is_err());
|
||||
let one = HashMap::from([("b".to_string(), remote(1, 1))]);
|
||||
assert!(guard_remote_wipe(&base, &one).is_ok());
|
||||
let empty: HashMap<String, ()> = HashMap::new();
|
||||
assert!(guard_remote_wipe(&empty, &none).is_ok());
|
||||
}
|
||||
|
||||
/// A node that failed to decrypt has no name, so everything a short
|
||||
/// listing did not return is left out, and nothing else.
|
||||
#[test]
|
||||
fn a_short_remote_listing_hides_what_it_did_not_return() {
|
||||
let remote = HashMap::from([
|
||||
("docs".to_string(), folder()),
|
||||
("docs/a.txt".to_string(), remote(1, 1)),
|
||||
("pics".to_string(), folder()),
|
||||
]);
|
||||
let dirs = HashMap::from([
|
||||
(String::new(), uid("root")),
|
||||
("docs".to_string(), uid("docs")),
|
||||
("pics".to_string(), uid("pics")),
|
||||
]);
|
||||
let mut gaps = RemoteGaps::default();
|
||||
assert!(!gaps.hides("docs/b.txt", &remote, &dirs));
|
||||
gaps.short_listing("docs");
|
||||
// Returned paths stand; unreturned ones under the short folder hide,
|
||||
// and so does anything under a subfolder it failed to return.
|
||||
assert!(!gaps.hides("docs/a.txt", &remote, &dirs));
|
||||
assert!(gaps.hides("docs/b.txt", &remote, &dirs));
|
||||
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
|
||||
// A complete sibling listing still reads deletions as deletions.
|
||||
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
|
||||
assert!(!gaps.hides("top.txt", &remote, &dirs));
|
||||
gaps.short_listing("");
|
||||
assert!(gaps.hides("top.txt", &remote, &dirs));
|
||||
}
|
||||
|
||||
/// A folder whose listing failed was walked no further: nothing under it
|
||||
/// reads as deleted on Drive, and a sibling that listed still does
|
||||
/// (`docs/BUGS.md` B271).
|
||||
#[test]
|
||||
fn a_folder_that_failed_to_list_hides_everything_under_it() {
|
||||
let remote = HashMap::from([
|
||||
("docs".to_string(), folder()),
|
||||
("pics".to_string(), folder()),
|
||||
]);
|
||||
let dirs = HashMap::from([
|
||||
(String::new(), uid("root")),
|
||||
("docs".to_string(), uid("docs")),
|
||||
("pics".to_string(), uid("pics")),
|
||||
]);
|
||||
let mut gaps = RemoteGaps::default();
|
||||
gaps.unlisted("docs");
|
||||
assert!(gaps.hides("docs/a.txt", &remote, &dirs));
|
||||
assert!(gaps.hides("docs/sub/deep.txt", &remote, &dirs));
|
||||
assert!(!gaps.hides("docs", &remote, &dirs));
|
||||
assert!(!gaps.hides("pics/gone.jpg", &remote, &dirs));
|
||||
assert_eq!(
|
||||
gaps.report(),
|
||||
["docs (could not be listed on Proton Drive; tried again next pass)"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_size_could_not_be_read_is_hidden() {
|
||||
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||
let dirs = HashMap::from([(String::new(), uid("root"))]);
|
||||
let mut gaps = RemoteGaps::default();
|
||||
gaps.unread("a.txt".to_string());
|
||||
assert!(gaps.hides("a.txt", &remote, &dirs));
|
||||
assert!(!gaps.hides("b.txt", &remote, &dirs));
|
||||
assert_eq!(gaps.report(), ["a.txt (cannot be read on Proton Drive)"]);
|
||||
}
|
||||
|
||||
/// A folder deleted here holds another device's new work on Drive, so
|
||||
/// trashing it would lose that work.
|
||||
#[test]
|
||||
fn a_remote_folder_with_new_or_changed_files_is_not_trashed() {
|
||||
let mut base = HashMap::from([
|
||||
("x".to_string(), baseline(0, 0, 0, 0)),
|
||||
("x/old.txt".to_string(), baseline(1, 1, 5, 6)),
|
||||
]);
|
||||
let mut remote_tree = HashMap::from([
|
||||
("x".to_string(), folder()),
|
||||
("x/old.txt".to_string(), remote(5, 6)),
|
||||
("xy/new.txt".to_string(), remote(1, 1)),
|
||||
]);
|
||||
// Unchanged under x; a name-prefixed sibling does not count.
|
||||
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
|
||||
|
||||
remote_tree.insert("x/old.txt".to_string(), remote(9, 6));
|
||||
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
|
||||
|
||||
remote_tree.insert("x/old.txt".to_string(), remote(5, 6));
|
||||
remote_tree.insert("x/sub".to_string(), folder());
|
||||
assert!(remote_subtree_changed("x", &remote_tree, &base).is_some());
|
||||
base.insert("x/sub".to_string(), baseline(0, 0, 0, 0));
|
||||
assert_eq!(remote_subtree_changed("x", &remote_tree, &base), None);
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,19 @@ fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||
/// when the process exists.
|
||||
pub(crate) fn ready() {
|
||||
if notify("READY=1\n") {
|
||||
if notify("READY=1\nSTATUS=Mounted\n") {
|
||||
debug!("notified systemd: ready");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the one-line status `systemctl --user status` shows for the unit, such
|
||||
/// as why the mount keeps failing (`docs/BUGS.md` B263).
|
||||
pub fn status(text: &str) {
|
||||
// A newline would end the `STATUS=` line and start another variable.
|
||||
let line = text.replace('\n', " ");
|
||||
notify(&format!("STATUS={line}\n"));
|
||||
}
|
||||
|
||||
/// Tell the manager the daemon is still healthy.
|
||||
pub(crate) fn watchdog_ping() {
|
||||
let _ = notify("WATCHDOG=1\n");
|
||||
|
||||
@@ -25,7 +25,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||
@@ -36,6 +36,7 @@ use sha1::{Digest, Sha1};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::Core;
|
||||
use crate::photos::can_make_thumbnail;
|
||||
use crate::transfers::CountingReader;
|
||||
|
||||
/// The one import this daemon may be running, and how the last one ended.
|
||||
@@ -136,6 +137,30 @@ fn read_entry<R>(
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// Copy archive entry `entry` into the thumbnail staging folder and return
|
||||
/// its path. The caller removes it.
|
||||
fn stage_takeout_entry(
|
||||
&self,
|
||||
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||
entry: &str,
|
||||
) -> CoreResult<PathBuf> {
|
||||
static STAGED: AtomicU64 = AtomicU64::new(0);
|
||||
let path = self.cache.raw_thumbnail_staging_dir().join(format!(
|
||||
"takeout-{}-{}",
|
||||
std::process::id(),
|
||||
STAGED.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let copied = read_entry(archive, entry, |reader| {
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
std::io::copy(reader, &mut file)
|
||||
})?;
|
||||
if let Err(e) = copied {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(CoreError::internal(format!("copy {entry}: {e}")));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||
///
|
||||
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||
@@ -321,18 +346,65 @@ impl Core {
|
||||
capture_time: photo.capture_time,
|
||||
..Default::default()
|
||||
};
|
||||
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||
let counting = CountingReader::new(reader, &guard);
|
||||
self.rt.block_on(photos.upload_photo_from(
|
||||
&photo.name,
|
||||
&photo.media_type,
|
||||
counting,
|
||||
photo.size as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
))
|
||||
});
|
||||
// A thumbnail is made from a file, so a photo that can have one
|
||||
// is copied out of the archive first and uploaded from the copy.
|
||||
// Without one, Proton's apps show a blank tile until they make
|
||||
// their own.
|
||||
let staged = can_make_thumbnail(&photo.name, photo.size)
|
||||
.then(|| self.stage_takeout_entry(&mut archive, &photo.entry))
|
||||
.and_then(|staged| {
|
||||
staged
|
||||
.inspect_err(|e| {
|
||||
warn!(name = photo.name, error = %e,
|
||||
"copying a takeout photo out failed; uploading it without a thumbnail")
|
||||
})
|
||||
.ok()
|
||||
});
|
||||
let uploaded = match &staged {
|
||||
Some(path) => {
|
||||
let thumbnails = self.upload_thumbnails(path, &photo.name);
|
||||
// No sidecar and no date in the name: the photo's own
|
||||
// EXIF, before Drive files it under today.
|
||||
let metadata = PhotoUploadMetadata {
|
||||
capture_time: metadata
|
||||
.capture_time
|
||||
.or_else(|| pdfs_core::exiftime::capture_time_of(path)),
|
||||
..metadata
|
||||
};
|
||||
std::fs::File::open(path)
|
||||
.map_err(|e| {
|
||||
CoreError::internal(format!("open {}: {e}", path.display()))
|
||||
})
|
||||
.map(|file| {
|
||||
let counting =
|
||||
CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||
self.rt.block_on(photos.upload_photo_from(
|
||||
&photo.name,
|
||||
&photo.media_type,
|
||||
counting,
|
||||
photo.size as i64,
|
||||
thumbnails,
|
||||
metadata,
|
||||
false,
|
||||
))
|
||||
})
|
||||
}
|
||||
None => read_entry(&mut archive, &photo.entry, |reader| {
|
||||
let counting = CountingReader::new(reader, &guard);
|
||||
self.rt.block_on(photos.upload_photo_from(
|
||||
&photo.name,
|
||||
&photo.media_type,
|
||||
counting,
|
||||
photo.size as i64,
|
||||
Vec::new(),
|
||||
metadata,
|
||||
false,
|
||||
))
|
||||
}),
|
||||
};
|
||||
if let Some(path) = staged {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
drop(guard);
|
||||
job.step();
|
||||
|
||||
@@ -480,7 +552,15 @@ impl Core {
|
||||
let mut state = IMPORT.lock();
|
||||
state.running = false;
|
||||
state.cancel = None;
|
||||
state.last = outcome.as_ref().ok().cloned();
|
||||
// A failed run is reported too: without it, `--wait` read
|
||||
// "no import has run" and exited as if all was well.
|
||||
state.last = Some(match &outcome {
|
||||
Ok(summary) => summary.clone(),
|
||||
Err(e) => ImportSummary {
|
||||
error: Some(e.to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
}
|
||||
match outcome {
|
||||
Ok(summary) => {
|
||||
@@ -545,7 +625,7 @@ pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||
Ok(hex_lower(&hasher.finalize()))
|
||||
}
|
||||
|
||||
fn hex_lower(bytes: &[u8]) -> String {
|
||||
pub(crate) fn hex_lower(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
out.push_str(&format!("{byte:02x}"));
|
||||
|
||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
||||
detail: Mutex<String>,
|
||||
done: AtomicU64,
|
||||
total: AtomicU64,
|
||||
/// Housekeeping that moves nothing; see [`JobItem::background`].
|
||||
background: bool,
|
||||
}
|
||||
|
||||
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||
@@ -123,6 +125,11 @@ impl TransferRegistry {
|
||||
self.download_limit.set(download);
|
||||
}
|
||||
|
||||
/// The upload cap in bytes per second, `0` when there is none.
|
||||
pub fn upload_cap(&self) -> u64 {
|
||||
self.upload_limit.rate.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||
match direction {
|
||||
TransferDirection::Upload => &self.upload_limit,
|
||||
@@ -160,12 +167,25 @@ impl TransferRegistry {
|
||||
/// that carries its progress and deregisters the job when dropped. The job
|
||||
/// starts indeterminate; give it a `total` once one is known.
|
||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), false)
|
||||
}
|
||||
|
||||
/// [`TransferRegistry::begin_job`] for housekeeping the user did not ask
|
||||
/// for and that changes nothing in their files, such as an index rebuild or
|
||||
/// a cache check. It is reported, but front ends don't call the drive busy
|
||||
/// over it.
|
||||
pub fn begin_background_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
||||
self.register_job(title.into(), true)
|
||||
}
|
||||
|
||||
fn register_job(self: &Arc<Self>, title: String, background: bool) -> JobGuard {
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
let entry = Arc::new(JobEntry {
|
||||
title: title.into(),
|
||||
title,
|
||||
detail: Mutex::new(String::new()),
|
||||
done: AtomicU64::new(0),
|
||||
total: AtomicU64::new(0),
|
||||
background,
|
||||
});
|
||||
self.jobs.lock().insert(id, entry.clone());
|
||||
JobGuard {
|
||||
@@ -191,6 +211,7 @@ impl TransferRegistry {
|
||||
detail: e.detail.lock().clone(),
|
||||
done: e.done.load(Ordering::Relaxed),
|
||||
total: e.total.load(Ordering::Relaxed),
|
||||
background: e.background,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 441 B |
@@ -14,7 +14,6 @@
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
.sidebar-status image.warning { color: var(--warning-color); }
|
||||
.sidebar-status image.error { color: var(--error-color); }
|
||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||
/* The account row is the main menu's button: a row, not a pushbutton, so
|
||||
* the name keeps the text weight. */
|
||||
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
|
||||
/* A count of what waits on a page, at the end of its sidebar row */
|
||||
.sidebar-badge {
|
||||
min-width: 10px;
|
||||
@@ -48,6 +50,9 @@
|
||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||
.badge-issue { color: var(--error-color); }
|
||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||
/* The folder on screen, last in the breadcrumb trail: a flat button's side
|
||||
* padding, so the "›" before it is spaced like the ones between buttons. */
|
||||
.crumb-current { padding: 0 10px; }
|
||||
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||
|
||||
@@ -10,9 +10,14 @@
|
||||
//! `dead_code` allowances below are for — not code nobody uses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfs_core::control::{PhotoKind, SearchHit};
|
||||
|
||||
/// How long a `stat` of the mount may take before the file counts as not
|
||||
/// there. A frozen mount never answers, and every caller has a fallback.
|
||||
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DriveActivation {
|
||||
Folder,
|
||||
@@ -55,18 +60,32 @@ pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathB
|
||||
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)
|
||||
}
|
||||
|
||||
/// Whether anything is at `path`, asked on a thread of its own and given up
|
||||
/// on after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer".
|
||||
///
|
||||
/// For the GTK binaries, whose main thread must not `stat` the mount: a
|
||||
/// mount that hangs hung the whole window with it. The thread of a `stat`
|
||||
/// that never returns is left behind; there is no way to cancel one.
|
||||
#[allow(dead_code)] // GTK binaries only
|
||||
pub(crate) async fn present_within(path: PathBuf) -> Option<PathBuf> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(present(path));
|
||||
});
|
||||
match gtk4::glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
|
||||
Ok(Ok(target)) => target,
|
||||
Ok(Err(_)) => None,
|
||||
Err(_) => {
|
||||
tracing::warn!("the mount did not answer in time");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mount path for a hit, whether or not anything is there — for callers
|
||||
/// that have no fallback (a folder cannot be materialised) or already know the
|
||||
/// entry came from a mount listing.
|
||||
|
||||
+193
-47
@@ -10,6 +10,7 @@ pub(crate) mod pages;
|
||||
pub(crate) mod reload;
|
||||
#[path = "../theme.rs"]
|
||||
pub(crate) mod theme;
|
||||
pub(crate) mod update;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use compat::*;
|
||||
@@ -33,7 +34,9 @@ use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||
use update::*;
|
||||
use widgets::compat::*;
|
||||
use widgets::crumb::*;
|
||||
use widgets::details::*;
|
||||
use widgets::file_list::*;
|
||||
use widgets::menu::*;
|
||||
@@ -68,15 +71,15 @@ use gtk4::glib::BoxedAnyObject;
|
||||
|
||||
use pdfs_core::auth;
|
||||
|
||||
use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
||||
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
||||
TransferDirection, TransferItem, send,
|
||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||
};
|
||||
|
||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||
@@ -129,8 +132,12 @@ struct Ui {
|
||||
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
||||
sidebar: gtk4::ListBox,
|
||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
||||
/// owns the whole window and no destination is reachable without a session.
|
||||
/// owns the whole window and no destination is reachable without a session,
|
||||
/// and while the window is [`narrow`](Self::narrow).
|
||||
nav: adw::NavigationSplitView,
|
||||
/// The window is too narrow for the sidebar beside a page, so the two take
|
||||
/// turns. Set by the narrow breakpoint; [`refresh`] reads it.
|
||||
narrow: Cell<bool>,
|
||||
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
||||
/// One cache and request queue serves Files, search, Shared and Trash, so
|
||||
/// the same image is downloaded and decoded only once.
|
||||
@@ -150,6 +157,7 @@ struct Ui {
|
||||
pub(crate) locations: LocationsState,
|
||||
pub(crate) activity: ActivityState,
|
||||
pub(crate) takeout: TakeoutState,
|
||||
pub(crate) update: UpdateState,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
@@ -296,7 +304,10 @@ fn present_window(app: &adw::Application) {
|
||||
/// Spawn the tray icon process in the background.
|
||||
pub(crate) fn spawn_tray() {
|
||||
match Command::new("pdfs-tray").spawn() {
|
||||
Ok(_) => tracing::info!("spawned `pdfs-tray`"),
|
||||
Ok(child) => {
|
||||
tracing::info!("spawned `pdfs-tray`");
|
||||
pdfs_core::opener::reap(child);
|
||||
}
|
||||
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -317,7 +328,9 @@ fn build_window(app: &adw::Application) {
|
||||
tracing::warn!("cannot create the app's directories: {e}");
|
||||
}
|
||||
|
||||
let stack = adw::ViewStack::new();
|
||||
// Not homogeneous: each page sets the window's minimum width only while it
|
||||
// is shown, so the widest page does not hold every other one wide.
|
||||
let stack = adw::ViewStack::builder().hhomogeneous(false).build();
|
||||
let (login_page, login_widgets) = build_login_page();
|
||||
let main_widgets = build_main_page();
|
||||
let (browser_page, browser_widgets) = build_browser_page();
|
||||
@@ -335,8 +348,12 @@ fn build_window(app: &adw::Application) {
|
||||
login_frame.add_top_bar(&{
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_show_title(false);
|
||||
// The split is collapsed while signed out; going "back" would only
|
||||
// uncover a sidebar with nowhere to go.
|
||||
header.set_show_back_button(false);
|
||||
header
|
||||
});
|
||||
login_frame.add_top_bar(&login_widgets.keyring_banner);
|
||||
login_frame.set_content(Some(&login_page));
|
||||
stack.add_named(&login_frame, Some("login"));
|
||||
stack.add_named(&browser_page, Some("browser"));
|
||||
@@ -351,7 +368,8 @@ fn build_window(app: &adw::Application) {
|
||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||
// login lands on Files).
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer);
|
||||
let update = UpdateState::new();
|
||||
let (sidebar_page, sidebar_list) = build_sidebar(&main_widgets.footer, &update.banner);
|
||||
|
||||
// Every page brings its own header bar (see `page_frame`), so the content
|
||||
// side is the bare stack.
|
||||
@@ -364,6 +382,8 @@ fn build_window(app: &adw::Application) {
|
||||
.content(&content_page)
|
||||
.min_sidebar_width(200.0)
|
||||
.max_sidebar_width(240.0)
|
||||
// A narrow window opens on the page, not the sidebar.
|
||||
.show_content(true)
|
||||
.build();
|
||||
|
||||
// Toasts float over everything, so a report from a background action reaches
|
||||
@@ -371,17 +391,22 @@ fn build_window(app: &adw::Application) {
|
||||
let toasts = adw::ToastOverlay::new();
|
||||
toasts.set_child(Some(&split));
|
||||
|
||||
let (startup_session, keyring_error) = match read_session() {
|
||||
Ok(session) => (session, None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let ui = Rc::new(Ui {
|
||||
dirs,
|
||||
stack: stack.clone(),
|
||||
toasts: toasts.clone(),
|
||||
busy: Cell::new(0),
|
||||
opening: RefCell::new(HashSet::new()),
|
||||
session: RefCell::new(auth::load().ok()),
|
||||
session: RefCell::new(startup_session),
|
||||
unlock_offered: Cell::new(false),
|
||||
mounted: RefCell::new(false),
|
||||
sidebar: sidebar_list.clone(),
|
||||
nav: split.clone(),
|
||||
narrow: Cell::new(false),
|
||||
file_thumbs: FileThumbnailState::new(),
|
||||
login: login_widgets,
|
||||
status: StatusState {
|
||||
@@ -411,6 +436,7 @@ fn build_window(app: &adw::Application) {
|
||||
theme_row: main_widgets.theme_row.clone(),
|
||||
online_map_row: main_widgets.online_map_row.clone(),
|
||||
tray_row: main_widgets.tray_row.clone(),
|
||||
update_row: main_widgets.update_row.clone(),
|
||||
language_row: main_widgets.language_row.clone(),
|
||||
settings_suppress: Cell::new(false),
|
||||
budget_source: RefCell::new(None),
|
||||
@@ -537,6 +563,7 @@ fn build_window(app: &adw::Application) {
|
||||
reload_pending: Cell::new(false),
|
||||
loading: Cell::new(false),
|
||||
has_more: Cell::new(false),
|
||||
library: Cell::new(true),
|
||||
page_waiters: RefCell::new(Vec::new()),
|
||||
width: Cell::new(0),
|
||||
photo_tex: RefCell::new(HashMap::new()),
|
||||
@@ -578,6 +605,8 @@ fn build_window(app: &adw::Application) {
|
||||
detail: locations_widgets.card_detail.clone(),
|
||||
pause: locations_widgets.pause.clone(),
|
||||
paused: Cell::new(false),
|
||||
sign_in: locations_widgets.card_sign_in.clone(),
|
||||
restart: locations_widgets.card_restart.clone(),
|
||||
},
|
||||
queue: QueueState {
|
||||
group: locations_widgets.queue_group.clone(),
|
||||
@@ -630,8 +659,12 @@ fn build_window(app: &adw::Application) {
|
||||
running: Cell::new(false),
|
||||
dry_run: Cell::new(false),
|
||||
},
|
||||
update,
|
||||
});
|
||||
wire_login(&ui);
|
||||
if let Some(e) = keyring_error {
|
||||
keyring_unreadable(&ui, &e);
|
||||
}
|
||||
let ui_status = ui.clone();
|
||||
main_widgets
|
||||
.status_button
|
||||
@@ -673,6 +706,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_activity(&ui, &activity_widgets);
|
||||
wire_takeout(&ui, &takeout_widgets);
|
||||
wire_retry(&ui);
|
||||
wire_update(&ui);
|
||||
|
||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
||||
// network round-trip only happens on demand rather than on every refresh.
|
||||
@@ -709,13 +743,18 @@ fn build_window(app: &adw::Application) {
|
||||
}
|
||||
});
|
||||
|
||||
let saved = ui.dirs.load_config().window.usable();
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Proton Drive")
|
||||
.default_width(980)
|
||||
.default_height(680)
|
||||
.default_width(saved.width)
|
||||
.default_height(saved.height)
|
||||
.maximized(saved.maximized)
|
||||
.width_request(WindowState::MIN_WIDTH)
|
||||
.height_request(WindowState::MIN_HEIGHT)
|
||||
.content(&toasts)
|
||||
.build();
|
||||
remember_window_state(&ui, app, &window);
|
||||
// A wide window docks the details pane beside the files instead of laying it
|
||||
// over them.
|
||||
let wide = adw::Breakpoint::new(
|
||||
@@ -723,6 +762,18 @@ fn build_window(app: &adw::Application) {
|
||||
);
|
||||
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
||||
window.add_breakpoint(wide);
|
||||
// A narrow window shows the sidebar and the page in turn, so every header
|
||||
// bar keeps room for the window buttons. Signals rather than a setter:
|
||||
// `refresh` decides the collapse while signed out, and would undo one.
|
||||
let narrow = adw::Breakpoint::new(
|
||||
adw::BreakpointCondition::parse("max-width: 760sp").expect("static breakpoint"),
|
||||
);
|
||||
narrow.add_setter(&ui.browser.search, "width-chars", Some(&8.to_value()));
|
||||
let ui_narrow = ui.clone();
|
||||
narrow.connect_apply(move |_| set_narrow(&ui_narrow, true));
|
||||
let ui_wide = ui.clone();
|
||||
narrow.connect_unapply(move |_| set_narrow(&ui_wide, false));
|
||||
window.add_breakpoint(narrow);
|
||||
install_shortcuts(&ui, &window);
|
||||
install_window_actions(&ui, &window);
|
||||
install_launch_actions(&ui, app, &window);
|
||||
@@ -736,6 +787,51 @@ fn build_window(app: &adw::Application) {
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Follow the narrow breakpoint. While signed out the login page keeps the
|
||||
/// split collapsed whatever the width.
|
||||
fn set_narrow(ui: &Ui, narrow: bool) {
|
||||
ui.narrow.set(narrow);
|
||||
if ui.session.borrow().is_some() {
|
||||
ui.nav.set_collapsed(narrow);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the window's size when it closes, and when the app quits with the
|
||||
/// window still open (Ctrl+Q, the tray's Quit), which never asks it to close.
|
||||
fn remember_window_state(ui: &Rc<Ui>, app: &adw::Application, window: &adw::ApplicationWindow) {
|
||||
let ui_close = ui.clone();
|
||||
window.connect_close_request(move |window| {
|
||||
save_window_state(&ui_close, window);
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
let ui_quit = ui.clone();
|
||||
let window_quit = window.downgrade();
|
||||
app.connect_shutdown(move |_| {
|
||||
if let Some(window) = window_quit.upgrade() {
|
||||
save_window_state(&ui_quit, &window);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Write the window's unmaximised size and maximised state to the config, if
|
||||
/// either changed. Runs as the window goes away, so a failure is only logged.
|
||||
fn save_window_state(ui: &Ui, window: &adw::ApplicationWindow) {
|
||||
let (width, height) = window.default_size();
|
||||
let state = WindowState {
|
||||
width,
|
||||
height,
|
||||
maximized: window.is_maximized(),
|
||||
};
|
||||
let mut config = ui.dirs.load_config();
|
||||
if config.window == state {
|
||||
return;
|
||||
}
|
||||
config.window = state;
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
tracing::warn!("cannot save the window size: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `window` is minimised or otherwise out of sight (another workspace,
|
||||
/// fully covered), as far as the compositor tells us.
|
||||
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
||||
@@ -782,10 +878,13 @@ const DESTINATIONS: [(&str, &str, &str); 7] = [
|
||||
/// Index of the first row in the sidebar's sync section.
|
||||
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
||||
|
||||
/// The navigation sidebar: the destinations over a footer carrying the sync
|
||||
/// status, the account quota and the account menu. Returns the page (for the
|
||||
/// The navigation sidebar: the destinations under the update banner and over a
|
||||
/// footer carrying the sync status, the account quota and the account menu. Returns the page (for the
|
||||
/// split view) and the list (to drive + reflect the current page).
|
||||
fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
fn build_sidebar(
|
||||
footer: >k4::Box,
|
||||
update_banner: &adw::Banner,
|
||||
) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
let list = gtk4::ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||
list.add_css_class("navigation-sidebar");
|
||||
@@ -830,7 +929,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&brand));
|
||||
header.pack_end(&build_primary_menu());
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
@@ -839,6 +937,7 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
||||
.build();
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.add_top_bar(update_banner);
|
||||
toolbar.set_content(Some(&scroll));
|
||||
toolbar.add_bottom_bar(footer);
|
||||
|
||||
@@ -864,6 +963,18 @@ fn set_busy_spinners(visible: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `row` in a frame that scrolls sideways once the window is too narrow for it,
|
||||
/// so a row of labelled buttons never sets the window's minimum width.
|
||||
fn scroll_sideways(row: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
|
||||
gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_width(true)
|
||||
.propagate_natural_height(true)
|
||||
.child(row)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// A destination page's frame: its own header bar over the page content, the
|
||||
/// way GNOME apps lay a split view out. The title sits in the middle with the
|
||||
/// busy spinner beside it; the page packs its actions into the returned header
|
||||
@@ -927,6 +1038,14 @@ fn wire_sidebar(ui: &Rc<Ui>) {
|
||||
ui_row.stack.set_visible_child_name(page);
|
||||
}
|
||||
});
|
||||
// In a narrow window the sidebar covers the page, so picking a row, even
|
||||
// the selected one, has to bring the page forward.
|
||||
let ui_open = ui.clone();
|
||||
ui.sidebar.connect_row_activated(move |_, _| {
|
||||
if ui_open.nav.is_collapsed() {
|
||||
ui_open.nav.set_show_content(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-fetch the visible page from the server, bypassing every layer of cache
|
||||
@@ -1062,8 +1181,17 @@ pub(crate) fn set_sidebar_badge(ui: &Ui, page: &str, count: u64) {
|
||||
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
||||
/// reported without stealing focus or forcing a click, so the user can keep
|
||||
/// working while a slow upload lands.
|
||||
///
|
||||
/// A toast's title is Pango markup unless told otherwise. Messages carry file
|
||||
/// names and daemon errors, and one with a `&` or `<` in it showed as an empty
|
||||
/// toast, so every toast here is plain text.
|
||||
fn toast(ui: &Rc<Ui>, message: &str) {
|
||||
ui.toasts.add_toast(adw::Toast::new(message));
|
||||
ui.toasts.add_toast(
|
||||
adw::Toast::builder()
|
||||
.title(message)
|
||||
.use_markup(false)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
||||
@@ -1074,6 +1202,7 @@ fn toast(ui: &Rc<Ui>, message: &str) {
|
||||
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
||||
let toast = adw::Toast::builder()
|
||||
.title(message)
|
||||
.use_markup(false)
|
||||
.button_label(label)
|
||||
.timeout(8)
|
||||
.build();
|
||||
@@ -1096,7 +1225,11 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
||||
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||
};
|
||||
tracing::warn!("{message}");
|
||||
let toast = adw::Toast::builder().title(&message).timeout(6).build();
|
||||
let toast = adw::Toast::builder()
|
||||
.title(&message)
|
||||
.use_markup(false)
|
||||
.timeout(6)
|
||||
.build();
|
||||
ui.toasts.add_toast(toast);
|
||||
}
|
||||
|
||||
@@ -1153,25 +1286,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
|
||||
/// belong on any one page.
|
||||
fn build_primary_menu() -> gtk4::MenuButton {
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
menu.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
gtk4::MenuButton::builder()
|
||||
.icon_name("pdfs-menu-symbolic")
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.primary(true)
|
||||
.menu_model(&menu)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Back the primary and account menus' entries with window actions.
|
||||
/// Back the main menu's entries with window actions.
|
||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||
let preferences = gio::SimpleAction::new("preferences", None);
|
||||
let ui_prefs = ui.clone();
|
||||
@@ -1308,6 +1423,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
||||
if ui_page.session.borrow().is_none() {
|
||||
return;
|
||||
}
|
||||
// Signed in, but Proton refused the session (the tray's "Sign In
|
||||
// Again…"): the login page, with the rest of the app still reachable.
|
||||
if page == "login" {
|
||||
show_login(&ui_page);
|
||||
return;
|
||||
}
|
||||
// Activity lives on as the Sync page's History view.
|
||||
if page == "activity" {
|
||||
ui_page.locations.views.set_visible_child_name("history");
|
||||
@@ -1676,13 +1797,35 @@ pub(crate) fn service_unreachable(
|
||||
);
|
||||
return;
|
||||
}
|
||||
status(
|
||||
&ui,
|
||||
"network-idle-symbolic",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive service to start."),
|
||||
false,
|
||||
);
|
||||
// Running but not answering may be a mount that fails on every try,
|
||||
// which never opens the socket. Its reason is in a file the daemon
|
||||
// keeps for this (B263); "Connecting…" alone looked like a slow start
|
||||
// for as long as it lasted.
|
||||
let state_dir = ui.dirs.state_dir();
|
||||
let failure = gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
match failure {
|
||||
Some(failure) => status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Can't open your Drive"),
|
||||
// Translators: {error} is an error message, in English.
|
||||
&gettext_f(
|
||||
"The Proton Drive service keeps failing to start and tries again on its own. The last error was: {error}",
|
||||
&[("error", &failure.error)],
|
||||
),
|
||||
true,
|
||||
),
|
||||
None => status(
|
||||
&ui,
|
||||
"network-idle-symbolic",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive service to start."),
|
||||
false,
|
||||
),
|
||||
}
|
||||
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
||||
reload(&ui);
|
||||
@@ -1708,9 +1851,12 @@ pub(crate) fn flash_copied(button: >k4::Button, icon: &'static str) {
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// On a thread of its own: telling a folder from a file is a `stat`, and the
|
||||
/// path is often in the mount, which may hang.
|
||||
fn open_path(path: &str) {
|
||||
let path = Path::new(path);
|
||||
pdfs_core::opener::open_default(path, path.is_dir());
|
||||
let path = PathBuf::from(path);
|
||||
std::thread::spawn(move || pdfs_core::opener::open_default(&path, path.is_dir()));
|
||||
}
|
||||
|
||||
/// [`open_path`] for a file the daemon materialised into the content cache,
|
||||
|
||||
@@ -378,7 +378,9 @@ pub(crate) fn activity_status(
|
||||
) {
|
||||
ui.activity.status.set_icon_name(Some(icon));
|
||||
ui.activity.status.set_title(title);
|
||||
ui.activity.status.set_description(Some(description));
|
||||
ui.activity
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.activity.retry.set_visible(retry);
|
||||
ui.activity.content.set_visible_child_name("status");
|
||||
}
|
||||
@@ -1041,6 +1043,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
ActivityKind::Sync => "pdfs-sync-symbolic",
|
||||
ActivityKind::Rename => "document-edit-symbolic",
|
||||
ActivityKind::Move => "go-jump-symbolic",
|
||||
ActivityKind::Copy => "edit-copy-symbolic",
|
||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||
ActivityKind::Trash => "user-trash-symbolic",
|
||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||
|
||||
@@ -179,6 +179,10 @@ pub(crate) fn activity_title(kind: ActivityKind, ok: bool, name: &str) -> String
|
||||
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Copy, true) => gettext_f("Copied {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
(ActivityKind::Copy, false) => gettext_f("Couldn't copy {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
|
||||
@@ -645,7 +645,9 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
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_status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.gallery.albums_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, mounted_target_rel};
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_path, present_within};
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct BrowserState {
|
||||
@@ -376,7 +376,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
bulk_box.append(&bulk_clear);
|
||||
let bulk = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||
.child(&bulk_box)
|
||||
.child(&scroll_sideways(&bulk_box))
|
||||
.build();
|
||||
|
||||
// The status bar: contextual text (stretch 1), then the icon-size slider.
|
||||
@@ -500,7 +500,9 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
pub(crate) fn browser_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.browser.status.set_icon_name(Some(icon));
|
||||
ui.browser.status.set_title(title);
|
||||
ui.browser.status.set_description(Some(description));
|
||||
ui.browser
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.browser.retry.set_visible(retry);
|
||||
// Only the empty-folder state offers a way to fill the folder; every other
|
||||
// status (loading, offline, error) turns them back off.
|
||||
@@ -520,9 +522,9 @@ pub(crate) fn clear_selection(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
/// Reflect the current selection in the bulk bar: how many are selected, and
|
||||
/// which bulk actions apply to that mix. Offline state is a file-only notion, so
|
||||
/// a folder anywhere in the selection disables the two pin buttons rather than
|
||||
/// half-failing once pressed.
|
||||
/// which bulk actions apply to that mix. An entry kept offline through a pinned
|
||||
/// folder above it cannot be made online only on its own, so it does not count
|
||||
/// towards Make online only.
|
||||
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
let entries = selected_entries(ui);
|
||||
// One selected entry is the details pane's job; the bar is for a batch.
|
||||
@@ -530,8 +532,7 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
ui.browser.bulk.set_reveal_child(false);
|
||||
return;
|
||||
}
|
||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned);
|
||||
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
|
||||
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
||||
let mounted = *ui.mounted.borrow();
|
||||
ui.browser.bulk_label.set_label(&ngettext_f(
|
||||
@@ -541,12 +542,8 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||
&[],
|
||||
));
|
||||
ui.browser.bulk_trash.set_sensitive(mounted);
|
||||
ui.browser
|
||||
.bulk_pin
|
||||
.set_sensitive(mounted && files_only && any_unpinned);
|
||||
ui.browser
|
||||
.bulk_unpin
|
||||
.set_sensitive(mounted && files_only && any_pinned);
|
||||
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
|
||||
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
|
||||
ui.browser.bulk.set_reveal_child(true);
|
||||
}
|
||||
|
||||
@@ -652,16 +649,19 @@ pub(crate) fn restore_uids(ui: &Rc<Ui>, uids: Vec<String>, count: usize) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pin or unpin every selected file, one request at a time.
|
||||
/// Pin or unpin every selected entry, one request at a time. A folder's files
|
||||
/// download in the background, so its request returns before they are there.
|
||||
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
let socket = ui.dirs.control_socket();
|
||||
// Only the entries that need changing: re-pinning an already-pinned file is
|
||||
// a wasted round-trip, and it would inflate the count the toast reports.
|
||||
let paths: Vec<String> = entries
|
||||
// a wasted round-trip, and it would inflate the count the toast reports. One
|
||||
// kept through a pinned folder above it has no pin of its own to remove.
|
||||
let changing: Vec<&DirEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.pinned != pin)
|
||||
.map(|e| entry_rel(ui, e))
|
||||
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
|
||||
.collect();
|
||||
let any_folder = changing.iter().any(|e| e.is_dir);
|
||||
let paths: Vec<String> = changing.iter().map(|e| entry_rel(ui, e)).collect();
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -698,13 +698,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||
(0, None) => {}
|
||||
(n, _) => toast(
|
||||
&ui,
|
||||
&if pin {
|
||||
&if pin && any_folder {
|
||||
ngettext_f(
|
||||
"Making {n} item available offline…",
|
||||
"Making {n} items available offline…",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if pin {
|
||||
ngettext_f(
|
||||
"{n} file is now available offline",
|
||||
"{n} files are now available offline",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else if any_folder {
|
||||
ngettext_f(
|
||||
"{n} item is now online only",
|
||||
"{n} items are now online only",
|
||||
n as u64,
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
ngettext_f(
|
||||
"{n} file is now online only",
|
||||
@@ -860,13 +874,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
}
|
||||
menu.section();
|
||||
|
||||
if !entry.is_dir {
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove: the check shows that state without offering to change it.
|
||||
if entry.pinned_by_folder {
|
||||
menu.locked_toggle(&gettext("Available offline"), true);
|
||||
} else {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
||||
toggle_pin(&ui_c, &entry_c)
|
||||
});
|
||||
menu.section();
|
||||
}
|
||||
menu.section();
|
||||
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Share…"), move || {
|
||||
@@ -886,6 +904,12 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
menu.item(&gettext("Move To…"), move || {
|
||||
prompt_move(&ui_c, vec![entry_c.clone()])
|
||||
});
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Make a Copy"), move || make_copy(&ui_c, &entry_c));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Copy To…"), move || {
|
||||
prompt_copy(&ui_c, vec![entry_c.clone()])
|
||||
});
|
||||
if !entry.is_dir {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
menu.item(&gettext("Versions…"), move || {
|
||||
@@ -908,9 +932,8 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||
/// simply absent here rather than offered and then refused.
|
||||
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||
let mut menu = ActionMenu::new();
|
||||
// Offline state is a file-only notion; the check shows whether the whole
|
||||
// batch already is.
|
||||
if entries.iter().all(|e| !e.is_dir) {
|
||||
// The check shows whether the whole batch already is offline.
|
||||
{
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
||||
@@ -921,6 +944,10 @@ pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMe
|
||||
menu.item(&gettext("Move To…"), move || {
|
||||
prompt_move(&ui_c, batch.clone())
|
||||
});
|
||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||
menu.item(&gettext("Copy To…"), move || {
|
||||
prompt_copy(&ui_c, batch.clone())
|
||||
});
|
||||
menu.labelled_section(&ngettext_f(
|
||||
"{n} selected",
|
||||
"{n} selected",
|
||||
@@ -966,28 +993,27 @@ pub(crate) fn background_context_menu(ui: &Rc<Ui>) -> ActionMenu {
|
||||
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rel = entry_rel(ui, entry);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let Some(path) = mounted_target_rel(&mountpoint, &rel) else {
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Couldn't open file"),
|
||||
&gettext("Open With needs the Proton Drive service to be running."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
|
||||
launcher.set_always_ask(true);
|
||||
let ui = ui.clone();
|
||||
launcher.launch(
|
||||
ui_window(&ui).as_ref(),
|
||||
gio::Cancellable::NONE,
|
||||
move |result| {
|
||||
glib::spawn_future_local(async move {
|
||||
let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await else {
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't open file"),
|
||||
&gettext("Open With needs the Proton Drive service to be running."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let launcher = gtk4::FileLauncher::new(Some(&gio::File::for_path(path)));
|
||||
launcher.set_always_ask(true);
|
||||
let window = ui_window(&ui);
|
||||
launcher.launch(window.as_ref(), gio::Cancellable::NONE, move |result| {
|
||||
if let Err(e) = result
|
||||
&& !e.matches(gtk4::DialogError::Dismissed)
|
||||
{
|
||||
toast_error(&ui, &gettext("Couldn't open file"), &e.to_string());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Copy the entry's public link, when it has one this client can read the
|
||||
@@ -1095,27 +1121,29 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
/// [`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.
|
||||
// Ignore a repeat activation of a file already opening, so an impatient
|
||||
// double-click doesn't open it twice or 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(),
|
||||
uid: Some(entry.uid.clone()),
|
||||
},
|
||||
);
|
||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||
let (name, uid) = (entry.name.clone(), entry.uid.clone());
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
// Asked off the main thread: a mount that hangs used to hang the app.
|
||||
if let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await {
|
||||
ui.opening.borrow_mut().remove(&rel);
|
||||
open_path(&path.to_string_lossy());
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenFile {
|
||||
path: rel.clone(),
|
||||
uid: Some(uid),
|
||||
},
|
||||
);
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.opening.borrow_mut().remove(&rel);
|
||||
@@ -1147,6 +1175,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let name = entry.name.clone();
|
||||
let pinned = entry.pinned;
|
||||
let is_dir = entry.is_dir;
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
@@ -1166,6 +1195,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
&ui,
|
||||
&if pinned {
|
||||
gettext_f("“{name}” is now online only", &[("name", &name)])
|
||||
} else if is_dir {
|
||||
// The daemon downloads a folder's files in the background.
|
||||
gettext_f("Making “{name}” available offline…", &[("name", &name)])
|
||||
} else {
|
||||
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
||||
},
|
||||
@@ -1220,9 +1252,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
));
|
||||
let mut acc = String::new();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
ui.browser.crumb.append(&sep);
|
||||
ui.browser.crumb.append(&crumb_separator());
|
||||
acc = if acc.is_empty() {
|
||||
seg.to_string()
|
||||
} else {
|
||||
@@ -1237,12 +1267,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
|
||||
/// flat button that navigates to `target` (clearing any active search first).
|
||||
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -1925,17 +1950,63 @@ pub(crate) fn prompt_rename(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
/// still made on Proton Drive, so nothing is downloaded or uploaded again; it
|
||||
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
||||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
prompt_destination(ui, entries, PickerKind::Move);
|
||||
}
|
||||
|
||||
/// Ask where to copy `entries`, then copy them there on Proton Drive. Only
|
||||
/// My files is offered: a copy is made on the server, inside the mount.
|
||||
pub(crate) fn prompt_copy(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
prompt_destination(ui, entries, PickerKind::Copy);
|
||||
}
|
||||
|
||||
/// Copy one entry into the folder it is in. The copy gets a free name, such
|
||||
/// as "report (1).pdf".
|
||||
pub(crate) fn make_copy(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let source = entry_rel(ui, entry);
|
||||
let parent = source
|
||||
.rfind('/')
|
||||
.map(|i| source[..i].to_string())
|
||||
.unwrap_or_default();
|
||||
run_bulk_copy(ui, vec![source], parent);
|
||||
}
|
||||
|
||||
/// What the folder picker chooses a destination for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum PickerKind {
|
||||
Move,
|
||||
Copy,
|
||||
}
|
||||
|
||||
fn prompt_destination(ui: &Rc<Ui>, entries: Vec<DirEntry>, kind: PickerKind) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
||||
let heading = match entries.as_slice() {
|
||||
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]),
|
||||
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
||||
let (heading, body, confirm) = match (kind, entries.as_slice()) {
|
||||
(PickerKind::Move, [one]) => (
|
||||
gettext_f("Move “{name}”", &[("name", &one.name)]),
|
||||
gettext("Choose the folder to move into."),
|
||||
gettext("Move Here"),
|
||||
),
|
||||
(PickerKind::Move, many) => (
|
||||
ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
||||
gettext("Choose the folder to move into."),
|
||||
gettext("Move Here"),
|
||||
),
|
||||
(PickerKind::Copy, [one]) => (
|
||||
gettext_f("Copy “{name}”", &[("name", &one.name)]),
|
||||
gettext("Choose the folder to copy into."),
|
||||
gettext("Copy Here"),
|
||||
),
|
||||
(PickerKind::Copy, many) => (
|
||||
ngettext_f("Copy {n} item", "Copy {n} items", many.len() as u64, &[]),
|
||||
gettext("Choose the folder to copy into."),
|
||||
gettext("Copy Here"),
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(heading)
|
||||
.body(gettext("Choose the folder to move into."))
|
||||
.body(body)
|
||||
.build();
|
||||
|
||||
let up = gtk4::Button::builder()
|
||||
@@ -1966,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
body.append(&scroll);
|
||||
dialog.set_extra_child(Some(&body));
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.add_response("move", &confirm);
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("move"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -1979,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
list,
|
||||
up: up.clone(),
|
||||
sources: sources.clone(),
|
||||
kind,
|
||||
folder: RefCell::new(MoveTarget::Locations),
|
||||
generation: Cell::new(0),
|
||||
});
|
||||
@@ -1998,6 +2070,10 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||
}
|
||||
match picker_done.folder.borrow().clone() {
|
||||
MoveTarget::Locations => {}
|
||||
MoveTarget::Drive(folder) if kind == PickerKind::Copy => {
|
||||
run_bulk_copy(&ui, sources.clone(), folder)
|
||||
}
|
||||
MoveTarget::Synced { .. } if kind == PickerKind::Copy => {}
|
||||
MoveTarget::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
||||
MoveTarget::Synced { path, .. } => {
|
||||
let absolute = sources
|
||||
@@ -2073,8 +2149,9 @@ struct MovePicker {
|
||||
location: gtk4::Label,
|
||||
list: gtk4::ListBox,
|
||||
up: gtk4::Button,
|
||||
/// What is being moved, mountpoint-relative.
|
||||
/// What is being moved or copied, mountpoint-relative.
|
||||
sources: Vec<String>,
|
||||
kind: PickerKind,
|
||||
/// The folder on show, and the destination if the user confirms now.
|
||||
folder: RefCell<MoveTarget>,
|
||||
/// Drops a listing that arrives after the user went elsewhere.
|
||||
@@ -2095,11 +2172,13 @@ impl MovePicker {
|
||||
self.location.set_tooltip_text(Some(&tooltip));
|
||||
self.up
|
||||
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
||||
let allowed = match &folder {
|
||||
MoveTarget::Locations => false,
|
||||
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
|
||||
let allowed = match (&folder, self.kind) {
|
||||
(MoveTarget::Locations, _) => false,
|
||||
(MoveTarget::Drive(rel), PickerKind::Move) => move_target_allowed(&self.sources, rel),
|
||||
(MoveTarget::Drive(rel), PickerKind::Copy) => copy_target_allowed(&self.sources, rel),
|
||||
// What is moved is in My files, so it cannot be in a synced folder.
|
||||
MoveTarget::Synced { .. } => true,
|
||||
(MoveTarget::Synced { .. }, PickerKind::Move) => true,
|
||||
(MoveTarget::Synced { .. }, PickerKind::Copy) => false,
|
||||
};
|
||||
self.dialog.set_response_enabled("move", allowed);
|
||||
*self.folder.borrow_mut() = folder.clone();
|
||||
@@ -2165,6 +2244,10 @@ impl MovePicker {
|
||||
/// My files and every synced folder, as rows of the top level.
|
||||
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
||||
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
||||
// A copy is made on the server, so it can only land in My files.
|
||||
if self.kind == PickerKind::Copy {
|
||||
return Some(rows);
|
||||
}
|
||||
let rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
||||
// My files alone is still somewhere to move to.
|
||||
@@ -2271,11 +2354,16 @@ pub(crate) fn move_target_allowed(sources: &[String], target: &str) -> bool {
|
||||
.map(|i| path[..i].to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let inside = sources
|
||||
.iter()
|
||||
.any(|src| target == src || target.starts_with(&format!("{src}/")));
|
||||
let all_here = sources.iter().all(|src| parent(src) == target);
|
||||
!inside && !all_here
|
||||
copy_target_allowed(sources, target) && !all_here
|
||||
}
|
||||
|
||||
/// Whether `target` is somewhere `sources` can be copied to: anywhere but into
|
||||
/// one of themselves. The folder they are in is fine; the copy gets a free name.
|
||||
pub(crate) fn copy_target_allowed(sources: &[String], target: &str) -> bool {
|
||||
!sources
|
||||
.iter()
|
||||
.any(|src| target == src || target.starts_with(&format!("{src}/")))
|
||||
}
|
||||
|
||||
/// Move each of `sources` into `target`, one request at a time, then report
|
||||
@@ -2294,38 +2382,14 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
clear_selection(ui);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let mut done = 0usize;
|
||||
let mut failure: Option<(String, ErrorKind)> = None;
|
||||
for path in sources {
|
||||
let rx = spawn_request(
|
||||
socket.clone(),
|
||||
Request::Move {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
},
|
||||
);
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => done += 1,
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
failure.get_or_insert((message, kind));
|
||||
}
|
||||
_ => {
|
||||
failure.get_or_insert_with(|| {
|
||||
(
|
||||
gettext("The Proton Drive service didn't respond."),
|
||||
ErrorKind::Internal,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let (done, failure) = send_each(&socket, sources, |path| Request::Move {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
})
|
||||
.await;
|
||||
ui.busy_end();
|
||||
reload_listing(&ui);
|
||||
let place = match target.rsplit('/').next() {
|
||||
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
||||
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
||||
_ => "Proton Drive".to_string(),
|
||||
};
|
||||
let place = quoted_place(&target);
|
||||
match (done, failure) {
|
||||
(0, Some((message, kind))) => {
|
||||
toast_failure(&ui, &gettext("Couldn't move"), &message, kind)
|
||||
@@ -2358,6 +2422,101 @@ pub(crate) fn run_bulk_move(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Copy each of `sources` into `target` on Proton Drive, one request at a
|
||||
/// time, then report once for the lot.
|
||||
pub(crate) fn run_bulk_copy(ui: &Rc<Ui>, sources: Vec<String>, target: String) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Couldn't copy"),
|
||||
&gettext("Proton Drive isn't connected."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let socket = ui.dirs.control_socket();
|
||||
ui.busy_begin();
|
||||
clear_selection(ui);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let (done, failure) = send_each(&socket, sources, |path| Request::Copy {
|
||||
path,
|
||||
new_parent: target.clone(),
|
||||
})
|
||||
.await;
|
||||
ui.busy_end();
|
||||
reload_listing(&ui);
|
||||
let place = quoted_place(&target);
|
||||
match (done, failure) {
|
||||
(0, Some((message, kind))) => {
|
||||
toast_failure(&ui, &gettext("Couldn't copy"), &message, kind)
|
||||
}
|
||||
(0, None) => {}
|
||||
(n, Some((message, _))) => toast_error(
|
||||
&ui,
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
&ngettext_f(
|
||||
"Copied {n} item to {place}, but not all",
|
||||
"Copied {n} items to {place}, but not all",
|
||||
n as u64,
|
||||
&[("place", &place)],
|
||||
),
|
||||
&message,
|
||||
),
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
(1, None) => toast(&ui, &gettext_f("Copied to {place}", &[("place", &place)])),
|
||||
(n, None) => toast(
|
||||
&ui,
|
||||
// Translators: {place} is a quoted folder name or "Proton Drive".
|
||||
&ngettext_f(
|
||||
"Copied {n} item to {place}",
|
||||
"Copied {n} items to {place}",
|
||||
n as u64,
|
||||
&[("place", &place)],
|
||||
),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Send one request per source, in order, and count how many succeeded. The
|
||||
/// first failure is kept for the report.
|
||||
async fn send_each(
|
||||
socket: &Path,
|
||||
sources: Vec<String>,
|
||||
request: impl Fn(String) -> Request,
|
||||
) -> (usize, Option<(String, ErrorKind)>) {
|
||||
let mut done = 0usize;
|
||||
let mut failure: Option<(String, ErrorKind)> = None;
|
||||
for path in sources {
|
||||
let rx = spawn_request(socket.to_path_buf(), request(path));
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => done += 1,
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
failure.get_or_insert((message, kind));
|
||||
}
|
||||
_ => {
|
||||
failure.get_or_insert_with(|| {
|
||||
(
|
||||
gettext("The Proton Drive service didn't respond."),
|
||||
ErrorKind::Internal,
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
(done, failure)
|
||||
}
|
||||
|
||||
/// The destination folder for a report: its name in quotes, or "Proton Drive"
|
||||
/// for My files.
|
||||
fn quoted_place(target: &str) -> String {
|
||||
match target.rsplit('/').next() {
|
||||
// Translators: a folder name in quotes, filled in as {place} in "Moved to {place}".
|
||||
Some(name) if !name.is_empty() => gettext_f("“{name}”", &[("name", name)]),
|
||||
_ => "Proton Drive".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move one entry to Trash, through the batch path so it gets the same Undo.
|
||||
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
run_bulk_delete(ui, vec![entry.clone()]);
|
||||
@@ -2494,16 +2653,19 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
|
||||
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
||||
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
||||
/// cached (downloaded, evictable); everything else is online-only.
|
||||
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
|
||||
/// cache state of its own, so it has one only when kept offline. A sync issue
|
||||
/// ranks first, on folders too: the change waits for the user. Pinned (kept
|
||||
/// offline) ranks above merely cached (downloaded, evictable); everything else
|
||||
/// is online-only.
|
||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||
if entry.issue.is_some() {
|
||||
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||
}
|
||||
if entry.is_dir {
|
||||
return None;
|
||||
return entry
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if entry.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
@@ -2912,6 +3074,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
size: h.size,
|
||||
modified: h.modified,
|
||||
pinned: h.pinned,
|
||||
pinned_by_folder: false,
|
||||
cached: h.cached,
|
||||
uid: h.uid.clone(),
|
||||
path: h.path.clone(),
|
||||
@@ -2959,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
||||
show_thumbnail_build_progress, sort_entries,
|
||||
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
|
||||
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||
};
|
||||
use pdfs_core::config::{FileSort, FilesView};
|
||||
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||
@@ -2972,6 +3135,7 @@ mod tests {
|
||||
size,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: String::new(),
|
||||
path: String::new(),
|
||||
@@ -2999,6 +3163,8 @@ mod tests {
|
||||
|
||||
let mut folder = entry("d", true, 0, 0);
|
||||
assert_eq!(badge_for(&folder), None);
|
||||
folder.pinned = true;
|
||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
|
||||
folder.issue = Some(SyncIssue::Missing);
|
||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||
}
|
||||
@@ -3045,6 +3211,16 @@ mod tests {
|
||||
assert!(move_target_allowed(&mixed, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_refuses_its_own_subtree_but_not_the_folder_it_is_in() {
|
||||
let sources = ["Docs/Work".to_string()];
|
||||
assert!(!copy_target_allowed(&sources, "Docs/Work"));
|
||||
assert!(!copy_target_allowed(&sources, "Docs/Work/Old"));
|
||||
assert!(copy_target_allowed(&sources, "Docs"));
|
||||
assert!(copy_target_allowed(&sources, ""));
|
||||
assert!(copy_target_allowed(&sources, "Docs/Workshop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_summary_matches_dolphin_order_and_wording() {
|
||||
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
||||
|
||||
@@ -204,7 +204,9 @@ pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
|
||||
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.devices.status.set_icon_name(Some(icon));
|
||||
ui.devices.status.set_title(title);
|
||||
ui.devices.status.set_description(Some(description));
|
||||
ui.devices
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.devices.retry.set_visible(retry);
|
||||
ui.devices.content.set_visible_child_name("status");
|
||||
}
|
||||
@@ -390,6 +392,7 @@ fn this_computer_row(
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.use_markup(false)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
@@ -426,6 +429,7 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&dev.name)
|
||||
.subtitle(device_subtitle(dev))
|
||||
.use_markup(false)
|
||||
.activatable(true)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
@@ -439,6 +443,14 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
|
||||
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Sync With This Computer…"), move || {
|
||||
prompt_join_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Move to This Computer…"), move || {
|
||||
prompt_migrate_device(&ui_c, &uid_c, &name_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
@@ -483,9 +495,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -494,12 +504,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` levels below the device list.
|
||||
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -592,6 +597,19 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
folders.len() as u64,
|
||||
&[],
|
||||
);
|
||||
// A folder whose first pass has not run is not backed up yet, whatever its
|
||||
// state column says (`docs/BUGS.md` B261).
|
||||
let waiting = folders.iter().filter(|f| never_synced(f)).count();
|
||||
let count = match waiting {
|
||||
0 => count,
|
||||
// Translators: {folders} is "N folders backed up"; {n} counts the folders whose first sync has not run yet.
|
||||
n => ngettext_f(
|
||||
"{folders} · {n} waiting for its first sync",
|
||||
"{folders} · {n} waiting for their first sync",
|
||||
n as u64,
|
||||
&[("folders", &count)],
|
||||
),
|
||||
};
|
||||
let attention = folders
|
||||
.iter()
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
@@ -608,12 +626,21 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column.
|
||||
pub(crate) fn sync_state_label(state: &str) -> String {
|
||||
/// Whether a synced folder is a mirror whose first pass has not finished.
|
||||
/// Its `state` starts out `idle`, which would read as "up to date".
|
||||
fn never_synced(folder: &SyncFolderInfo) -> bool {
|
||||
folder.mode == "mirror" && folder.last_sync == 0 && folder.state == "idle"
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column. `never_synced` is a
|
||||
/// mirror folder whose first pass has not finished: its `state` starts out
|
||||
/// `idle`, and "up to date" would say the opposite of the truth (B261).
|
||||
pub(crate) fn sync_state_label(state: &str, never_synced: bool) -> String {
|
||||
match state {
|
||||
"syncing" => gettext("syncing…"),
|
||||
"error" => gettext("sync error"),
|
||||
"conflict" => gettext("needs attention"),
|
||||
_ if never_synced => gettext("waiting for first sync"),
|
||||
_ => gettext("up to date"),
|
||||
}
|
||||
}
|
||||
@@ -624,9 +651,17 @@ pub(crate) fn sync_state_label(state: &str) -> String {
|
||||
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
||||
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
match p.phase {
|
||||
// Before the first pass finishes there is no estimate, so the count would
|
||||
// be "checked 12 of 12" — worse than saying nothing.
|
||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
||||
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
|
||||
// Before the first pass finishes there is no estimate, so "12 of 12" would
|
||||
// say nothing. The count alone still shows a first scan of a large folder
|
||||
// moving, which can take many minutes before anything downloads.
|
||||
// Translators: {n} counts the folders and files checked so far.
|
||||
SyncPhase::Scanning if p.total == 0 => ngettext_f(
|
||||
"checking for changes — {n} item checked",
|
||||
"checking for changes — {n} items checked",
|
||||
p.done as u64,
|
||||
&[],
|
||||
),
|
||||
// Translators: {done} and {total} count folders and files checked so far.
|
||||
SyncPhase::Scanning => gettext_f(
|
||||
"checking for changes — {done} of {total}",
|
||||
@@ -766,7 +801,10 @@ fn show_restore_picker(
|
||||
.active(true)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let row = adw::EntryRow::builder().title(&f.name).build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title(&f.name)
|
||||
.use_markup(false)
|
||||
.build();
|
||||
row.set_text(&f.local_path);
|
||||
row.add_prefix(&check);
|
||||
group.add(&row);
|
||||
@@ -1096,6 +1134,130 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask before syncing into another computer's device alongside it, then offer
|
||||
/// its folders for syncing here.
|
||||
///
|
||||
/// The same adoption as "Continue This Backup Here", but the other computer
|
||||
/// goes on syncing, so the dialog says what that means: the two keep the same
|
||||
/// folders in step, deletions included.
|
||||
pub(crate) fn prompt_join_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Sync With This Computer?"))
|
||||
// Translators: {name} is the name of the other computer this one syncs with.
|
||||
.body(gettext_f(
|
||||
"This computer and “{name}” keep the same folders in step. A change or a deletion on one reaches the other.\n\nNew synced folders on this computer go under “{name}” in Proton Drive. Folders already synced here stay where they are. Next you choose which of its folders to sync here.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("join", &gettext("Sync Here"));
|
||||
dialog.set_response_appearance("join", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the other computer this one now syncs with.
|
||||
let done = gettext_f("Now syncing with “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "join" {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::AdoptDevice {
|
||||
uid: Some(uid.clone()),
|
||||
},
|
||||
);
|
||||
let (ui, done) = (ui.clone(), done.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
let failed = gettext("Couldn't sync with that computer");
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
// The device is this computer's now, so its own restore
|
||||
// picker offers the folders.
|
||||
prompt_restore_folders(&ui, None);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask before moving another computer's folders into this computer's backup,
|
||||
/// then offer them for syncing here.
|
||||
///
|
||||
/// Destructive-looking on purpose: the other computer keeps its device but
|
||||
/// loses its folders, and a copy of Proton Drive still running there would
|
||||
/// watch them vanish.
|
||||
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Move Backup to This Computer?"))
|
||||
// Translators: {name} is the name of the other computer whose folders move to this one.
|
||||
.body(gettext_f(
|
||||
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("move", &gettext("Move Here"));
|
||||
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
// Translators: {name} is the name of the computer whose folders moved to this one.
|
||||
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp != "move" {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::MigrateDevice { uid: uid.clone() },
|
||||
);
|
||||
let (ui, done) = (ui.clone(), done.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
let failed = gettext("Couldn't move that backup");
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
// The folders are this computer's now, so its own restore
|
||||
// picker is where the user chooses their local paths.
|
||||
prompt_restore_folders(&ui, None);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
@@ -1134,7 +1296,7 @@ mod tests {
|
||||
remote_uid: "vol~link".into(),
|
||||
mode: "mirror".into(),
|
||||
state: state.into(),
|
||||
last_sync: 0,
|
||||
last_sync: 1_700_000_000,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
paused: false,
|
||||
@@ -1146,6 +1308,33 @@ mod tests {
|
||||
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
||||
}
|
||||
|
||||
/// A folder added a moment ago is not backed up yet, and the row must not
|
||||
/// say it is (`docs/BUGS.md` B261). An on-demand folder has no first sync.
|
||||
#[test]
|
||||
fn a_folder_before_its_first_sync_is_not_counted_as_backed_up() {
|
||||
let new = SyncFolderInfo {
|
||||
last_sync: 0,
|
||||
..folder("idle")
|
||||
};
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), new.clone()]),
|
||||
"2 folders backed up · 1 waiting for its first sync"
|
||||
);
|
||||
let failed = SyncFolderInfo {
|
||||
state: "error".into(),
|
||||
..new.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[new.clone(), failed]),
|
||||
"2 folders backed up · 1 waiting for its first sync · 1 needs attention"
|
||||
);
|
||||
let on_demand = SyncFolderInfo {
|
||||
mode: "ondemand".into(),
|
||||
..new
|
||||
};
|
||||
assert_eq!(this_computer_subtitle(&[on_demand]), "1 folder backed up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_needing_attention_are_counted_not_hidden() {
|
||||
// The per-folder rows live on the Sync page, so this row is the only
|
||||
@@ -1164,6 +1353,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn scanning(done: usize, total: usize) -> SyncProgress {
|
||||
SyncProgress {
|
||||
phase: SyncPhase::Scanning,
|
||||
done,
|
||||
total,
|
||||
current: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_scan_counts_what_it_checked() {
|
||||
// A first sync of a large folder scans for many minutes before anything
|
||||
// downloads; without a count it reads as stuck (#46).
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(0, 0)),
|
||||
"checking for changes…"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(1, 0)),
|
||||
"checking for changes — 1 item checked"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(1234, 0)),
|
||||
"checking for changes — 1234 items checked"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_progress_label(&scanning(12, 40)),
|
||||
"checking for changes — 12 of 40"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
||||
|
||||
@@ -209,7 +209,8 @@ fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
view.status.set_title(&failed);
|
||||
view.status.set_description(Some(&message));
|
||||
view.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(&message)));
|
||||
view.progress.set_visible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) struct SyncCard {
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
/// Whether the last status said paused, so the button knows which way to go.
|
||||
pub(crate) paused: Cell<bool>,
|
||||
/// Shown only while Proton refuses the session; opens the sign-in page.
|
||||
pub(crate) sign_in: gtk4::Button,
|
||||
/// Shown only while the daemon is older than the app; restarts it.
|
||||
pub(crate) restart: gtk4::Button,
|
||||
}
|
||||
|
||||
/// What a folder row's controls are built from. The subtitle and progress bar
|
||||
@@ -108,6 +112,8 @@ pub(crate) struct LocationsWidgets {
|
||||
pub(crate) card_icon: gtk4::Image,
|
||||
pub(crate) card_title: gtk4::Label,
|
||||
pub(crate) card_detail: gtk4::Label,
|
||||
pub(crate) card_sign_in: gtk4::Button,
|
||||
pub(crate) card_restart: gtk4::Button,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
@@ -134,7 +140,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
.build();
|
||||
add_folder.add_css_class("flat");
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_title, card_detail) = build_sync_card();
|
||||
let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
|
||||
let pause = build_pause_button();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
let conflicts_group = adw::PreferencesGroup::builder()
|
||||
@@ -253,6 +259,8 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
||||
card_icon,
|
||||
card_title,
|
||||
card_detail,
|
||||
card_sign_in,
|
||||
card_restart,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
@@ -290,6 +298,16 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
wire_pause(ui);
|
||||
let ui_sign_in = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.sign_in
|
||||
.connect_clicked(move |_| show_login(&ui_sign_in));
|
||||
let ui_restart = ui.clone();
|
||||
ui.locations
|
||||
.card
|
||||
.restart
|
||||
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
|
||||
let ui_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
@@ -379,9 +397,16 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
||||
state.banner.set_revealed(away && title.is_some());
|
||||
}
|
||||
|
||||
/// The status hero: a large state icon, the state in words, and what it
|
||||
/// means.
|
||||
fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
/// The status hero: a large state icon, the state in words, what it means,
|
||||
/// and the way out when there is one only the user can take.
|
||||
fn build_sync_card() -> (
|
||||
gtk4::Box,
|
||||
gtk4::Image,
|
||||
gtk4::Label,
|
||||
gtk4::Label,
|
||||
gtk4::Button,
|
||||
gtk4::Button,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("pdfs-sync-symbolic")
|
||||
.pixel_size(48)
|
||||
@@ -408,9 +433,25 @@ fn build_sync_card() -> (gtk4::Box, gtk4::Image, gtk4::Label, gtk4::Label) {
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("sync-card");
|
||||
let sign_in = gtk4::Button::builder()
|
||||
.label(gettext("Sign In Again"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
sign_in.add_css_class("pill");
|
||||
sign_in.add_css_class("suggested-action");
|
||||
let restart = gtk4::Button::builder()
|
||||
.label(gettext("Restart"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
restart.add_css_class("pill");
|
||||
restart.add_css_class("suggested-action");
|
||||
card.append(&icon);
|
||||
card.append(&text);
|
||||
(card, icon, title, detail)
|
||||
card.append(&sign_in);
|
||||
card.append(&restart);
|
||||
(card, icon, title, detail, sign_in, restart)
|
||||
}
|
||||
|
||||
/// Pause/Resume, with timed pauses in its menu.
|
||||
@@ -1104,7 +1145,9 @@ pub(crate) fn locations_status(
|
||||
) {
|
||||
ui.locations.status.set_icon_name(Some(icon));
|
||||
ui.locations.status.set_title(title);
|
||||
ui.locations.status.set_description(Some(description));
|
||||
ui.locations
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.locations.retry.set_visible(retry);
|
||||
ui.locations.content.set_visible_child_name("status");
|
||||
}
|
||||
@@ -1463,8 +1506,13 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
||||
} else {
|
||||
gettext("Pause Syncing")
|
||||
};
|
||||
let ui_pause = ui.clone();
|
||||
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
|
||||
let ui = ui.clone();
|
||||
menu.item(&label, move || set_folder_paused(&ui, id, !paused));
|
||||
let path = spec.local_path.clone();
|
||||
menu.item(&gettext("Sync From Scratch…"), move || {
|
||||
confirm_reset_sync_folder(&ui, id, &path)
|
||||
});
|
||||
menu.section();
|
||||
}
|
||||
let ui = ui.clone();
|
||||
@@ -1477,6 +1525,51 @@ fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i6
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
|
||||
/// that differ come back as conflict copies, so it is not a click to make by
|
||||
/// accident.
|
||||
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Sync From Scratch?"))
|
||||
// Translators: {path} is a local folder path.
|
||||
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("reset", &gettext("Sync From Scratch"));
|
||||
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let win = ui_window(ui);
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "reset" {
|
||||
reset_sync_folder(&ui, id);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Forget a folder's sync records, then repaint so its row shows the new pass.
|
||||
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let failed = gettext("Couldn't sync the folder from scratch");
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&failed,
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
refresh_locations(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask before making a synced folder online only: the switch frees disk space
|
||||
/// by removing the local copies, which is not something to do by accident.
|
||||
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||
@@ -1636,7 +1729,10 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||
parts.push(match &spec.progress {
|
||||
Some(p) => sync_progress_label(p),
|
||||
None if spec.paused => pgettext("state", "paused"),
|
||||
None => sync_state_label(&spec.state).to_string(),
|
||||
None => sync_state_label(
|
||||
&spec.state,
|
||||
spec.mode == MountMode::Mirror && spec.last_sync == 0,
|
||||
),
|
||||
});
|
||||
}
|
||||
// Only the locations that are supposed to have a session report its absence:
|
||||
@@ -1682,7 +1778,25 @@ mod tests {
|
||||
MountMode::Mirror,
|
||||
false,
|
||||
);
|
||||
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
|
||||
}
|
||||
|
||||
/// A folder whose first pass has not run is not up to date, though its
|
||||
/// state column starts out `idle` (`docs/BUGS.md` B261).
|
||||
#[test]
|
||||
fn a_folder_reads_up_to_date_only_after_its_first_sync() {
|
||||
let mut spec = spec(
|
||||
MountKind::Device { sync_folder_id: 7 },
|
||||
MountMode::Mirror,
|
||||
false,
|
||||
);
|
||||
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
|
||||
spec.last_sync = 1_700_000_000;
|
||||
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||
// A folder that failed before it ever synced says so.
|
||||
spec.last_sync = 0;
|
||||
spec.state = "error".into();
|
||||
assert_eq!(location_subtitle(&spec), "Synced · sync error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -9,6 +9,9 @@ pub(crate) struct LoginState {
|
||||
/// Spins inside the Sign in button while a sign-in is running.
|
||||
pub(crate) login_spinner: Spinner,
|
||||
pub(crate) login_status: gtk4::Label,
|
||||
/// Shown over the form while a saved sign-in exists but the keyring would
|
||||
/// not hand it over; see [`keyring_unreadable`].
|
||||
pub(crate) keyring_banner: adw::Banner,
|
||||
}
|
||||
|
||||
/// Where the login page's account links go.
|
||||
@@ -87,6 +90,12 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
.child(&inner)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build();
|
||||
let keyring_banner = adw::Banner::builder()
|
||||
.title(gettext(
|
||||
"Couldn't read your saved sign-in. Unlock the system keyring, then try again.",
|
||||
))
|
||||
.button_label(gettext("Retry"))
|
||||
.build();
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
@@ -96,10 +105,80 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
login_button,
|
||||
login_spinner,
|
||||
login_status,
|
||||
keyring_banner,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The stored session, `None` when there is none, or the keyring's error when
|
||||
/// it could not be read. A locked or absent keyring is not a sign-out: the
|
||||
/// session is still saved, and signing in again would only replace it.
|
||||
pub(crate) fn read_session() -> pdfs_core::Result<Option<auth::StoredSession>> {
|
||||
match auth::load() {
|
||||
Ok(session) => Ok(Some(session)),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait before each automatic retry of an unreadable keyring. A
|
||||
/// keyring daemon started with the session is usually up within seconds.
|
||||
const KEYRING_RETRIES: [u32; 4] = [2, 5, 10, 30];
|
||||
|
||||
/// The keyring refused to hand over the saved session. Keep the login page up,
|
||||
/// but say why, and pick the session up as soon as the keyring answers: on its
|
||||
/// own a few times, and whenever the user presses Retry.
|
||||
pub(crate) fn keyring_unreadable(ui: &Rc<Ui>, error: &pdfs_core::Error) {
|
||||
tracing::warn!("cannot read the saved session: {error}");
|
||||
let banner = &ui.login.keyring_banner;
|
||||
if banner.is_revealed() {
|
||||
return;
|
||||
}
|
||||
banner.set_revealed(true);
|
||||
let ui_retry = ui.clone();
|
||||
let handler = banner.connect_button_clicked(move |_| retry_session(&ui_retry));
|
||||
// Disconnected once the banner goes, so a later failure wires it afresh.
|
||||
let handler = RefCell::new(Some(handler));
|
||||
banner.connect_revealed_notify(move |banner| {
|
||||
if !banner.is_revealed()
|
||||
&& let Some(handler) = handler.borrow_mut().take()
|
||||
{
|
||||
banner.disconnect(handler);
|
||||
}
|
||||
});
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
for seconds in KEYRING_RETRIES {
|
||||
glib::timeout_future_seconds(seconds).await;
|
||||
if !ui.login.keyring_banner.is_revealed() {
|
||||
return;
|
||||
}
|
||||
retry_session(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Read the saved session again after [`keyring_unreadable`]. Found: show the
|
||||
/// signed-in window. Gone: the plain login page. Still unreadable: keep waiting.
|
||||
fn retry_session(ui: &Rc<Ui>) {
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let Ok(result) = gio::spawn_blocking(read_session).await else {
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
Ok(session) => {
|
||||
ui.login.keyring_banner.set_revealed(false);
|
||||
if session.is_some() && ui.session.borrow().is_none() {
|
||||
*ui.session.borrow_mut() = session;
|
||||
refresh(&ui);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::debug!("the keyring still cannot be read: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
||||
/// thread, and report the outcome back on the main loop.
|
||||
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
@@ -150,26 +229,24 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does. A
|
||||
// window that never came up is told apart from one the user closed, so
|
||||
// the result below can say why the sign-in stopped.
|
||||
// sign-in, and the worker is blocked on the credential until it does.
|
||||
let ui_hv = ui.clone();
|
||||
let verification_failed = Rc::new(Cell::new(false));
|
||||
let hv_failed = verification_failed.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
// Anything but a token drops the sender, which the worker reads
|
||||
// as a cancelled sign-in.
|
||||
match verify_human(url).await {
|
||||
// Anything but a credential drops the sender, which the worker
|
||||
// reads as a cancelled sign-in.
|
||||
match verify_human(hv.verification_url()).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = token_tx.send(token);
|
||||
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
Verification::Failed => hv_failed.set(true),
|
||||
// The web view could not start, most likely its sandbox
|
||||
// (B195): the user's browser can show the page instead.
|
||||
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -201,14 +278,18 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
ui.login.login_status.set_text("");
|
||||
ui.login.password.set_text("");
|
||||
// Cache the new identity so `refresh` never hits the keyring.
|
||||
*ui.session.borrow_mut() = auth::load().ok();
|
||||
// Read on a worker: a keyring that has to be unlocked first
|
||||
// answers only once the user has, and the window must not
|
||||
// freeze until then.
|
||||
match gio::spawn_blocking(read_session).await {
|
||||
Ok(Ok(session)) => *ui.session.borrow_mut() = session,
|
||||
Ok(Err(e)) => keyring_unreadable(&ui, &e),
|
||||
Err(_) => {}
|
||||
}
|
||||
// Enable+start the mount service now that we have a session.
|
||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||
)),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
@@ -232,6 +313,11 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
||||
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
|
||||
}
|
||||
// Proton's text for this one asks the user to update an app that is
|
||||
// already current.
|
||||
pdfs_core::Error::Proton(e) if pdfs_core::error::sign_in_blocked(e) => gettext(
|
||||
"Proton is temporarily refusing sign-in from this device. Your app version is not the problem. Wait several hours, up to a day, without trying again, then sign in.",
|
||||
),
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||
api.message.clone()
|
||||
}
|
||||
@@ -267,9 +353,12 @@ pub(crate) struct LoginChannels {
|
||||
pub(crate) result: async_channel::Receiver<Result<(), String>>,
|
||||
/// Fires only if the account needs a 2FA code; carries the sender for it.
|
||||
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||
/// Fires only if the sign-in is gated: the page to show, and the sender for
|
||||
/// the token the user earns on it.
|
||||
pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
||||
/// Fires only if the sign-in is gated: the challenge, and the sender for the
|
||||
/// credential the user earns by solving it.
|
||||
pub(crate) hv: async_channel::Receiver<(
|
||||
HumanVerification,
|
||||
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
)>,
|
||||
/// Fires only for a two-password account: how many answers were already
|
||||
/// wrong, and the sender for the password and the "remember" choice.
|
||||
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
|
||||
@@ -327,18 +416,16 @@ pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||
})
|
||||
},
|
||||
|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
|
||||
// Gated: hand the UI the challenge, block on the credential
|
||||
// the user earns by solving it.
|
||||
let (credential_tx, credential_rx) =
|
||||
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
credential_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -525,6 +612,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Let the user solve the challenge in their own browser, for when the
|
||||
/// verification window could not come up. The page cannot hand its token back
|
||||
/// to us, so the user says when they are done, and the retry carries the
|
||||
/// challenge token, which the solved page made good (see
|
||||
/// [`auth::browser_verified`]).
|
||||
fn verify_in_browser(
|
||||
ui: &Rc<Ui>,
|
||||
hv: &HumanVerification,
|
||||
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Verify in Your Browser"))
|
||||
.body(gettext(
|
||||
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let url = auth::browser_verification_url(hv);
|
||||
let open = gtk4::Button::builder()
|
||||
.label(gettext("Open Verification Page"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("pill");
|
||||
dialog.set_extra_child(Some(&open));
|
||||
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("continue", &gettext("Continue"));
|
||||
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
|
||||
// Continuing before the page was even opened would only fail the retry.
|
||||
dialog.set_response_enabled("continue", false);
|
||||
dialog.set_default_response(Some("continue"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let dialog_weak = dialog.downgrade();
|
||||
open.connect_clicked(move |_| {
|
||||
open_uri(&url);
|
||||
if let Some(dialog) = dialog_weak.upgrade() {
|
||||
dialog.set_response_enabled("continue", true);
|
||||
}
|
||||
});
|
||||
|
||||
let credential = auth::browser_verified(hv);
|
||||
let credential_tx = RefCell::new(Some(credential_tx));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||
// recv errors out and the login is reported as cancelled.
|
||||
if let Some(tx) = credential_tx.borrow_mut().take()
|
||||
&& resp == "continue"
|
||||
{
|
||||
let _ = tx.send(credential.clone());
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
@@ -544,7 +688,7 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(|| {
|
||||
service::disable_stop();
|
||||
auth::logout().map_err(|e| e.to_string())
|
||||
auth::logout().map(|_| ()).map_err(|e| e.to_string())
|
||||
})
|
||||
.await;
|
||||
if let Ok(Err(e)) = result {
|
||||
@@ -575,6 +719,21 @@ mod tests {
|
||||
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_sign_in_does_not_ask_for_an_update() {
|
||||
use pdfs_core::proton_sdk::api::ResponseCode;
|
||||
use pdfs_core::proton_sdk::{ProtonApiError, ProtonError};
|
||||
let error = pdfs_core::Error::Proton(ProtonError::Api(ProtonApiError {
|
||||
code: ResponseCode::Blocked,
|
||||
http_status: 422,
|
||||
message: "This version of the app is no longer supported, please update to continue using the app".into(),
|
||||
details: None,
|
||||
}));
|
||||
let message = login_error_message(&error);
|
||||
assert!(message.contains("temporarily refusing"), "{message}");
|
||||
assert!(!message.contains("update"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
let error = pdfs_core::Error::Other("something odd".into());
|
||||
|
||||
@@ -275,14 +275,42 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
viewer.status.set_label(message);
|
||||
viewer.status.set_visible(true);
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(Response::FilePath { path })) if is_video => {
|
||||
let path = match result {
|
||||
Ok(Ok(Response::FilePath { path })) => path,
|
||||
Ok(Ok(Response::Error { message, .. })) => return fail(&message),
|
||||
Ok(Ok(_)) => return fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
||||
Ok(Err(_)) | Err(_) => return fail(&gettext("Couldn't reach Proton Drive.")),
|
||||
};
|
||||
// Decoding a full-size photo and reading its EXIF take a good part of
|
||||
// a second for a large one, which froze the whole app on the main
|
||||
// thread. Done on a worker; a reply for a photo the user has since
|
||||
// left is dropped like the request's own.
|
||||
let loaded = {
|
||||
let path = path.clone();
|
||||
gio::spawn_blocking(move || {
|
||||
let size = std::fs::metadata(&path).ok().map(|meta| meta.len());
|
||||
if is_video {
|
||||
return (size, None, ExifInfo::default());
|
||||
}
|
||||
let texture = gtk4::gdk::Texture::from_filename(&path);
|
||||
(size, Some(texture), read_exif(&path))
|
||||
})
|
||||
.await
|
||||
};
|
||||
if *viewer.uid.borrow() != uid {
|
||||
return;
|
||||
}
|
||||
let Ok((size, texture, info)) = loaded else {
|
||||
return fail(&gettext("Couldn't render this photo."));
|
||||
};
|
||||
match texture {
|
||||
None => {
|
||||
viewer.video.set_filename(Some(&path));
|
||||
viewer.media.set_visible_child_name("video");
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, &path, ExifInfo::default());
|
||||
show_info(&viewer, size, ExifInfo::default());
|
||||
}
|
||||
Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) {
|
||||
Some(texture) => match texture {
|
||||
Ok(texture) => {
|
||||
// Zoomed in on the thumbnail: keep the photo the size it is
|
||||
// on screen, now drawn from the full-size pixels.
|
||||
@@ -297,16 +325,13 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
|
||||
zoom_to(&viewer, Some(zoom), None);
|
||||
}
|
||||
*viewer.path.borrow_mut() = Some(path.clone());
|
||||
show_info(&viewer, &path, read_exif(&path));
|
||||
show_info(&viewer, size, info);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to load texture for {path}: {e}");
|
||||
fail(&gettext("Couldn't render this photo."));
|
||||
}
|
||||
},
|
||||
Ok(Ok(Response::Error { message, .. })) => fail(&message),
|
||||
Ok(Ok(_)) => fail(&gettext("Unexpected reply from the Proton Drive service.")),
|
||||
Ok(Err(_)) | Err(_) => fail(&gettext("Couldn't reach Proton Drive.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -452,16 +477,16 @@ pub(crate) fn clear_info(viewer: &Rc<Viewer>) {
|
||||
}
|
||||
|
||||
/// Fill the details drawer for the photo now on screen: its own file facts
|
||||
/// (size, dimensions) plus whatever EXIF it carries.
|
||||
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
||||
/// (`size`, dimensions) plus whatever EXIF it carries.
|
||||
pub(crate) fn show_info(viewer: &Rc<Viewer>, size: Option<u64>, info: ExifInfo) {
|
||||
while let Some(row) = viewer.info_rows.first_child() {
|
||||
viewer.info_rows.remove(&row);
|
||||
}
|
||||
|
||||
let mut fields: Vec<(String, String)> = Vec::new();
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
if let Some(size) = size {
|
||||
// Translators: label of a photo's file size in the details panel.
|
||||
fields.push((pgettext("property", "Size"), human_bytes(meta.len())));
|
||||
fields.push((pgettext("property", "Size"), human_bytes(size)));
|
||||
}
|
||||
fields.extend(info.fields);
|
||||
|
||||
@@ -481,6 +506,7 @@ pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(label.as_str())
|
||||
.subtitle(value)
|
||||
.use_markup(false)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
@@ -1259,8 +1285,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
// Any real pointer movement brings the controls back; resting over one of
|
||||
// them keeps them up.
|
||||
let motion = gtk4::EventControllerMotion::new();
|
||||
let viewer_motion = viewer.clone();
|
||||
let viewer_motion = Rc::downgrade(&viewer);
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
let Some(viewer_motion) = viewer_motion.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let (last_x, last_y) = viewer_motion.pointer.get();
|
||||
viewer_motion.pointer.set((x, y));
|
||||
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
|
||||
@@ -1270,10 +1299,17 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
body.add_controller(motion);
|
||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||
let hover = gtk4::EventControllerMotion::new();
|
||||
let viewer_enter = viewer.clone();
|
||||
hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true));
|
||||
let viewer_leave = viewer.clone();
|
||||
let viewer_enter = Rc::downgrade(&viewer);
|
||||
hover.connect_enter(move |_, _, _| {
|
||||
if let Some(viewer) = viewer_enter.upgrade() {
|
||||
viewer.chrome_hover.set(true);
|
||||
}
|
||||
});
|
||||
let viewer_leave = Rc::downgrade(&viewer);
|
||||
hover.connect_leave(move |_| {
|
||||
let Some(viewer_leave) = viewer_leave.upgrade() else {
|
||||
return;
|
||||
};
|
||||
viewer_leave.chrome_hover.set(false);
|
||||
show_chrome(&viewer_leave);
|
||||
});
|
||||
@@ -1283,8 +1319,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
|
||||
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||
let viewer_wheel = viewer.clone();
|
||||
let viewer_wheel = Rc::downgrade(&viewer);
|
||||
wheel.connect_scroll(move |controller, _, dy| {
|
||||
let Some(viewer_wheel) = viewer_wheel.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
if !controller
|
||||
.current_event_state()
|
||||
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
||||
@@ -1304,14 +1343,20 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
scroller.add_controller(wheel);
|
||||
|
||||
let pinch = gtk4::GestureZoom::new();
|
||||
let viewer_pinch = viewer.clone();
|
||||
let viewer_pinch = Rc::downgrade(&viewer);
|
||||
pinch.connect_begin(move |_, _| {
|
||||
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||
return;
|
||||
};
|
||||
viewer_pinch
|
||||
.pinch_start
|
||||
.set(current_scale(&viewer_pinch).unwrap_or(1.0));
|
||||
});
|
||||
let viewer_pinch = viewer.clone();
|
||||
let viewer_pinch = Rc::downgrade(&viewer);
|
||||
pinch.connect_scale_changed(move |gesture, delta| {
|
||||
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let start = viewer_pinch.pinch_start.get();
|
||||
zoom_to(
|
||||
&viewer_pinch,
|
||||
@@ -1323,8 +1368,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
// Dragging a zoomed photo pans it.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let viewer_drag = viewer.clone();
|
||||
let viewer_drag = Rc::downgrade(&viewer);
|
||||
drag.connect_drag_begin(move |_, _, _| {
|
||||
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let scroller = &viewer_drag.scroller;
|
||||
viewer_drag.drag_start.set((
|
||||
scroller.hadjustment().value(),
|
||||
@@ -1334,8 +1382,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
viewer_drag.picture.set_cursor_from_name(Some("grabbing"));
|
||||
}
|
||||
});
|
||||
let viewer_drag = viewer.clone();
|
||||
let viewer_drag = Rc::downgrade(&viewer);
|
||||
drag.connect_drag_update(move |_, dx, dy| {
|
||||
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if viewer_drag.zoom.get().is_none() {
|
||||
return;
|
||||
}
|
||||
@@ -1343,39 +1394,58 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
viewer_drag.scroller.hadjustment().set_value(x - dx);
|
||||
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
||||
});
|
||||
let viewer_drag = viewer.clone();
|
||||
let viewer_drag = Rc::downgrade(&viewer);
|
||||
drag.connect_drag_end(move |_, _, _| {
|
||||
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if viewer_drag.zoom.get().is_some() {
|
||||
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
||||
}
|
||||
});
|
||||
scroller.add_controller(drag);
|
||||
|
||||
let w_close = window.clone();
|
||||
let w_close = window.downgrade();
|
||||
close_btn.connect_clicked(move |_| {
|
||||
let Some(w_close) = w_close.upgrade() else {
|
||||
return;
|
||||
};
|
||||
w_close.close();
|
||||
});
|
||||
|
||||
let viewer_closing = viewer.clone();
|
||||
// The one strong handle on the viewer. Every handler holds a weak one: a
|
||||
// strong one in a handler of a widget the viewer holds is a cycle, which
|
||||
// kept every closed lightbox, its window and its full-size photo in memory
|
||||
// for the life of the app.
|
||||
let keep = RefCell::new(Some(viewer.clone()));
|
||||
window.connect_close_request(move |_| {
|
||||
stop_video(&viewer_closing);
|
||||
disarm_slide(&viewer_closing);
|
||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
if let Some(viewer) = keep.borrow_mut().take() {
|
||||
stop_video(&viewer);
|
||||
disarm_slide(&viewer);
|
||||
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||
}
|
||||
glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
let viewer_info = viewer.clone();
|
||||
let viewer_info = Rc::downgrade(&viewer);
|
||||
info_toggle.connect_toggled(move |toggle| {
|
||||
let Some(viewer_info) = viewer_info.upgrade() else {
|
||||
return;
|
||||
};
|
||||
viewer_info
|
||||
.info_revealer
|
||||
.set_reveal_child(toggle.is_active());
|
||||
});
|
||||
|
||||
let ui_slides = ui.clone();
|
||||
let viewer_slides = viewer.clone();
|
||||
let viewer_slides = Rc::downgrade(&viewer);
|
||||
slideshow_btn.connect_toggled(move |toggle| {
|
||||
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if toggle.is_active() {
|
||||
start_slideshow(&ui_slides, &viewer_slides);
|
||||
} else {
|
||||
@@ -1387,9 +1457,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
|
||||
let ui_map = ui.clone();
|
||||
let viewer_map = viewer.clone();
|
||||
let w_map = window.clone();
|
||||
let viewer_map = Rc::downgrade(&viewer);
|
||||
let w_map = window.downgrade();
|
||||
info_map.connect_clicked(move |_| {
|
||||
let Some(w_map) = w_map.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_map) = viewer_map.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let coords = *viewer_map.coords.borrow();
|
||||
if let Some((lat, lon)) = coords {
|
||||
w_map.close();
|
||||
@@ -1398,8 +1474,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
});
|
||||
|
||||
let ui_fav = ui.clone();
|
||||
let viewer_fav = viewer.clone();
|
||||
let viewer_fav = Rc::downgrade(&viewer);
|
||||
favorite_btn.connect_toggled(move |btn| {
|
||||
let Some(viewer_fav) = viewer_fav.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if viewer_fav.favorite_suppress.get() {
|
||||
return;
|
||||
}
|
||||
@@ -1443,17 +1522,26 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
// gone, and leaving it open on a trashed photo invites a second Delete on
|
||||
// something the user cannot see.
|
||||
let ui_delete = ui.clone();
|
||||
let viewer_delete = viewer.clone();
|
||||
let w_delete = window.clone();
|
||||
let viewer_delete = Rc::downgrade(&viewer);
|
||||
let w_delete = window.downgrade();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let Some(w_delete) = w_delete.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_delete) = viewer_delete.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let uid = viewer_delete.uid.borrow().clone();
|
||||
w_delete.close();
|
||||
trash_photos(&ui_delete, vec![uid], false);
|
||||
});
|
||||
|
||||
let ui_group = ui.clone();
|
||||
let viewer_group = viewer.clone();
|
||||
let viewer_group = Rc::downgrade(&viewer);
|
||||
group_btn.connect_clicked(move |_| {
|
||||
let Some(viewer_group) = viewer_group.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let uid = viewer_group.uid.borrow().clone();
|
||||
let next =
|
||||
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
|
||||
@@ -1464,9 +1552,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
});
|
||||
|
||||
let ui_download = ui.clone();
|
||||
let w_download = window.clone();
|
||||
let viewer_download = viewer.clone();
|
||||
let w_download = window.downgrade();
|
||||
let viewer_download = Rc::downgrade(&viewer);
|
||||
download_btn.connect_clicked(move |_| {
|
||||
let Some(w_download) = w_download.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_download) = viewer_download.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||
let name = viewer_download.name.borrow();
|
||||
save_photo_to_disk(
|
||||
@@ -1478,23 +1572,35 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
}
|
||||
});
|
||||
|
||||
let w_ext = window.clone();
|
||||
let viewer_ext = viewer.clone();
|
||||
let w_ext = window.downgrade();
|
||||
let viewer_ext = Rc::downgrade(&viewer);
|
||||
open_ext_btn.connect_clicked(move |_| {
|
||||
let Some(w_ext) = w_ext.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_ext) = viewer_ext.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||
}
|
||||
});
|
||||
|
||||
let ui_prev = ui.clone();
|
||||
let viewer_prev = viewer.clone();
|
||||
let viewer_prev = Rc::downgrade(&viewer);
|
||||
prev_btn.connect_clicked(move |_| {
|
||||
let Some(viewer_prev) = viewer_prev.upgrade() else {
|
||||
return;
|
||||
};
|
||||
navigate_photo(&ui_prev, &viewer_prev, -1);
|
||||
});
|
||||
|
||||
let ui_next = ui.clone();
|
||||
let viewer_next = viewer.clone();
|
||||
let viewer_next = Rc::downgrade(&viewer);
|
||||
next_btn.connect_clicked(move |_| {
|
||||
let Some(viewer_next) = viewer_next.upgrade() else {
|
||||
return;
|
||||
};
|
||||
navigate_photo(&ui_next, &viewer_next, 1);
|
||||
});
|
||||
|
||||
@@ -1503,8 +1609,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
// reaching for the image doesn't fling the window shut; a zoomed photo has
|
||||
// no backdrop to speak of. Double-click toggles between fitted and 100 %.
|
||||
let backdrop = gtk4::GestureClick::new();
|
||||
let viewer_click = viewer.clone();
|
||||
let viewer_click = Rc::downgrade(&viewer);
|
||||
backdrop.connect_pressed(move |_, n_press, x, y| {
|
||||
let Some(viewer_click) = viewer_click.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if n_press != 2 {
|
||||
return;
|
||||
}
|
||||
@@ -1521,9 +1630,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
|
||||
}
|
||||
});
|
||||
let viewer_click = viewer.clone();
|
||||
let w_click = window.clone();
|
||||
let viewer_click = Rc::downgrade(&viewer);
|
||||
let w_click = window.downgrade();
|
||||
backdrop.connect_released(move |_, n_press, x, y| {
|
||||
let Some(w_click) = w_click.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(viewer_click) = viewer_click.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if n_press == 1
|
||||
&& viewer_click.zoom.get().is_none()
|
||||
&& !over_photo(&viewer_click.picture, x, y)
|
||||
@@ -1535,9 +1650,15 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
|
||||
let key_controller = gtk4::EventControllerKey::new();
|
||||
let ui_key = ui.clone();
|
||||
let viewer_key = viewer.clone();
|
||||
let w_key = window.clone();
|
||||
let viewer_key = Rc::downgrade(&viewer);
|
||||
let w_key = window.downgrade();
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
||||
let Some(w_key) = w_key.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let Some(viewer_key) = viewer_key.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||
match key.name().as_deref() {
|
||||
|
||||
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
|
||||
pub(crate) loading: Cell<bool>,
|
||||
/// Whether the last page came back full, so there may be more to load.
|
||||
pub(crate) has_more: Cell<bool>,
|
||||
/// Whether the account has a photo library. Until the daemon says it has
|
||||
/// none, assume it does, so Upload is not greyed out while the page loads.
|
||||
pub(crate) library: Cell<bool>,
|
||||
/// Run once the page in flight has landed in [`Self::model`] — how the
|
||||
/// lightbox steps past the last loaded photo.
|
||||
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
||||
@@ -606,7 +609,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
select_box.append(&select_done);
|
||||
let select_bar = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||
.child(&select_box)
|
||||
.child(&scroll_sideways(&select_box))
|
||||
.build();
|
||||
|
||||
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
||||
@@ -1376,11 +1379,9 @@ pub(crate) fn wire_gallery(
|
||||
"tiff" | "tif" => "image/tiff",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
let capture_time = std::fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
// The daemon dates the photo: by its EXIF, or by the file's
|
||||
// modification time when it has none.
|
||||
let capture_time = None;
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
@@ -3341,6 +3342,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
items,
|
||||
counts,
|
||||
})) => {
|
||||
// Nothing can go into a library the account does not have; the
|
||||
// SDK refuses the upload before sending it (issue #38).
|
||||
ui.gallery.library.set(available);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(available && *ui.mounted.borrow());
|
||||
if !available {
|
||||
gallery_status(
|
||||
&ui,
|
||||
@@ -3450,7 +3457,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
||||
pub(crate) fn gallery_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.gallery.status.set_icon_name(Some(icon));
|
||||
ui.gallery.status.set_title(title);
|
||||
ui.gallery.status.set_description(Some(description));
|
||||
ui.gallery
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.gallery.retry.set_visible(retry);
|
||||
// Only the empty timeline offers a way to fill it; every other status turns
|
||||
// those buttons back off.
|
||||
@@ -3470,10 +3479,10 @@ pub(crate) fn gallery_unreachable(ui: &Rc<Ui>) {
|
||||
/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the
|
||||
/// user's default handler when mpv isn't installed.
|
||||
pub(crate) fn play_external(path: &str) {
|
||||
if Command::new("mpv").arg(path).spawn().is_ok() {
|
||||
return;
|
||||
match Command::new("mpv").arg(path).spawn() {
|
||||
Ok(child) => pdfs_core::opener::reap(child),
|
||||
Err(_) => open_path(path),
|
||||
}
|
||||
open_path(path);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -448,7 +448,9 @@ pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||
ui.gallery.places_status.set_title(title);
|
||||
ui.gallery.places_status.set_description(Some(description));
|
||||
ui.gallery
|
||||
.places_status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.gallery.places_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
|
||||
@@ -311,7 +311,9 @@ pub(crate) fn wire_shared(ui: &Rc<Ui>, widgets: &SharedWidgets) {
|
||||
pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.shared.status.set_icon_name(Some(icon));
|
||||
ui.shared.status.set_title(title);
|
||||
ui.shared.status.set_description(Some(description));
|
||||
ui.shared
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.shared.retry.set_visible(retry);
|
||||
ui.shared.content.set_visible_child_name("status");
|
||||
}
|
||||
@@ -484,9 +486,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
nav.is_empty(),
|
||||
));
|
||||
for (i, (_, name)) in nav.iter().enumerate() {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
crumb.append(&sep);
|
||||
crumb.append(&crumb_separator());
|
||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
||||
}
|
||||
}
|
||||
@@ -495,12 +495,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
||||
/// that goes back up to `depth` folders below the top level.
|
||||
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||
if current {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
return l.upcast();
|
||||
return crumb_current(label);
|
||||
}
|
||||
let button = gtk4::Button::builder().label(label).build();
|
||||
button.add_css_class("flat");
|
||||
@@ -825,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow {
|
||||
.title(&item)
|
||||
// Translators: {email} is the address of the person who sent the invitation.
|
||||
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
||||
.use_markup(false)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
@@ -869,6 +865,7 @@ fn repaint_links(ui: &Rc<Ui>, bookmarks: &[BookmarkInfo]) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.use_markup(false)
|
||||
.subtitle_lines(1)
|
||||
.activatable(true)
|
||||
.tooltip_text(gettext("Open in browser"))
|
||||
|
||||
@@ -329,7 +329,9 @@ pub(crate) fn shared_by_me_status(
|
||||
) {
|
||||
ui.shared_by_me.status.set_icon_name(Some(icon));
|
||||
ui.shared_by_me.status.set_title(title);
|
||||
ui.shared_by_me.status.set_description(Some(description));
|
||||
ui.shared_by_me
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.shared_by_me.retry.set_visible(retry);
|
||||
ui.shared_by_me.content.set_visible_child_name("status");
|
||||
ui.shared_by_me.subtitle.set_subtitle("");
|
||||
@@ -565,6 +567,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
let done = done.clone();
|
||||
let stopped = uids.clone();
|
||||
run_requests(
|
||||
&ui_c,
|
||||
uids.iter()
|
||||
@@ -572,6 +575,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
.collect(),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
move |ui| {
|
||||
forget_shared_by_me(ui, &stopped);
|
||||
reload_listing(ui);
|
||||
toast(ui, &done);
|
||||
},
|
||||
@@ -580,6 +584,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Take items whose sharing just stopped off the page now, rather than when
|
||||
/// the reload that confirms it comes back: that reload asks after every share
|
||||
/// in turn and takes a while with many of them.
|
||||
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
|
||||
let items = {
|
||||
let mut items = ui.shared_by_me.items.borrow_mut();
|
||||
items.retain(|item| !uids.contains(&item.uid));
|
||||
items.clone()
|
||||
};
|
||||
repaint_shared_by_me(ui, &items);
|
||||
}
|
||||
|
||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
@@ -608,6 +624,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
size: 0,
|
||||
modified: item.modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
|
||||
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
/// "Check for updates" toggle, not shown where a package manager brings
|
||||
/// updates. Guarded by [`Self::settings_suppress`].
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||
/// [`Self::settings_suppress`].
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
|
||||
pub(crate) theme_row: adw::SwitchRow,
|
||||
pub(crate) online_map_row: adw::SwitchRow,
|
||||
pub(crate) tray_row: adw::SwitchRow,
|
||||
pub(crate) update_row: adw::SwitchRow,
|
||||
pub(crate) language_row: adw::ComboRow,
|
||||
}
|
||||
|
||||
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
let language_row = build_language_row();
|
||||
appearance_group.add(&language_row);
|
||||
|
||||
let updates_group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Updates"))
|
||||
.build();
|
||||
let update_row = adw::SwitchRow::builder()
|
||||
.title(gettext("Check for updates"))
|
||||
.subtitle(gettext(
|
||||
"Ask GitHub once a day whether a new version is out. GitHub learns your IP address and which version you use.",
|
||||
))
|
||||
.build();
|
||||
updates_group.add(&update_row);
|
||||
|
||||
let general = adw::PreferencesPage::builder()
|
||||
.title(gettext("General"))
|
||||
.icon_name("preferences-system-symbolic")
|
||||
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
general.add(&location_group);
|
||||
general.add(&network_group);
|
||||
general.add(&appearance_group);
|
||||
// A package manager brings new versions, so there is nothing to tell.
|
||||
if !updates_managed_here() {
|
||||
general.add(&updates_group);
|
||||
}
|
||||
|
||||
// ---- Preferences: Storage
|
||||
let storage_group = adw::PreferencesGroup::builder()
|
||||
@@ -381,23 +400,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.build();
|
||||
// The app's one menu, on the account row the way Proton's own apps keep it:
|
||||
// app-wide entries first, the account's last. The whole row opens it, and
|
||||
// F10 too, as the primary menu.
|
||||
let app_section = gio::Menu::new();
|
||||
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
|
||||
app_section.append(
|
||||
Some(&gettext("About Proton Drive for Linux")),
|
||||
Some("win.about"),
|
||||
);
|
||||
let account_section = gio::Menu::new();
|
||||
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
let account_menu = gio::Menu::new();
|
||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
||||
let sign_out = gio::Menu::new();
|
||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
||||
account_menu.append_section(None, &sign_out);
|
||||
let account_button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
.tooltip_text(gettext("Account"))
|
||||
account_menu.append_section(None, &app_section);
|
||||
account_menu.append_section(None, &account_section);
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
row.append(&avatar);
|
||||
row.append(&account_name);
|
||||
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||
let account = gtk4::MenuButton::builder()
|
||||
.child(&row)
|
||||
.tooltip_text(gettext("Main menu"))
|
||||
.menu_model(&account_menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.direction(gtk4::ArrowType::Up)
|
||||
.primary(true)
|
||||
.build();
|
||||
account_button.add_css_class("flat");
|
||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||
account.add_css_class("flat");
|
||||
account.add_css_class("sidebar-account");
|
||||
account.append(&avatar);
|
||||
account.append(&account_name);
|
||||
account.append(&account_button);
|
||||
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||
footer.add_css_class("sidebar-footer");
|
||||
@@ -431,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
||||
theme_row,
|
||||
online_map_row,
|
||||
tray_row,
|
||||
update_row,
|
||||
language_row,
|
||||
}
|
||||
}
|
||||
@@ -530,6 +561,7 @@ pub(crate) fn wire_settings(
|
||||
.set_active(config.proton_theme.unwrap_or(false));
|
||||
ui.status.online_map_row.set_active(config.online_map);
|
||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||
ui.status.update_row.set_active(config.update_check);
|
||||
ui.status
|
||||
.language_row
|
||||
.set_selected(language_index(config.language.as_deref()));
|
||||
@@ -679,6 +711,26 @@ pub(crate) fn wire_settings(
|
||||
}
|
||||
});
|
||||
|
||||
// Update check: saved, and the first check runs right away if one is due.
|
||||
let ui_update = ui.clone();
|
||||
ui.status.update_row.connect_active_notify(move |row| {
|
||||
if ui_update.status.settings_suppress.get() {
|
||||
return;
|
||||
}
|
||||
let mut config = ui_update.dirs.load_config();
|
||||
config.update_check = row.is_active();
|
||||
if let Err(e) = ui_update.dirs.save_config(&config) {
|
||||
toast_error(
|
||||
&ui_update,
|
||||
&gettext("Couldn't save the update setting"),
|
||||
&e.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
show_latest(&ui_update);
|
||||
start_update_checks(&ui_update);
|
||||
});
|
||||
|
||||
// Tray icon: the tray reads the same flag at start, so hiding it here also
|
||||
// keeps the login autostart from bringing it back.
|
||||
let ui_tray = ui.clone();
|
||||
@@ -825,6 +877,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
||||
}
|
||||
|
||||
/// Show the sign-in page while a session is still stored, because Proton has
|
||||
/// refused it. The sidebar stays, so the user can go back to cached files; a
|
||||
/// login moves on to the files as from any sign-in ([`refresh`]).
|
||||
pub(crate) fn show_login(ui: &Rc<Ui>) {
|
||||
ui.stack.set_visible_child_name("login");
|
||||
}
|
||||
|
||||
/// Repaint the window from the cached login identity, then ask the daemon for
|
||||
/// its status and transfers. Runs at startup, after a login and after a service
|
||||
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||
@@ -842,7 +901,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
}
|
||||
ui.nav.set_collapsed(false);
|
||||
ui.nav.set_collapsed(ui.narrow.get());
|
||||
if ui.status.account_name.label() != s.username {
|
||||
ui.status.account_name.set_label(&s.username);
|
||||
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
||||
@@ -940,7 +999,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||
*ui.mounted.borrow_mut() = mounted;
|
||||
sync_mounted_actions(ui, mounted);
|
||||
ui.gallery.upload.set_sensitive(mounted);
|
||||
ui.gallery
|
||||
.upload
|
||||
.set_sensitive(mounted && ui.gallery.library.get());
|
||||
ui.details.details.pin_row.set_sensitive(mounted);
|
||||
ui.details.details.rename_button.set_sensitive(mounted);
|
||||
ui.details.details.trash_button.set_sensitive(mounted);
|
||||
@@ -1210,6 +1271,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
session_expired,
|
||||
tokens_unsaved,
|
||||
version,
|
||||
..
|
||||
} = status
|
||||
else {
|
||||
@@ -1220,7 +1284,13 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let state = if paused {
|
||||
let state = if session_expired {
|
||||
SyncState::SessionExpired { queued }
|
||||
} else if tokens_unsaved {
|
||||
SyncState::TokensUnsaved
|
||||
} else if pdfs_core::control::daemon_outdated(&version) {
|
||||
SyncState::Outdated { running: version }
|
||||
} else if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
@@ -1283,6 +1353,20 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// Proton refused the session: nothing reaches it until the user signs in
|
||||
/// again. Outranks a pause, which it makes moot.
|
||||
SessionExpired {
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// The keyring refuses the session's refreshed tokens. Sync goes on, but
|
||||
/// a reboot before the keyring takes them signs the user out.
|
||||
TokensUnsaved,
|
||||
/// The daemon is an older release than this window: an upgrade replaced
|
||||
/// the files but left the service running, with the old release's bugs,
|
||||
/// until it restarts (#33). `running` is empty when it is too old to say.
|
||||
Outdated {
|
||||
running: String,
|
||||
},
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
@@ -1317,7 +1401,44 @@ pub(crate) enum SyncState {
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::SessionExpired { queued } => (
|
||||
"dialog-password-symbolic",
|
||||
Some("error"),
|
||||
gettext("Session expired"),
|
||||
Some(match queued {
|
||||
// Translators: {queued} says what is waiting, such as "3 uploads
|
||||
// queued".
|
||||
Some(queued) => {
|
||||
gettext_f("{queued} · sign in again to sync", &[("queued", &queued)])
|
||||
}
|
||||
None => gettext("Sign in again to sync"),
|
||||
}),
|
||||
),
|
||||
SyncState::TokensUnsaved => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Sign-in not saved"),
|
||||
Some(gettext(
|
||||
"Unlock your keyring, or the next reboot signs you out",
|
||||
)),
|
||||
),
|
||||
SyncState::Outdated { running } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Restart to finish updating"),
|
||||
Some(if running.is_empty() {
|
||||
gettext("An older version of Proton Drive is still running")
|
||||
} else {
|
||||
// Translators: {version} is a release number such as "3.2.0".
|
||||
gettext_f(
|
||||
"Proton Drive {version} is still running",
|
||||
&[("version", &running)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
@@ -1388,6 +1509,8 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
if let Some(class) = class {
|
||||
image.add_css_class(class);
|
||||
}
|
||||
ui.locations.card.sign_in.set_visible(expired);
|
||||
ui.locations.card.restart.set_visible(outdated);
|
||||
ui.status.status_title.set_label(&title);
|
||||
ui.status.status_detail.set_visible(detail.is_some());
|
||||
ui.status
|
||||
@@ -1456,7 +1579,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("No files kept offline"))
|
||||
.subtitle(gettext(
|
||||
"Right-click a file and choose “Make available offline”.",
|
||||
"Right-click a file or folder and choose “Available offline”.",
|
||||
))
|
||||
.build();
|
||||
ui.status.pins_group.add(&row);
|
||||
@@ -1484,6 +1607,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&name)
|
||||
.subtitle(&pin.path)
|
||||
.use_markup(false)
|
||||
.build();
|
||||
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
||||
row.add_prefix(&icon);
|
||||
|
||||
@@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
.unwrap_or_else(|| path.display().to_string()),
|
||||
)
|
||||
.subtitle(human_bytes(size))
|
||||
.use_markup(false)
|
||||
.build();
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
@@ -645,7 +646,12 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
// An import runs for hours, so its ending is announced rather than
|
||||
// left to be discovered: a toast for whoever is looking at the
|
||||
// window, and a desktop notification for whoever is not.
|
||||
if was_running {
|
||||
let failed = summary.as_ref().and_then(|s| s.error.clone());
|
||||
if was_running && let Some(error) = &failed {
|
||||
let headline = gettext("Couldn't import from Google Photos");
|
||||
toast_error(&ui, &headline, error);
|
||||
notify("takeout-import", &headline, error);
|
||||
} else if was_running {
|
||||
let dry = ui.takeout.dry_run.get();
|
||||
let headline = if dry {
|
||||
gettext("Scan finished")
|
||||
@@ -701,7 +707,18 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
} else {
|
||||
gettext("Last import")
|
||||
});
|
||||
ui.takeout.summary_group.set_description(Some(&if dry {
|
||||
ui.takeout.summary_group.set_description(Some(&if let Some(error) = &summary.error {
|
||||
// The reason is the service's own text, which is not translated. A
|
||||
// group's description is markup, and the reason may name a path.
|
||||
glib::markup_escape_text(&gettext_f(
|
||||
"{what}: {detail}",
|
||||
&[
|
||||
("what", &gettext("Couldn't import from Google Photos")),
|
||||
("detail", error),
|
||||
],
|
||||
))
|
||||
.to_string()
|
||||
} else if dry {
|
||||
gettext("Nothing was uploaded — this is what an import would do.")
|
||||
} else if summary.cancelled {
|
||||
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||
|
||||
@@ -330,7 +330,9 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
|
||||
ui.trash.status.set_icon_name(Some(icon));
|
||||
ui.trash.status.set_title(title);
|
||||
ui.trash.status.set_description(Some(description));
|
||||
ui.trash
|
||||
.status
|
||||
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
||||
ui.trash.retry.set_visible(retry);
|
||||
ui.trash.content.set_visible_child_name("status");
|
||||
ui.trash.subtitle.set_subtitle("");
|
||||
@@ -524,6 +526,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified,
|
||||
pinned: false,
|
||||
pinned_by_folder: false,
|
||||
cached: false,
|
||||
uid: name.into(),
|
||||
path: String::new(),
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Tells the user when a newer release is out, if they turned on Preferences →
|
||||
//! Updates. The decisions are [`pdfs_core::update`]'s; this asks GitHub and
|
||||
//! shows the banner.
|
||||
|
||||
use crate::*;
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pdfs_core::update::{
|
||||
RELEASES_LATEST_API, is_newer, latest_version, next_check_in, release_url, updates_managed,
|
||||
};
|
||||
|
||||
/// How long one request to GitHub may take.
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long to wait after a failed check, such as one made offline, before
|
||||
/// trying again. Not saved, so a restart tries again straight away.
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
pub(crate) struct UpdateState {
|
||||
/// Over the sidebar, so it shows whichever page is open.
|
||||
pub(crate) banner: adw::Banner,
|
||||
/// The check loop is running. Turning the setting off and on again while it
|
||||
/// sleeps must not start a second one.
|
||||
running: Cell<bool>,
|
||||
/// The release page the banner's button opens.
|
||||
url: RefCell<String>,
|
||||
}
|
||||
|
||||
impl UpdateState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
banner: adw::Banner::builder()
|
||||
.button_label(gettext("Download"))
|
||||
.build(),
|
||||
running: Cell::new(false),
|
||||
url: RefCell::new(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package manager brings new releases to this install. The app then
|
||||
/// never asks, and Preferences does not offer to.
|
||||
pub(crate) fn updates_managed_here() -> bool {
|
||||
std::env::current_exe().is_ok_and(|exe| updates_managed(&exe))
|
||||
}
|
||||
|
||||
/// Wire the banner, show a release an earlier check found, and start checking
|
||||
/// if the user asked for it.
|
||||
pub(crate) fn wire_update(ui: &Rc<Ui>) {
|
||||
let ui_open = ui.clone();
|
||||
ui.update
|
||||
.banner
|
||||
.connect_button_clicked(move |_| open_uri(&ui_open.update.url.borrow()));
|
||||
show_latest(ui);
|
||||
start_update_checks(ui);
|
||||
}
|
||||
|
||||
/// Start the daily check, unless it is off, managed elsewhere, or running.
|
||||
pub(crate) fn start_update_checks(ui: &Rc<Ui>) {
|
||||
if updates_managed_here()
|
||||
|| !ui.dirs.load_config().update_check
|
||||
|| ui.update.running.replace(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
// Also kept here, so a config that cannot be saved does not make every
|
||||
// pass ask GitHub again.
|
||||
let mut checked = None;
|
||||
loop {
|
||||
let config = ui.dirs.load_config();
|
||||
if !config.update_check {
|
||||
break;
|
||||
}
|
||||
let wait = next_check_in(config.update_last_checked.max(checked), unix_now());
|
||||
if !wait.is_zero() {
|
||||
glib::timeout_future(wait).await;
|
||||
continue;
|
||||
}
|
||||
match fetch_latest().await {
|
||||
Ok(latest) => {
|
||||
checked = Some(unix_now());
|
||||
let mut config = ui.dirs.load_config();
|
||||
config.update_last_checked = checked;
|
||||
config.update_latest = latest;
|
||||
if let Err(e) = ui.dirs.save_config(&config) {
|
||||
tracing::warn!("update check: cannot save the result: {e}");
|
||||
}
|
||||
show_latest(&ui);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("update check: {e}");
|
||||
glib::timeout_future(RETRY_AFTER).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.update.running.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the banner if the last check found a release newer than this one,
|
||||
/// and hide it otherwise.
|
||||
pub(crate) fn show_latest(ui: &Ui) {
|
||||
let config = ui.dirs.load_config();
|
||||
let newer = config
|
||||
.update_latest
|
||||
.filter(|latest| config.update_check && is_newer(latest, env!("CARGO_PKG_VERSION")));
|
||||
match newer {
|
||||
Some(version) => {
|
||||
ui.update.banner.set_title(&gettext_f(
|
||||
"Proton Drive {version} is available",
|
||||
&[("version", &version)],
|
||||
));
|
||||
*ui.update.url.borrow_mut() = release_url(&version);
|
||||
ui.update.banner.set_revealed(!updates_managed_here());
|
||||
}
|
||||
None => ui.update.banner.set_revealed(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// The latest release's version, asked on a worker thread so the window never
|
||||
/// waits on GitHub. `Ok(None)` when the answer names no `X.Y.Z` release.
|
||||
async fn fetch_latest() -> Result<Option<String>, String> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(fetch_latest_blocking());
|
||||
});
|
||||
rx.recv().await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn fetch_latest_blocking() -> Result<Option<String>, String> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("no runtime: {e}"))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!(
|
||||
"proton-drive-linux/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
")"
|
||||
))
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("no HTTP client: {e}"))?;
|
||||
runtime.block_on(async {
|
||||
let reply = client
|
||||
.get(RELEASES_LATEST_API)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !reply.status().is_success() {
|
||||
return Err(format!("GitHub answered {}", reply.status()));
|
||||
}
|
||||
let body = reply.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok(latest_version(&body))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::*;
|
||||
|
||||
/// The "›" between two breadcrumb segments.
|
||||
pub(crate) fn crumb_separator() -> gtk4::Label {
|
||||
let sep = gtk4::Label::new(Some("›"));
|
||||
sep.add_css_class("dim-label");
|
||||
sep
|
||||
}
|
||||
|
||||
/// The last breadcrumb segment, the folder on screen: a heading rather than a
|
||||
/// button. It carries a flat button's side padding (`.crumb-current`), so the
|
||||
/// "›" before it sits as far from it as from the button before.
|
||||
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
|
||||
let l = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||
.build();
|
||||
l.add_css_class("heading");
|
||||
l.add_css_class("crumb-current");
|
||||
l.upcast()
|
||||
}
|
||||
@@ -98,6 +98,8 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
.title(gettext("Location"))
|
||||
.subtitle("—")
|
||||
.subtitle_lines(3)
|
||||
// A folder path, which may hold `&` or `<`.
|
||||
.use_markup(false)
|
||||
.build();
|
||||
path_row.add_css_class("property");
|
||||
props.add(&size_row);
|
||||
@@ -366,12 +368,19 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
gettext("File")
|
||||
});
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
// A folder's size is Proton Drive's count, which takes a request; it lands
|
||||
// in `load_details_extras`.
|
||||
if entry.is_dir {
|
||||
d.size_row.set_title(&gettext("Size on Proton Drive"));
|
||||
d.size_row.set_subtitle("—");
|
||||
d.size_row.set_tooltip_text(Some(&gettext(
|
||||
"Counted by Proton Drive, including items in the trash.",
|
||||
)));
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
});
|
||||
d.size_row.set_visible(!entry.is_dir);
|
||||
d.size_row.set_title(&pgettext("column", "Size"));
|
||||
d.size_row.set_subtitle(&human_bytes(entry.size));
|
||||
d.size_row.set_tooltip_text(None);
|
||||
}
|
||||
d.modified_row
|
||||
.set_subtitle(&dates::short_date(entry.modified));
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -384,11 +393,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
} else {
|
||||
parent.to_string()
|
||||
});
|
||||
// Pinning is per file: the context menu and the selection bar offer it for
|
||||
// files only, so the pane must not offer it for folders either.
|
||||
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||
// to remove, so the switch shows that state without offering to change it.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
|
||||
gettext("Kept on this computer because a folder it is in is available offline.")
|
||||
} else if entry.is_dir {
|
||||
gettext(
|
||||
"Keep a copy of everything in this folder on this computer, even without a connection.",
|
||||
)
|
||||
} else {
|
||||
gettext("Keep a copy on this computer, even without a connection.")
|
||||
});
|
||||
d.pin_row
|
||||
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
|
||||
d.open_button.set_label(&if entry.is_dir {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
@@ -415,8 +433,8 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
d.pages.set_visible_child_name("details");
|
||||
}
|
||||
|
||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
||||
/// land only if the pane still shows the same entry.
|
||||
/// Fill the Sharing and Versions rows, and a folder's size, which need the
|
||||
/// network. The answers land only if the pane still shows the same entry.
|
||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||
let rel = entry_rel(ui, entry);
|
||||
@@ -442,6 +460,24 @@ fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
});
|
||||
|
||||
if entry.is_dir {
|
||||
let req = if by_uid {
|
||||
Request::FolderSizeByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::FolderSize { path: rel }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::FolderSize { size, items })) => folder_size_summary(size, items),
|
||||
_ => gettext("Couldn't load the size"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.size_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
let req = if by_uid {
|
||||
@@ -488,6 +524,18 @@ pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// "1.2 GiB · 340 items": what a folder holds, as Proton Drive counts it.
|
||||
pub(crate) fn folder_size_summary(size: u64, items: u64) -> String {
|
||||
// Translators: {size} is a size such as "1.2 GiB"; {n} counts the files
|
||||
// and folders inside a folder.
|
||||
ngettext_f(
|
||||
"{size} · {n} item",
|
||||
"{size} · {n} items",
|
||||
items,
|
||||
&[("size", &human_bytes(size))],
|
||||
)
|
||||
}
|
||||
|
||||
/// "3 versions · latest 2 Sep 2026".
|
||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
@@ -536,6 +584,12 @@ pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_size_summary_reads_size_and_item_count() {
|
||||
assert_eq!(folder_size_summary(0, 0), "0 bytes · 0 items");
|
||||
assert_eq!(folder_size_summary(2048, 1), "2.0 KiB · 1 item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_summary_names_people_and_link() {
|
||||
assert_eq!(sharing_summary(0, false), "Only you");
|
||||
|
||||
@@ -132,8 +132,13 @@ impl FileList {
|
||||
// and the grid collapses to a single column. Allowing a mid-word
|
||||
// break is what keeps the two-line-then-ellipsis budget below
|
||||
// enforceable for *every* name rather than only the ones that
|
||||
// happen to have spaces.
|
||||
// happen to have spaces. Pango marks such a break with a hyphen
|
||||
// that is not in the name ("SteamSetup.e-xe"); a file name is
|
||||
// not prose, so break it bare, as Nautilus does.
|
||||
let no_hyphens = gtk4::pango::AttrList::new();
|
||||
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
|
||||
let label = gtk4::Label::builder()
|
||||
.attributes(&no_hyphens)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(13)
|
||||
|
||||
@@ -61,6 +61,18 @@ impl ActionMenu {
|
||||
self
|
||||
}
|
||||
|
||||
/// A greyed-out check item showing `active`, for a state the user cannot
|
||||
/// change from here.
|
||||
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||
action.set_enabled(false);
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section; the next item starts a new one below a
|
||||
/// separator. An empty section is skipped, so callers can end a group of
|
||||
/// conditional items unconditionally.
|
||||
@@ -128,20 +140,34 @@ impl ActionMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
||||
/// selectors reach through a popover to its parent, and themes style the
|
||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
||||
/// margin around every section. Under the view itself it is no item's child.
|
||||
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||
///
|
||||
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||
/// its parent, and themes style the insides of list items (`gridview > child
|
||||
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||
/// rules up and puts a wide margin around every section. So the search starts
|
||||
/// at the outermost grid or list view around `anchor`.
|
||||
///
|
||||
/// Not on a widget without a layout manager either: a popover is sized and
|
||||
/// placed again when its parent's layout manager allocates it. The list views
|
||||
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
|
||||
/// first given, often too small, and opened scrolling. The host is the first
|
||||
/// widget from there up that has one.
|
||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
let mut host = anchor.clone();
|
||||
let mut start = anchor.clone();
|
||||
let mut widget = anchor.parent();
|
||||
while let Some(w) = widget {
|
||||
widget = w.parent();
|
||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||
host = w;
|
||||
start = w;
|
||||
}
|
||||
}
|
||||
host
|
||||
let mut host = Some(start.clone());
|
||||
while let Some(w) = host {
|
||||
if w.layout_manager().is_some() {
|
||||
return w;
|
||||
}
|
||||
host = w.parent();
|
||||
}
|
||||
start
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod compat;
|
||||
pub(crate) mod crumb;
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod file_list;
|
||||
pub(crate) mod menu;
|
||||
|
||||
@@ -51,6 +51,48 @@ pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
||||
.map(|days| now + days * 86_400)
|
||||
}
|
||||
|
||||
/// The labels of the Expires dropdown on an existing link. When the link has
|
||||
/// an expiry, the first entry is that expiry, so the dropdown shows what is
|
||||
/// set and choosing it again changes nothing.
|
||||
pub(crate) fn link_expiry_choices(current: Option<i64>, now: i64) -> Vec<String> {
|
||||
let current = current.map(|at| capitalize(&link_expiry_label(at, now)));
|
||||
current
|
||||
.into_iter()
|
||||
.chain(LINK_EXPIRY.iter().map(|(name, _)| gettext(name)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What choosing entry `idx` of [`link_expiry_choices`] does to the expiry.
|
||||
pub(crate) fn link_expiry_change(idx: u32, current: Option<i64>, now: i64) -> LinkChange<i64> {
|
||||
let idx = match (current, idx) {
|
||||
(Some(_), 0) => return LinkChange::Keep,
|
||||
(Some(_), idx) => idx - 1,
|
||||
(None, idx) => idx,
|
||||
};
|
||||
match link_expiry_at(idx, now) {
|
||||
Some(at) => LinkChange::Set(at),
|
||||
None if current.is_some() => LinkChange::Remove,
|
||||
None => LinkChange::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
/// A row with a title and a dropdown of `labels` as its suffix.
|
||||
fn dropdown_row(title: &str, labels: &[String], selected: u32) -> (adw::ActionRow, gtk4::DropDown) {
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(labels))
|
||||
.selected(selected)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let row = adw::ActionRow::builder().title(title).build();
|
||||
row.add_suffix(&drop);
|
||||
(row, drop)
|
||||
}
|
||||
|
||||
/// The roles a public link can grant, in dropdown order.
|
||||
fn link_roles() -> [String; 2] {
|
||||
[pgettext("role", "Viewer"), pgettext("role", "Editor")]
|
||||
}
|
||||
|
||||
/// A loose shape check for an email address: one `@` with something before
|
||||
/// it and a dotted domain after it. The server does the real validation; this
|
||||
/// only catches typos before the Invite button is pressed.
|
||||
@@ -127,6 +169,26 @@ impl Drop for Busy {
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's action, such as Send Invitations. It goes under the section's
|
||||
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
|
||||
/// list reads as a setting, and a button alone in one looks misplaced.
|
||||
fn group_button(label: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::builder()
|
||||
.label(label)
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
button.add_css_class("suggested-action");
|
||||
button
|
||||
}
|
||||
|
||||
/// A section's only row while the share is being read.
|
||||
fn loading_row() -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
|
||||
row.add_prefix(&spinner());
|
||||
row
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
@@ -217,6 +279,31 @@ impl ShareTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_link(
|
||||
&self,
|
||||
id: String,
|
||||
role: Option<String>,
|
||||
password: LinkChange<String>,
|
||||
expires: LinkChange<i64>,
|
||||
) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::UpdatePublicLink {
|
||||
path: path.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::UpdatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
id,
|
||||
role,
|
||||
password,
|
||||
expires,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_link(&self, id: String) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||
@@ -287,26 +374,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
||||
role_wrap.add_suffix(&role_drop);
|
||||
// The role goes with the addresses it applies to, at the end of their row.
|
||||
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||
email_row.add_suffix(&role_drop);
|
||||
let message_row = adw::EntryRow::builder()
|
||||
.title(gettext("Message (optional)"))
|
||||
.build();
|
||||
let invite_btn = gtk4::Button::builder()
|
||||
.label(gettext("Send Invitations"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||
invite_btn.set_sensitive(false);
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
.build();
|
||||
invite_group.add(&email_row);
|
||||
invite_group.add(&role_wrap);
|
||||
invite_group.add(&message_row);
|
||||
invite_group.add(&invite_wrap);
|
||||
invite_group.add(&invite_btn);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title(gettext("People with access"))
|
||||
@@ -315,21 +393,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
.title(gettext("Public link"))
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(12);
|
||||
content.set_margin_end(12);
|
||||
content.append(&invite_group);
|
||||
content.append(&people);
|
||||
content.append(&link_group);
|
||||
let clamp = adw::Clamp::builder().child(&content).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
toolbar.set_content(Some(&scroll));
|
||||
// Until the share is read, each section says so rather than standing
|
||||
// empty under its title.
|
||||
let people_loading = loading_row();
|
||||
people.add(&people_loading);
|
||||
let link_loading = loading_row();
|
||||
link_group.add(&link_loading);
|
||||
|
||||
let content = adw::PreferencesPage::new();
|
||||
content.add(&invite_group);
|
||||
content.add(&people);
|
||||
content.add(&link_group);
|
||||
toolbar.set_content(Some(&content));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(pgettext("verb", "Share"))
|
||||
@@ -343,8 +418,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
target,
|
||||
people,
|
||||
link_group,
|
||||
people_rows: RefCell::new(Vec::new()),
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||
});
|
||||
|
||||
// Check the addresses as they are typed: a malformed one marks the row as
|
||||
@@ -430,17 +505,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
_ => {
|
||||
share_dialog_unread(&state);
|
||||
toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The share couldn't be read: what the sections showed (rows still loading,
|
||||
/// or ones that may now be stale) gives way to one line that says so.
|
||||
fn share_dialog_unread(state: &Rc<ShareDialog>) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
state.people.remove(&row);
|
||||
}
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
}
|
||||
let row = dim_row(&gettext("Couldn't load sharing"));
|
||||
state.people.add(&row);
|
||||
state.people_rows.borrow_mut().push(row.upcast());
|
||||
}
|
||||
|
||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||
for row in state.people_rows.borrow_mut().drain(..) {
|
||||
@@ -455,15 +548,19 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
return;
|
||||
}
|
||||
for entry in entries {
|
||||
// A member is the plain case and needs no word; an invitation says
|
||||
// it is still waiting on the other side.
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => gettext("Member"),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
||||
ShareEntryKind::Member => String::new(),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
.subtitle(&subtitle)
|
||||
.use_markup(false)
|
||||
.build();
|
||||
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||
|
||||
// External invites can't have their role changed; show it read-only.
|
||||
// Members and Proton invites get a role dropdown.
|
||||
@@ -477,10 +574,16 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
.selected(role_wire_to_index(&entry.role))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let state_role = state.clone();
|
||||
// Weak, here and in every row below: the rows belong to groups the
|
||||
// dialog state holds, so a strong handle in a row's handler is a
|
||||
// cycle that kept each closed dialog in memory.
|
||||
let state_role = Rc::downgrade(state);
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
drop.connect_selected_notify(move |d| {
|
||||
let Some(state_role) = state_role.upgrade() else {
|
||||
return;
|
||||
};
|
||||
share_dialog_op(
|
||||
&state_role,
|
||||
state_role.target.update_role(
|
||||
@@ -510,12 +613,14 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let state_rm = Rc::downgrade(state);
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
let who = entry.email.clone();
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let Some(state) = state_rm.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
@@ -547,8 +652,8 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// Rebuild the public-link section: an existing link (copy / remove) or a
|
||||
/// create control.
|
||||
/// Rebuild the public-link section: an existing link (copy / remove, and its
|
||||
/// settings) or a create control.
|
||||
pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLinkInfo>) {
|
||||
for row in state.link_rows.borrow_mut().drain(..) {
|
||||
state.link_group.remove(&row);
|
||||
@@ -576,6 +681,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
url.clone()
|
||||
})
|
||||
.subtitle(&subtitle)
|
||||
.use_markup(false)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
|
||||
@@ -586,12 +692,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let state_copy = state.clone();
|
||||
let ui_copy = state.ui.clone();
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
flash_copied(btn, "edit-copy-symbolic");
|
||||
toast(&state_copy.ui, &gettext("Link copied"));
|
||||
toast(&ui_copy, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
@@ -602,10 +708,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let state_rm = Rc::downgrade(state);
|
||||
let id = link.id.clone();
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let Some(state) = state_rm.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
@@ -629,48 +737,31 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
state.link_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
|
||||
let settings = link_settings_row(state, link);
|
||||
state.link_group.add(&settings);
|
||||
rows.push(settings.upcast());
|
||||
}
|
||||
None => {
|
||||
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_row = adw::ActionRow::builder()
|
||||
.title(gettext("Link role"))
|
||||
.build();
|
||||
role_row.add_suffix(&role_drop);
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), 0);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Password (optional)"))
|
||||
.build();
|
||||
let expiry_names: Vec<String> =
|
||||
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
||||
let expiry_drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let create = gtk4::Button::builder()
|
||||
.label(gettext("Create Public Link"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
create.add_css_class("suggested-action");
|
||||
let create_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&create)
|
||||
.build();
|
||||
let (expiry_row, expiry_drop) = dropdown_row(&gettext("Expires"), &expiry_names, 0);
|
||||
let create = group_button(&gettext("Create Public Link"));
|
||||
|
||||
// Enter in the password field creates the link.
|
||||
let btn = create.clone();
|
||||
pw_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
|
||||
let state_c = state.clone();
|
||||
let state_c = Rc::downgrade(state);
|
||||
let pw_for = pw_row.clone();
|
||||
create.connect_clicked(move |btn| {
|
||||
let Some(state_c) = state_c.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if !btn.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
@@ -689,16 +780,152 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&expiry_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
state.link_group.add(&create);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(expiry_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
rows.push(create.upcast());
|
||||
}
|
||||
}
|
||||
*state.link_rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The settings of an existing link, collapsed under one row. Each control
|
||||
/// applies at once, the way a member's role does. While a change is on its way
|
||||
/// the whole row is insensitive: the daemon fills in what a change keeps from
|
||||
/// the link as it stands, so two changes in flight could undo each other.
|
||||
fn link_settings_row(state: &Rc<ShareDialog>, link: &PublicLinkInfo) -> adw::ExpanderRow {
|
||||
let now = glib::real_time() / 1_000_000;
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(gettext("Link settings"))
|
||||
.subtitle(gettext(
|
||||
"Change who can edit, the password or the expiry. The link stays the same.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let selected_role = u32::from(link.role == "editor");
|
||||
let (role_row, role_drop) = dropdown_row(&gettext("Link role"), &link_roles(), selected_role);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
role_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let role = if d.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
"viewer"
|
||||
};
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
Some(role.into()),
|
||||
LinkChange::Keep,
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&role_row);
|
||||
|
||||
let current = link.expires;
|
||||
let (expiry_row, expiry_drop) =
|
||||
dropdown_row(&gettext("Expires"), &link_expiry_choices(current, now), 0);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
expiry_drop.connect_selected_notify(move |d| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let expires = link_expiry_change(d.selected(), current, glib::real_time() / 1_000_000);
|
||||
if expires == LinkChange::Keep {
|
||||
return;
|
||||
}
|
||||
let request = state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Keep, expires);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&expiry_row);
|
||||
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title(if link.has_password {
|
||||
gettext("New password")
|
||||
} else {
|
||||
gettext("Add a password")
|
||||
})
|
||||
.show_apply_button(true)
|
||||
.build();
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
pw_row.connect_apply(move |row| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let password = row.text().to_string();
|
||||
if password.is_empty() {
|
||||
return;
|
||||
}
|
||||
let request = state.target.update_link(
|
||||
id.clone(),
|
||||
None,
|
||||
LinkChange::Set(password),
|
||||
LinkChange::Keep,
|
||||
);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&pw_row);
|
||||
|
||||
if link.has_password {
|
||||
let remove = gtk4::Button::builder()
|
||||
.label(gettext("Remove"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(gettext("Remove the password"))
|
||||
.subtitle(gettext(
|
||||
"Anyone with the link can open it without a password.",
|
||||
))
|
||||
.build();
|
||||
row.add_suffix(&remove);
|
||||
let (state_w, expander_w, id) = (Rc::downgrade(state), expander.clone(), link.id.clone());
|
||||
remove.connect_clicked(move |_| {
|
||||
let Some(state) = state_w.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let request =
|
||||
state
|
||||
.target
|
||||
.update_link(id.clone(), None, LinkChange::Remove, LinkChange::Keep);
|
||||
share_dialog_update_link(&state, request, &expander_w);
|
||||
});
|
||||
expander.add_row(&row);
|
||||
}
|
||||
expander
|
||||
}
|
||||
|
||||
/// Send a link change, then reload the dialog: on success to show the link as
|
||||
/// it now is, on failure to put the controls back to what the link still is.
|
||||
fn share_dialog_update_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
request: Request,
|
||||
settings: &adw::ExpanderRow,
|
||||
) {
|
||||
settings.set_sensitive(false);
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), request);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::PublicLink { .. })) => toast(&state.ui, &gettext("Link updated")),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, &gettext("Couldn't change the link"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&gettext("Couldn't change the link"),
|
||||
&gettext("The Proton Drive service didn't respond."),
|
||||
),
|
||||
}
|
||||
share_dialog_reload(&state);
|
||||
});
|
||||
}
|
||||
|
||||
/// Create a public link, then reload the dialog so the copy/remove controls
|
||||
/// replace the create form (and the freshly minted URL is shown).
|
||||
pub(crate) fn share_dialog_create_link(
|
||||
@@ -828,6 +1055,28 @@ mod tests {
|
||||
assert_eq!(link_expiry_at(99, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_an_expiry_keeps_removes_or_sets_it() {
|
||||
let now = 1_000;
|
||||
// No expiry yet: the choices are just Never and the lifetimes.
|
||||
assert_eq!(link_expiry_choices(None, now).len(), LINK_EXPIRY.len());
|
||||
assert_eq!(link_expiry_change(0, None, now), LinkChange::Keep);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, None, now),
|
||||
LinkChange::Set(now + 7 * 86_400)
|
||||
);
|
||||
|
||||
// An expiry set: it comes first, then Never and the lifetimes.
|
||||
let at = Some(2_000_000_000);
|
||||
assert_eq!(link_expiry_choices(at, now).len(), LINK_EXPIRY.len() + 1);
|
||||
assert_eq!(link_expiry_change(0, at, now), LinkChange::Keep);
|
||||
assert_eq!(link_expiry_change(1, at, now), LinkChange::Remove);
|
||||
assert_eq!(
|
||||
link_expiry_change(2, at, now),
|
||||
LinkChange::Set(now + 86_400)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_expiry_reads_expired() {
|
||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||
|
||||
@@ -219,6 +219,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.use_markup(false)
|
||||
.build();
|
||||
|
||||
let save = gtk4::Button::builder()
|
||||
|
||||
@@ -15,7 +15,6 @@ mod thumbs;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::{gdk, gio, glib};
|
||||
@@ -24,7 +23,7 @@ use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource};
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, present_within};
|
||||
use crate::compat::{Spinner, spinner};
|
||||
use crate::theme::set_proton_theme;
|
||||
use crate::{
|
||||
@@ -38,10 +37,6 @@ use thumbs::Thumbs;
|
||||
/// Recent files shown above the pinned ones while the query is empty.
|
||||
const RECENT_LIMIT: usize = 8;
|
||||
|
||||
/// How long opening waits for the mount to answer whether it has a file. A
|
||||
/// frozen mount must not freeze the launcher; the daemon is asked instead.
|
||||
const STAT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The titled groups of the result list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Section {
|
||||
@@ -516,22 +511,11 @@ pub(crate) fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
Some(ui)
|
||||
}
|
||||
|
||||
/// Whether the mount has `hit`, asked on a worker thread and given up on
|
||||
/// after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer"; either
|
||||
/// way the daemon is the fallback.
|
||||
/// Whether the mount has `hit`, without blocking the window
|
||||
/// ([`present_within`]). `None` means "not there" or "no answer"; either way
|
||||
/// the daemon is the fallback.
|
||||
async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option<PathBuf> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(mounted_target(&mountpoint, &hit));
|
||||
});
|
||||
match glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await {
|
||||
Ok(Ok(target)) => target,
|
||||
Ok(Err(_)) => None,
|
||||
Err(_) => {
|
||||
tracing::warn!("the mount did not answer in time; opening through the daemon");
|
||||
None
|
||||
}
|
||||
}
|
||||
present_within(mounted_or_relative(&mountpoint, &hit)).await
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
|
||||
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
||||
}
|
||||
|
||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||
/// or online only. Folders and local files have none.
|
||||
/// or online only. A folder has one only when kept offline; local files have
|
||||
/// none.
|
||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||
let Hit::Drive(drive) = hit else {
|
||||
return None;
|
||||
};
|
||||
if drive.is_dir {
|
||||
return None;
|
||||
return drive
|
||||
.pinned
|
||||
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||
}
|
||||
Some(if drive.pinned {
|
||||
("pdfs-offline-symbolic", "badge-pinned")
|
||||
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user