mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800705ccb0 | ||
|
|
7ab14bb8f7 | ||
|
|
9c8b6680a7 | ||
|
|
bbd913b87d | ||
|
|
04f4610e4d | ||
|
|
0a5afd0b6a | ||
|
|
163c54a1e9 | ||
|
|
0d7c6e7bae | ||
|
|
10252fa84b | ||
|
|
bf9ec0862a | ||
|
|
d0d0c73a51 | ||
|
|
129ad1a50d | ||
|
|
506a610273 | ||
|
|
5048492a83 | ||
|
|
321547fb77 | ||
|
|
a06a8150fb | ||
|
|
c4bce50d35 | ||
|
|
d2b5916b99 | ||
|
|
4626ce17a7 | ||
|
|
630f316fe6 | ||
|
|
801681f747 | ||
|
|
e7382f2c7e | ||
|
|
7046f0f3ef | ||
|
|
37e8dae61b | ||
|
|
c52f2098f2 | ||
|
|
f4a2674b9c | ||
|
|
347861bbc1 | ||
|
|
c57763b07f | ||
|
|
846dfd8d14 | ||
|
|
2ae99de844 | ||
|
|
69ff216e7d | ||
|
|
b3ea564c6b | ||
|
|
463c97a14d | ||
|
|
40aa8016ea | ||
|
|
d1b7535696 | ||
|
|
ba414c4446 | ||
|
|
5b7f17bdaf | ||
|
|
73068f5868 | ||
|
|
b093010b64 | ||
|
|
ab8fe0e61d | ||
|
|
b8295373f7 | ||
|
|
5bc0b5c58a | ||
|
|
73af39ee65 | ||
|
|
48aaad23ce | ||
|
|
176112a9cb | ||
|
|
7957e1ed59 | ||
|
|
53543c91f9 | ||
|
|
fccb3d9561 | ||
|
|
6f2b4bb80a | ||
|
|
a617b2b410 | ||
|
|
bdbfc0bfd4 | ||
|
|
e9577f953f | ||
|
|
437d14b0f5 | ||
|
|
b40ca797d9 | ||
|
|
c64bb23e3c | ||
|
|
8d4a005aa2 | ||
|
|
9460c93b50 | ||
|
|
639ca359fa | ||
|
|
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 | ||
|
|
07c9b675d3 | ||
|
|
f82343b844 | ||
|
|
3e3dd7db41 | ||
|
|
d09e10dc7d | ||
|
|
8d0351124c | ||
|
|
a888e0bb28 | ||
|
|
c7fc85e943 | ||
|
|
5bfae0fee1 | ||
|
|
edd62c5f7a | ||
|
|
5c1530b4b9 | ||
|
|
ce324510de | ||
|
|
274bfd0ca2 | ||
|
|
b8cfd30ff3 | ||
|
|
9fa88ff397 | ||
|
|
f105159186 | ||
|
|
a14a63c94c | ||
|
|
ed3477d1b3 | ||
|
|
aa384ae2eb | ||
|
|
10c69b0d64 | ||
|
|
39be354bd2 | ||
|
|
2cca8afba9 | ||
|
|
6e9beb4b16 | ||
|
|
63484bf4b6 | ||
|
|
37c66b7220 | ||
|
|
41b6edbecb |
No files matched your search
@@ -3,6 +3,9 @@ contact_links:
|
|||||||
- name: Security vulnerability
|
- name: Security vulnerability
|
||||||
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||||
about: Report privately — never in a public issue.
|
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
|
- name: Known issues
|
||||||
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||||
about: The authoritative ledger of tracked bugs and their status.
|
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
|
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||||
- [ ] Changes packaging, the systemd unit, or desktop entries
|
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||||
- [ ] Changes the control protocol (daemon and front ends updated together)
|
- [ ] 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
|
- [ ] None of the above
|
||||||
|
|
||||||
<!-- Screenshots for GTK changes. -->
|
<!-- Screenshots for GTK changes. -->
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
- name: Lints
|
- name: Lints
|
||||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
run: cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
@@ -64,10 +64,11 @@ jobs:
|
|||||||
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
# Real Proton credentials and /dev/fuse deliberately do not run on pull
|
||||||
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
# requests. Follow docs/TESTING.md on a dedicated test-account runner.
|
||||||
|
|
||||||
# The seeded simulation runs (docs/MILESTONE-3.0.0.md §8.2): real FUSE
|
# The seeded simulation runs (docs/TESTING.md): real FUSE
|
||||||
# daemons against an in-memory Drive, so they need /dev/fuse but no account.
|
# 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:
|
simulation:
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 30
|
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}"
|
tag_version="${GITHUB_REF_NAME#v}"
|
||||||
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)"
|
||||||
pkgbuild_version="$(sed -n 's/^pkgver=//p' packaging/PKGBUILD | 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" = "$manifest_version"
|
||||||
test "$tag_version" = "$pkgbuild_version"
|
test "$tag_version" = "$pkgbuild_version"
|
||||||
|
test "$tag_version" = "$spec_version"
|
||||||
|
|
||||||
- name: Release quality gates
|
- name: Release quality gates
|
||||||
run: |
|
run: |
|
||||||
@@ -129,6 +131,12 @@ jobs:
|
|||||||
# Create tarball
|
# Create tarball
|
||||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
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)
|
- name: Package Debian package (.deb)
|
||||||
run: |
|
run: |
|
||||||
mkdir -p deb/DEBIAN
|
mkdir -p deb/DEBIAN
|
||||||
@@ -153,6 +161,9 @@ jobs:
|
|||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
po/build.sh deb/usr/share/locale
|
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
|
# Calculate Installed-Size in KB for the control file
|
||||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||||
|
|
||||||
@@ -227,27 +238,75 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
*.tar.xz
|
||||||
*.rpm
|
*.rpm
|
||||||
if-no-files-found: error
|
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:
|
publish:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
needs: [checks, build-release]
|
needs: [checks, build-release, nix]
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Download packages
|
- name: Download packages
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
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
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
|
body_path: release-notes.md
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
*.tar.xz
|
||||||
*.rpm
|
*.rpm
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
@@ -263,3 +322,21 @@ jobs:
|
|||||||
version: ${{ github.ref_name }}
|
version: ${{ github.ref_name }}
|
||||||
secrets:
|
secrets:
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
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,121 @@
|
|||||||
|
name: Test build
|
||||||
|
|
||||||
|
# On-demand Fedora RPM of any branch, for someone to try a fix before it ships.
|
||||||
|
# Run it from the Actions tab ("Run workflow", pick the branch). Built inside a
|
||||||
|
# Fedora container so the binaries link against Fedora's own GTK, libadwaita and
|
||||||
|
# WebKitGTK, not the Ubuntu ones the release RPM is built against.
|
||||||
|
#
|
||||||
|
# The RPM is unsigned and versioned `<Cargo version>-1.test.<sha>`, so it sorts
|
||||||
|
# below the release it precedes and `dnf` replaces it when that ships.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
fedora:
|
||||||
|
description: 'Fedora release to build on'
|
||||||
|
required: false
|
||||||
|
default: '44'
|
||||||
|
type: string
|
||||||
|
prerelease:
|
||||||
|
description: 'Also publish a pre-release anyone can download without a GitHub login'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: test-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rpm:
|
||||||
|
name: Fedora ${{ inputs.fedora }} RPM
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container: fedora:${{ inputs.fedora }}
|
||||||
|
steps:
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
dnf install -y --setopt=install_weak_deps=False \
|
||||||
|
git rpm-build gcc gcc-c++ make gettext curl \
|
||||||
|
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||||
|
libsecret-devel dbus-devel glib2-devel webkitgtk6.0-devel
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
# Fedora's packaged Rust can trail the workspace's minimum (1.96).
|
||||||
|
- name: Set up Rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: '1.96'
|
||||||
|
cache: false
|
||||||
|
|
||||||
|
- name: Cache Cargo target
|
||||||
|
uses: swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: fedora-test-build
|
||||||
|
save-if: false
|
||||||
|
|
||||||
|
- name: Build release binaries
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
- name: Package RPM
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||||
|
VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
|
||||||
|
SHORT=${GITHUB_SHA::7}
|
||||||
|
mkdir -p rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS,BUILDROOT,db}
|
||||||
|
rpmbuild -bb \
|
||||||
|
--nodeps \
|
||||||
|
--define "_topdir $PWD/rpmbuild" \
|
||||||
|
--define "_sourcedir $PWD" \
|
||||||
|
--define "git_dir $PWD" \
|
||||||
|
--define "prebuilt 1" \
|
||||||
|
--define "version ${VERSION}" \
|
||||||
|
--define "dist .test.${SHORT}" \
|
||||||
|
--define "_dbpath $PWD/rpmbuild/db" \
|
||||||
|
packaging/proton-drive-linux.spec
|
||||||
|
mkdir -p out
|
||||||
|
cp rpmbuild/RPMS/x86_64/*.rpm out/
|
||||||
|
# The bare binaries, for trying one without installing anything.
|
||||||
|
tar -czf "out/proton-drive-linux-${VERSION}-${SHORT}-fedora${{ inputs.fedora }}-x86_64.tar.gz" \
|
||||||
|
-C target/release pdfs pdfs-tray pdfs-app pdfs-prompt
|
||||||
|
ls -l out
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: proton-drive-linux-fedora${{ inputs.fedora }}-${{ github.sha }}
|
||||||
|
path: out/*
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
prerelease:
|
||||||
|
name: Publish pre-release
|
||||||
|
needs: rpm
|
||||||
|
if: inputs.prerelease
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Download build
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: out
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
# One rolling pre-release per branch: a new run replaces the last one, so
|
||||||
|
# the link a tester was given keeps pointing at the newest build.
|
||||||
|
- name: Publish
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
TAG="test-$(echo "${GITHUB_REF_NAME}" | tr -c 'A-Za-z0-9._\n' '-')"
|
||||||
|
gh release delete "$TAG" --cleanup-tag --yes 2>/dev/null || true
|
||||||
|
gh release create "$TAG" out/* \
|
||||||
|
--target "$GITHUB_SHA" \
|
||||||
|
--prerelease \
|
||||||
|
--title "Test build of ${GITHUB_REF_NAME} (${GITHUB_SHA::7})" \
|
||||||
|
--notes "Unsigned test build of branch \`${GITHUB_REF_NAME}\` at ${GITHUB_SHA}. Not a release; replaced by the next test build of this branch."
|
||||||
@@ -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
|
*.pdb
|
||||||
.env
|
.env
|
||||||
/rpmbuild
|
/rpmbuild
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
+11
-9
@@ -6,10 +6,10 @@ deliberately high — everything else is ordinary Rust review.
|
|||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
- Read [`AGENTS.md`](AGENTS.md) for repository conventions,
|
- Read [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from
|
||||||
[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) for building, running and debugging from source,
|
source, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and
|
||||||
and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
SDK fit together. [`docs/README.md`](docs/README.md) indexes the rest. The same pages are on
|
||||||
together. [`docs/README.md`](docs/README.md) indexes the rest.
|
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
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
already be tracked there with context.
|
already be tracked there with context.
|
||||||
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
@@ -39,9 +39,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
Changes to the FUSE layer, sync, or the queue should also pass the simulation runs and be
|
||||||
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
exercised against a real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live
|
||||||
use a dedicated test account, never one holding data you cannot lose.
|
runs are destructive: use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
## Making changes
|
## Making changes
|
||||||
|
|
||||||
@@ -58,8 +58,10 @@ use a dedicated test account, never one holding data you cannot lose.
|
|||||||
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
catalog in `po/LINGUAS` in the same change. See [`docs/TRANSLATING.md`](docs/TRANSLATING.md).
|
||||||
The CLI and daemon messages stay in English.
|
The CLI and daemon messages stay in English.
|
||||||
- **Document what users see.** A new command, setting or behaviour belongs in the matching page
|
- **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
|
under `docs/` (a user guide topic, the CLI reference or configuration) and in the `[Unreleased]`
|
||||||
[`docs/CHANGELOG.md`](docs/CHANGELOG.md).
|
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
|
## Commits and pull requests
|
||||||
|
|
||||||
|
|||||||
Generated
+402
-408
File diff suppressed because it is too large.
Load diff
+19
-6
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "3.0.0"
|
version = "3.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
|||||||
categories = ["filesystem", "command-line-utilities"]
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.7.2" }
|
proton-sdk = { version = "0.10.1" }
|
||||||
proton-drive-rs = { version = "0.7.2" }
|
proton-drive-rs = { version = "0.10.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -26,13 +26,14 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
|
zeroize = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
# wedge every later acquisition of it: `Workers` catches the unwind to keep the
|
||||||
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
# pool alive, and `std::sync::Mutex` would defeat that by poisoning the state
|
||||||
# the recovered worker needs.
|
# the recovered worker needs.
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.13"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "6"
|
directories = "6"
|
||||||
@@ -40,7 +41,7 @@ ignore = "0.4"
|
|||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
# Google Takeout exports are zip sets; the importer reads their central
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
# directories and streams entries out without extracting to disk first.
|
# directories and streams entries out without extracting to disk first.
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
# import has to speak SHA-1 whatever its age.
|
# import has to speak SHA-1 whatever its age.
|
||||||
sha1 = "0.11"
|
sha1 = "0.11"
|
||||||
@@ -48,7 +49,7 @@ sha1 = "0.11"
|
|||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||||
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
# with no `target` it matches items v3 wrote, so existing logins survive.
|
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||||
@@ -60,3 +61,15 @@ async-trait = "0.1"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
fuser = "0.18"
|
fuser = "0.18"
|
||||||
libc = "0.2"
|
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"
|
||||||
@@ -15,6 +15,7 @@ scriptable CLI.
|
|||||||
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
[](https://aur.archlinux.org/packages/proton-drive-for-linux)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.rust-lang.org)
|
[](https://www.rust-lang.org)
|
||||||
|
[](https://buymeacoffee.com/narl)
|
||||||
|
|
||||||
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
<img src="images/files.png" alt="My files in the Proton Drive app" width="820">
|
||||||
|
|
||||||
@@ -72,15 +73,30 @@ scriptable CLI.
|
|||||||
|
|
||||||
| Distribution | Package |
|
| 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 | [`narl-rs/proton-drive-linux`](https://copr.fedorainfracloud.org/coprs/narl-rs/proton-drive-linux/) in COPR, updated with `dnf upgrade` |
|
||||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
|
||||||
| Arch Linux | [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) (also `-bin` and `-git`) in the AUR |
|
| 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) |
|
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||||
|
|
||||||
|
**Fedora**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install ./proton-drive-linux_*.deb # Debian, Ubuntu
|
sudo dnf copr enable narl-rs/proton-drive-linux
|
||||||
sudo dnf install ./proton-drive-linux-*.rpm # Fedora
|
sudo dnf install proton-drive-linux
|
||||||
paru -S proton-drive-for-linux # Arch
|
```
|
||||||
|
|
||||||
|
**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
|
Then open **Proton Drive** from your applications menu and sign in. The app starts the background
|
||||||
@@ -95,18 +111,25 @@ systemctl --user enable --now proton-drive.service
|
|||||||
pdfs status
|
pdfs status
|
||||||
```
|
```
|
||||||
|
|
||||||
[docs/INSTALL.md](docs/INSTALL.md) has the requirements, optional extras (tray on GNOME, RAW and
|
New to the client? [Getting started](docs/GETTING_STARTED.md) walks through the first steps.
|
||||||
video thumbnails), headless use and uninstalling.
|
[Installation](docs/INSTALL.md) has the optional extras (tray on GNOME, RAW and video
|
||||||
|
thumbnails), updating, headless use and uninstalling.
|
||||||
|
|
||||||
## Documentation
|
## 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 |
|
| [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` |
|
| [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 |
|
| [Configuration](docs/CONFIGURATION.md) | `config.json`, file locations, environment variables, the service |
|
||||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Diagnosing problems and fixing common ones |
|
| [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 |
|
| [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 |
|
| [Changelog](docs/CHANGELOG.md) | What changed in each release |
|
||||||
|
|
||||||
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
For contributors: [development guide](docs/DEVELOPMENT.md), [architecture](docs/ARCHITECTURE.md),
|
||||||
@@ -148,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
|
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
|
the security of the machine is spelled out in the
|
||||||
[threat model](docs/ARCHITECTURE.md#8-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
|
## Contributing
|
||||||
|
|
||||||
@@ -156,6 +180,13 @@ Bug reports, translations and patches are welcome. See [CONTRIBUTING.md](CONTRIB
|
|||||||
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
workflow and the checks CI runs, and [SECURITY.md](SECURITY.md) to report a vulnerability
|
||||||
privately.
|
privately.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Proton Drive Linux is free and built in spare time. If it saves you a few headaches, you can
|
||||||
|
[buy me a coffee](https://buymeacoffee.com/narl).
|
||||||
|
|
||||||
|
<a href="https://buymeacoffee.com/narl"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=black" alt="Buy me a coffee"></a>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Released under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|||||||
+4
-1
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
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
|
## Reporting a vulnerability
|
||||||
|
|
||||||
**Do not open a public issue.** Report privately through GitHub's
|
**Do not open a public issue.** Report privately through GitHub's
|
||||||
@@ -31,7 +34,7 @@ the interesting boundaries are:
|
|||||||
or the wrong key material to Proton.
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
What the client deliberately keeps in plaintext on disk, and why, is documented in the
|
||||||
[threat model](docs/ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
Behaviour described there is known and not a vulnerability by itself; a way to make it worse
|
||||||
than described is.
|
than described is.
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
rpassword.workspace = true
|
rpassword.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1033
-107
File diff suppressed because it is too large.
Load diff
@@ -28,3 +28,6 @@ dbus-secret-service-keyring-store.workspace = true
|
|||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
zeroize.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
+1072
-123
File diff suppressed because it is too large.
Load diff
@@ -91,7 +91,7 @@ const TOUCH_FLUSH_AT: usize = 256;
|
|||||||
///
|
///
|
||||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||||
/// this cache: the network read path validates against it too, so a block that
|
/// this cache: the network read path validates against it too, so a block that
|
||||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
|
||||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||||
@@ -311,8 +311,7 @@ struct PinFile {
|
|||||||
pins: BTreeMap<String, Pin>,
|
pins: BTreeMap<String, Pin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a staged file actually contains, written beside it as `<name>.json`
|
/// What a staged file actually contains, written beside it as `<name>.json`.
|
||||||
/// (offline.md Phase 2).
|
|
||||||
///
|
///
|
||||||
/// A staged file is **not** necessarily valid whole-file content, and that is
|
/// A staged file is **not** necessarily valid whole-file content, and that is
|
||||||
/// the whole reason this exists. A partial overwrite commits by filling the
|
/// the whole reason this exists. A partial overwrite commits by filling the
|
||||||
@@ -439,9 +438,9 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
|
||||||
/// Emptied on open so a crashed run leaves no orphans.
|
/// Emptied on open so a crashed run leaves no orphans.
|
||||||
scratch_dir: PathBuf,
|
scratch_dir: PathBuf,
|
||||||
/// Subdirectory holding the bytes of writes that have not been uploaded yet
|
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
|
||||||
/// (offline.md Phase 2/3) — every released write passes through here, since
|
/// Every released write passes through here, since the upload is a queued
|
||||||
/// the upload is a queued op performed later. Unlike `scratch_dir` this is
|
/// op performed later. Unlike `scratch_dir` this is
|
||||||
/// **never** emptied on open: these are the only copy of content the user
|
/// **never** emptied on open: these are the only copy of content the user
|
||||||
/// authored, and the whole point is that they outlive the daemon.
|
/// authored, and the whole point is that they outlive the daemon.
|
||||||
staging_dir: PathBuf,
|
staging_dir: PathBuf,
|
||||||
@@ -476,7 +475,7 @@ pub struct ContentCache {
|
|||||||
thumb_bytes: AtomicU64,
|
thumb_bytes: AtomicU64,
|
||||||
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
|
||||||
/// store/read/evict updates it, and the budget enforcers query it instead of
|
/// store/read/evict updates it, and the budget enforcers query it instead of
|
||||||
/// scanning the cache directories (plan.md P4).
|
/// scanning the cache directories.
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
|
||||||
///
|
///
|
||||||
@@ -622,10 +621,20 @@ impl ContentCache {
|
|||||||
for entry in rd.flatten() {
|
for entry in rd.flatten() {
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let Some(name) = name.to_str() else { continue };
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// A `.meta` tags the blob named like it without the suffix. One
|
||||||
|
// whose blob is gone is what an eviction leaves between its two
|
||||||
|
// removals, by a crash or by a store of the same uid landing in
|
||||||
|
// between, and nothing else would ever remove it.
|
||||||
|
if let Some(blob) = name.strip_suffix(".meta") {
|
||||||
|
if !dir.join(blob).exists() {
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
|
||||||
// of bytes and not a cached block; like `.meta` it is neither
|
// of bytes and not a cached block; like `.meta` it is neither
|
||||||
// budgeted nor evictable on its own.
|
// budgeted nor evictable on its own.
|
||||||
if name.ends_with(".meta") || name.ends_with(".geom") {
|
if name.ends_with(".geom") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// A `.tmp` is a staging file from a store that never completed
|
// A `.tmp` is a staging file from a store that never completed
|
||||||
@@ -989,7 +998,7 @@ impl ContentCache {
|
|||||||
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
|
||||||
/// cannot escape this cache even though this does not check. Validating a
|
/// cannot escape this cache even though this does not check. Validating a
|
||||||
/// block is the *reader's* job, at the point it is fetched — see
|
/// block is the *reader's* job, at the point it is fetched — see
|
||||||
/// `Core::read_block` (bugs.md B84).
|
/// `Core::read_block` (`docs/BUGS.md` B84).
|
||||||
pub fn store_block(
|
pub fn store_block(
|
||||||
&self,
|
&self,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
@@ -1491,9 +1500,8 @@ impl ContentCache {
|
|||||||
/// This is what makes a write survive its upload: the caller is releasing a
|
/// This is what makes a write survive its upload: the caller is releasing a
|
||||||
/// write handle and would otherwise delete the file, so until the bytes are
|
/// write handle and would otherwise delete the file, so until the bytes are
|
||||||
/// on the remote, staging holds the only copy. Every dirty handle goes
|
/// on the remote, staging holds the only copy. Every dirty handle goes
|
||||||
/// through here — the upload is a queued op performed later (offline.md
|
/// through here — the upload is a queued op performed later, and a staged
|
||||||
/// Phase 3), and a staged file is also what a human can recover from if the
|
/// file is also what a human can recover from if the queue never drains.
|
||||||
/// queue never drains.
|
|
||||||
///
|
///
|
||||||
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
/// Falls back to a copy when the rename crosses a filesystem boundary; a
|
||||||
/// failure here means we could not save the bytes at all, so it is reported
|
/// failure here means we could not save the bytes at all, so it is reported
|
||||||
@@ -2252,6 +2260,28 @@ mod tests {
|
|||||||
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
assert!(total <= 8, "budget enforced after reconcile, total={total}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An eviction removes the blob, then its tag. A crash in between, or a
|
||||||
|
/// store of the same uid landing in between, left the tag on its own, and
|
||||||
|
/// nothing removed it.
|
||||||
|
#[test]
|
||||||
|
fn a_tag_without_its_blob_is_swept_at_open() {
|
||||||
|
let dir = TempDir::new();
|
||||||
|
let content = dir.path().join("content");
|
||||||
|
let pins = dir.path().join("pins.json");
|
||||||
|
let db = Arc::new(Db::open_in_memory().unwrap());
|
||||||
|
{
|
||||||
|
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
|
||||||
|
c.store(&uid("kept"), 1, 4, b"kept").unwrap();
|
||||||
|
c.store(&uid("gone"), 1, 4, b"gone").unwrap();
|
||||||
|
std::fs::remove_file(c.blob_path(&uid("gone"))).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = ContentCache::open(content, pins, 0, db).unwrap();
|
||||||
|
assert!(!c.meta_path(&uid("gone")).exists());
|
||||||
|
assert!(c.meta_path(&uid("kept")).exists());
|
||||||
|
assert_eq!(c.read_range(&uid("kept"), 1, 4, 0, 4).unwrap(), b"kept");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stale_metadata_is_a_miss() {
|
fn stale_metadata_is_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2311,42 +2341,26 @@ mod tests {
|
|||||||
/// call also needs.
|
/// call also needs.
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_check_is_free_when_under_budget() {
|
fn budget_check_is_free_when_under_budget() {
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
// Cap far above what we store, so every store stays under budget.
|
// Cap far above what we store, so every store stays under budget.
|
||||||
let (c, _d) = cache_capped(1 << 30);
|
let (c, _d) = cache_capped(1 << 30);
|
||||||
let payload = vec![0u8; 4096];
|
let payload = vec![0u8; 4096];
|
||||||
|
for i in 0..8u64 {
|
||||||
// Cost of a store against a near-empty index...
|
|
||||||
let t = Instant::now();
|
|
||||||
for i in 0..500u64 {
|
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let empty = t.elapsed() / 500;
|
|
||||||
|
|
||||||
// ...and against one with a few thousand entries in it.
|
// Plant an index row far over the cap behind the running total's back,
|
||||||
for i in 500..2500u64 {
|
// and older than every real block. Any budget check that consults the
|
||||||
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
|
// index would see the pool over budget, evict, and reseed the total.
|
||||||
.unwrap();
|
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).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;
|
|
||||||
|
|
||||||
println!("B4: store_block under budget — {empty:?} at ~0 entries, {full:?} at 2500");
|
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
|
||||||
// A ratio, not a wall-clock bound: the claim is that the under-budget
|
.unwrap();
|
||||||
// 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
|
// Not a timing ratio: that measured the CI runner's load and flaked.
|
||||||
// runs alongside the rest of the suite.
|
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
|
||||||
assert!(
|
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
|
||||||
full < empty * 3,
|
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
|
||||||
"under-budget store should not scale with the index: \
|
|
||||||
{empty:?} at ~0 entries vs {full:?} at 2500"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The running totals are a fast path, not a second source of truth: after
|
/// The running totals are a fast path, not a second source of truth: after
|
||||||
@@ -2707,7 +2721,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The geometry the whole read path validates against, here and over the
|
/// The geometry the whole read path validates against, here and over the
|
||||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
|
||||||
#[test]
|
#[test]
|
||||||
fn block_len_is_full_blocks_then_the_remainder() {
|
fn block_len_is_full_blocks_then_the_remainder() {
|
||||||
let size = BLOCK_SIZE * 2 + 17;
|
let size = BLOCK_SIZE * 2 + 17;
|
||||||
@@ -2787,7 +2801,7 @@ mod tests {
|
|||||||
/// A cached block is addressed by the range it covers, not by its index
|
/// A cached block is addressed by the range it covers, not by its index
|
||||||
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
/// alone. Re-planning a file onto its real geometry must therefore miss the
|
||||||
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
/// blocks the uniform fallback cached, rather than serve their bytes at the
|
||||||
/// wrong offset (bugs.md B85).
|
/// wrong offset (`docs/BUGS.md` B85).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
fn a_cached_block_is_not_served_to_a_different_geometry() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2854,7 +2868,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A block of the wrong length cannot escape the cache even though the
|
/// A block of the wrong length cannot escape the cache even though the
|
||||||
/// store does not check: the read validates it and reports a miss, so the
|
/// store does not check: the read validates it and reports a miss, so the
|
||||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||||
let (c, _d) = cache();
|
let (c, _d) = cache();
|
||||||
@@ -2919,7 +2933,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
|
/// A failed upload must never cost the user their bytes:
|
||||||
/// the scratch file moves to staging intact, and staging survives a reopen
|
/// the scratch file moves to staging intact, and staging survives a reopen
|
||||||
/// the way scratch deliberately does not.
|
/// the way scratch deliberately does not.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+317
-41
@@ -3,7 +3,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::menu::PromptConfig;
|
use crate::menu::PromptConfig;
|
||||||
@@ -12,8 +12,19 @@ use crate::syncignore::DEFAULT_IGNORE_PATTERNS;
|
|||||||
|
|
||||||
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
/// Proton requires `external-drive-{name}@{semver}-{channel}` (channel ∈
|
||||||
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
/// stable/beta/alpha); a malformed value trips the 422 anti-abuse path.
|
||||||
pub const APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
/// Follows the release so Proton can tell releases apart and retire old ones
|
||||||
pub const USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
/// without retiring every one.
|
||||||
|
pub const APP_VERSION: &str = concat!(
|
||||||
|
"external-drive-linux@",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
"-stable"
|
||||||
|
);
|
||||||
|
pub const USER_AGENT: &str = concat!("proton-drive-linux/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
/// The fixed identity every release up to 3.1.0 wrote into `config.json`. Read
|
||||||
|
/// back as "unset" so those configs follow the release instead of pinning it.
|
||||||
|
const LEGACY_APP_VERSION: &str = "external-drive-linux@0.1.0-alpha";
|
||||||
|
const LEGACY_USER_AGENT: &str = "proton-drive-linux/0.1.0";
|
||||||
|
|
||||||
/// Keyring service name; one entry per credential kind keyed by username.
|
/// Keyring service name; one entry per credential kind keyed by username.
|
||||||
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
||||||
@@ -76,10 +87,24 @@ fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration structure allowing the user to customize client identification headers.
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub app_version: String,
|
/// Override for [`APP_VERSION`]. `None` means "use the release's own";
|
||||||
pub user_agent: String,
|
/// the old fixed default reads as `None`, so it leaves the file on the
|
||||||
|
/// next save.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub app_version: Option<String>,
|
||||||
|
/// Override for [`USER_AGENT`], handled like `app_version`.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
deserialize_with = "identity_override",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub user_agent: Option<String>,
|
||||||
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
/// Soft cap on the on-disk content cache, in bytes (`0` = unlimited).
|
||||||
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
/// `None` means "use [`DEFAULT_CACHE_BUDGET_BYTES`]"; the Settings page
|
||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
@@ -138,6 +163,10 @@ pub struct AppConfig {
|
|||||||
/// Defaulted for configs predating the field.
|
/// Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub files_view: FilesView,
|
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
|
/// 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
|
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||||
/// back on. Defaulted for configs predating the field.
|
/// back on. Defaulted for configs predating the field.
|
||||||
@@ -157,10 +186,28 @@ pub struct AppConfig {
|
|||||||
/// Record a new folder, a new file, a rename or a delete through the mount
|
/// Record a new folder, a new file, a rename or a delete through the mount
|
||||||
/// locally and send it from the queue, instead of waiting for Drive in the
|
/// locally and send it from the queue, instead of waiting for Drive in the
|
||||||
/// syscall. `None` means on. `false` brings back the old path for one
|
/// syscall. `None` means on. `false` brings back the old path for one
|
||||||
/// release (docs/MILESTONE-3.0.0.md §10). Defaulted for configs predating
|
/// release (docs/ROADMAP.md). Defaulted for configs predating
|
||||||
/// the field.
|
/// the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub local_first: Option<bool>,
|
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.
|
/// The My Files page's view choices, remembered between runs.
|
||||||
@@ -186,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.
|
/// The key a My Files listing is ordered by.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -216,31 +301,28 @@ impl FileSort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
/// Reads an `app_version` or `user_agent` override, dropping the fixed
|
||||||
fn default() -> Self {
|
/// identity older releases stored as the default.
|
||||||
Self {
|
fn identity_override<'de, D: Deserializer<'de>>(
|
||||||
app_version: APP_VERSION.to_string(),
|
deserializer: D,
|
||||||
user_agent: USER_AGENT.to_string(),
|
) -> std::result::Result<Option<String>, D::Error> {
|
||||||
cache_budget: None,
|
let value = Option::<String>::deserialize(deserializer)?;
|
||||||
upload_limit: None,
|
Ok(value.filter(|v| v != LEGACY_APP_VERSION && v != LEGACY_USER_AGENT))
|
||||||
download_limit: None,
|
|
||||||
mountpoint: None,
|
|
||||||
ignore_patterns: None,
|
|
||||||
device_uid: None,
|
|
||||||
conflict_sweep: None,
|
|
||||||
open_with: None,
|
|
||||||
prompt: None,
|
|
||||||
proton_theme: None,
|
|
||||||
files_view: FilesView::default(),
|
|
||||||
tray_hidden: false,
|
|
||||||
language: None,
|
|
||||||
online_map: false,
|
|
||||||
local_first: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
|
/// The `x-pm-appversion` sent to Proton: the user's override, or
|
||||||
|
/// [`APP_VERSION`].
|
||||||
|
pub fn resolved_app_version(&self) -> &str {
|
||||||
|
self.app_version.as_deref().unwrap_or(APP_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `User-Agent` sent to Proton: the user's override, or
|
||||||
|
/// [`USER_AGENT`].
|
||||||
|
pub fn resolved_user_agent(&self) -> &str {
|
||||||
|
self.user_agent.as_deref().unwrap_or(USER_AGENT)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective cache budget in bytes: the user's explicit choice, or
|
/// The effective cache budget in bytes: the user's explicit choice, or
|
||||||
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
/// [`DEFAULT_CACHE_BUDGET_BYTES`] when unset.
|
||||||
pub fn resolved_cache_budget(&self) -> u64 {
|
pub fn resolved_cache_budget(&self) -> u64 {
|
||||||
@@ -288,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.
|
/// Resolved XDG locations for state, cache, and the default mountpoint.
|
||||||
pub struct AppDirs {
|
pub struct AppDirs {
|
||||||
dirs: ProjectDirs,
|
dirs: ProjectDirs,
|
||||||
@@ -319,6 +417,7 @@ impl AppDirs {
|
|||||||
// Preserve the original bytes for diagnosis/recovery. In
|
// Preserve the original bytes for diagnosis/recovery. In
|
||||||
// particular, never replace a truncated config with defaults
|
// particular, never replace a truncated config with defaults
|
||||||
// and silently lose mount, device, or ignore settings.
|
// 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");
|
tracing::error!(path = %path.display(), error = %e, "config is unreadable; using in-memory defaults without overwriting it");
|
||||||
return AppConfig::default();
|
return AppConfig::default();
|
||||||
}
|
}
|
||||||
@@ -326,9 +425,8 @@ impl AppDirs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
if let Ok(content) = serde_json::to_string_pretty(&default_config) {
|
if let Err(e) = self.save_config(&default_config) {
|
||||||
let _ = std::fs::create_dir_all(self.dirs.config_dir());
|
tracing::warn!(path = %path.display(), error = %e, "could not write the default config");
|
||||||
let _ = std::fs::write(&path, content);
|
|
||||||
}
|
}
|
||||||
default_config
|
default_config
|
||||||
}
|
}
|
||||||
@@ -336,8 +434,15 @@ impl AppDirs {
|
|||||||
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
/// Persist `config` to [`config_path`](Self::config_path), creating the
|
||||||
/// config dir if missing. The Settings page calls this after editing 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.
|
/// 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<()> {
|
pub fn save_config(&self, config: &AppConfig) -> Result<()> {
|
||||||
let path = self.config_path();
|
let path = self.config_path();
|
||||||
|
refuse_to_replace_unreadable(&path)?;
|
||||||
let parent = path
|
let parent = path
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
.ok_or_else(|| Error::Other("configuration path has no parent".into()))?;
|
||||||
@@ -361,6 +466,46 @@ impl AppDirs {
|
|||||||
Ok(())
|
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.
|
/// Persistent state: inode map / cache index DB lives here.
|
||||||
pub fn state_dir(&self) -> PathBuf {
|
pub fn state_dir(&self) -> PathBuf {
|
||||||
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
// `state_dir` is Linux-only in `directories`; fall back to data dir.
|
||||||
@@ -432,7 +577,7 @@ impl AppDirs {
|
|||||||
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
/// typical umask makes them `0755`. Until this was fixed, the only thing
|
||||||
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
/// keeping another local user out was `~/.cache` and `~/.local/state`
|
||||||
/// happening to be `0700`, which is a convention of the user's system and
|
/// happening to be `0700`, which is a convention of the user's system and
|
||||||
/// not something this client established (bugs.md B6).
|
/// not something this client established (`docs/BUGS.md` B6).
|
||||||
///
|
///
|
||||||
/// Applied on every start, not just at creation: a directory that already
|
/// Applied on every start, not just at creation: a directory that already
|
||||||
/// exists with a permissive mode — restored from a backup that flattened
|
/// exists with a permissive mode — restored from a backup that flattened
|
||||||
@@ -449,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,
|
/// Make `dir` owner-only (`0700`). These directories contain decrypted bytes,
|
||||||
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
/// plaintext names, and an authenticated control socket, so failure is fatal.
|
||||||
fn restrict_dir(dir: &Path) -> Result<()> {
|
fn restrict_dir(dir: &Path) -> Result<()> {
|
||||||
@@ -483,7 +634,7 @@ fn restrict_dir(dir: &Path) -> Result<()> {
|
|||||||
/// session — enumerate, read, upload, trash, share — without presenting any
|
/// session — enumerate, read, upload, trash, share — without presenting any
|
||||||
/// credential, since the daemon already holds one. It is an authority boundary,
|
/// credential, since the daemon already holds one. It is an authority boundary,
|
||||||
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
/// not merely private data, and `UnixListener::bind` applies the umask like any
|
||||||
/// other file (bugs.md B6).
|
/// other file (`docs/BUGS.md` B6).
|
||||||
///
|
///
|
||||||
/// There is a window between `bind` and this call during which the socket
|
/// There is a window between `bind` and this call during which the socket
|
||||||
/// carries the umask's mode. It is closed in practice by the containing
|
/// carries the umask's mode. It is closed in practice by the containing
|
||||||
@@ -504,7 +655,29 @@ mod tests {
|
|||||||
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
std::fs::metadata(p).unwrap().permissions().mode() & 0o777
|
||||||
}
|
}
|
||||||
|
|
||||||
/// bugs.md B6. A permissive mode here is not a cosmetic problem: these
|
/// 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,
|
/// directories hold decrypted content, a plaintext index of every node name,
|
||||||
/// and the control socket that commands the daemon's session.
|
/// and the control socket that commands the daemon's session.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -571,8 +744,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_app_config_serialization() {
|
fn test_app_config_serialization() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: Some("external-drive-test-client@1.0.0".to_string()),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: Some("test-agent/1.0".to_string()),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
upload_limit: Some(500_000),
|
upload_limit: Some(500_000),
|
||||||
download_limit: None,
|
download_limit: None,
|
||||||
@@ -589,22 +762,69 @@ mod tests {
|
|||||||
descending: true,
|
descending: true,
|
||||||
folders_first: false,
|
folders_first: false,
|
||||||
},
|
},
|
||||||
|
window: WindowState {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
maximized: true,
|
||||||
|
},
|
||||||
tray_hidden: true,
|
tray_hidden: true,
|
||||||
language: Some("de".to_string()),
|
language: Some("de".to_string()),
|
||||||
online_map: true,
|
online_map: true,
|
||||||
local_first: Some(false),
|
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 json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
decoded.resolved_app_version(),
|
||||||
|
"external-drive-test-client@1.0.0"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), "test-agent/1.0");
|
||||||
assert_eq!(decoded.proton_theme, Some(true));
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
assert!(decoded.tray_hidden);
|
assert!(decoded.tray_hidden);
|
||||||
assert!(decoded.online_map);
|
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.language.as_deref(), Some("de"));
|
||||||
assert_eq!(decoded.upload_limit, Some(500_000));
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
assert_eq!(decoded.download_limit, None);
|
assert_eq!(decoded.download_limit, None);
|
||||||
assert_eq!(decoded.files_view, config.files_view);
|
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]
|
#[test]
|
||||||
@@ -628,8 +848,55 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_default_app_config() {
|
fn test_default_app_config() {
|
||||||
let default_config = AppConfig::default();
|
let default_config = AppConfig::default();
|
||||||
assert_eq!(default_config.app_version, APP_VERSION);
|
assert_eq!(default_config.resolved_app_version(), APP_VERSION);
|
||||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
assert_eq!(default_config.resolved_user_agent(), USER_AGENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_client_identity_follows_the_release() {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
assert_eq!(
|
||||||
|
APP_VERSION,
|
||||||
|
format!("external-drive-linux@{version}-stable")
|
||||||
|
);
|
||||||
|
assert_eq!(USER_AGENT, format!("proton-drive-linux/{version}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_old_config_with_the_fixed_identity_follows_the_release() {
|
||||||
|
// Every config written up to 3.1.0 holds these two values, so they must
|
||||||
|
// not pin the identity once Proton retires 0.1.0.
|
||||||
|
let json = format!(
|
||||||
|
r#"{{"app_version":"{LEGACY_APP_VERSION}","user_agent":"{LEGACY_USER_AGENT}","tray_hidden":true}}"#
|
||||||
|
);
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
|
assert!(decoded.tray_hidden, "the rest of the config survives");
|
||||||
|
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(!saved.contains("app_version"), "{saved}");
|
||||||
|
assert!(!saved.contains("user_agent"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hand_set_client_identity_is_kept() {
|
||||||
|
let json = r#"{"app_version":"external-drive-linux@9.9.9-beta","user_agent":"custom/1"}"#;
|
||||||
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decoded.resolved_app_version(),
|
||||||
|
"external-drive-linux@9.9.9-beta"
|
||||||
|
);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), "custom/1");
|
||||||
|
let saved = serde_json::to_string(&decoded).unwrap();
|
||||||
|
assert!(saved.contains("external-drive-linux@9.9.9-beta"), "{saved}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_client_identity_loads() {
|
||||||
|
let decoded: AppConfig = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(decoded.resolved_app_version(), APP_VERSION);
|
||||||
|
assert_eq!(decoded.resolved_user_agent(), USER_AGENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -688,6 +955,15 @@ mod tests {
|
|||||||
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
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]
|
#[test]
|
||||||
fn a_config_predating_ignore_patterns_still_loads() {
|
fn a_config_predating_ignore_patterns_still_loads() {
|
||||||
// Every installed config on disk looks like this; the field must not be
|
// Every installed config on disk looks like this; the field must not be
|
||||||
|
|||||||
+226
-10
@@ -344,6 +344,17 @@ pub enum Request {
|
|||||||
/// or a mirror synced folder), and the move is still made on Proton Drive
|
/// or a mirror synced folder), and the move is still made on Proton Drive
|
||||||
/// without transferring content. Replies with [`Response::Ok`].
|
/// without transferring content. Replies with [`Response::Ok`].
|
||||||
Move { path: String, new_parent: String },
|
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
|
/// Trash a file or folder. `path` is mountpoint-relative. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
Delete { path: String },
|
Delete { path: String },
|
||||||
@@ -443,11 +454,18 @@ pub enum Request {
|
|||||||
DeleteDevice { uid: String },
|
DeleteDevice { uid: String },
|
||||||
/// Adopt an existing device as this machine's, pinning its uid in
|
/// Adopt an existing device as this machine's, pinning its uid in
|
||||||
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
/// `config.json` so a hostname change or reinstall re-attaches to it instead
|
||||||
/// of registering a duplicate (features.md 5.1). `uid: None` clears the pin.
|
/// of registering a duplicate. `uid: None` clears the pin.
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
AdoptDevice { uid: Option<String> },
|
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 (devices.md) ----------------------------------
|
// ---- device folder sync ----------------------------------
|
||||||
/// Add a local folder to this machine's device, uploading its tree and
|
/// Add a local folder to this machine's device, uploading its tree and
|
||||||
/// registering the device on first use. Replies with [`Response::Ok`].
|
/// registering the device on first use. Replies with [`Response::Ok`].
|
||||||
AddSyncFolder { local_path: String },
|
AddSyncFolder { local_path: String },
|
||||||
@@ -462,6 +480,11 @@ pub enum Request {
|
|||||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||||
SetSyncFolderPaused { id: i64, paused: bool },
|
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`.
|
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
SyncNow { id: Option<i64> },
|
SyncNow { id: Option<i64> },
|
||||||
@@ -496,7 +519,7 @@ pub enum Request {
|
|||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
ResolveConflict { path: String, keep: ConflictKeep },
|
ResolveConflict { path: String, keep: ConflictKeep },
|
||||||
/// List the folders under this machine's device that can be synced here,
|
/// List the folders under this machine's device that can be synced here,
|
||||||
/// each with a proposed local path (features.md 5.2). Replies with
|
/// each with a proposed local path. Replies with
|
||||||
/// [`Response::RestorableFolders`].
|
/// [`Response::RestorableFolders`].
|
||||||
ListRestorableFolders,
|
ListRestorableFolders,
|
||||||
/// Attach the given remote device folders to local paths and sync them down.
|
/// Attach the given remote device folders to local paths and sync them down.
|
||||||
@@ -589,6 +612,24 @@ pub enum Request {
|
|||||||
RemovePublicLink { path: String, id: String },
|
RemovePublicLink { path: String, id: String },
|
||||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||||
RemovePublicLinkByUid { uid: String, id: String },
|
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
|
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||||
/// invitation and every member. Each removal is attempted even if an
|
/// invitation and every member. Each removal is attempted even if an
|
||||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||||
@@ -1068,6 +1109,18 @@ pub struct PublicLinkInfo {
|
|||||||
pub created: i64,
|
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`]).
|
/// One invitation addressed to me (in [`Response::Invitations`]).
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct InvitationInfo {
|
pub struct InvitationInfo {
|
||||||
@@ -1162,6 +1215,7 @@ pub enum ActivityKind {
|
|||||||
Sync,
|
Sync,
|
||||||
Rename,
|
Rename,
|
||||||
Move,
|
Move,
|
||||||
|
Copy,
|
||||||
CreateFolder,
|
CreateFolder,
|
||||||
Trash,
|
Trash,
|
||||||
Restore,
|
Restore,
|
||||||
@@ -1218,6 +1272,12 @@ pub struct JobItem {
|
|||||||
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
/// Steps known to need doing, or `0` when unknown (indeterminate progress).
|
||||||
/// May *grow* mid-job as more work is discovered.
|
/// May *grow* mid-job as more work is discovered.
|
||||||
pub total: u64,
|
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.
|
/// One in-flight transfer in a [`Response::Transfers`] snapshot.
|
||||||
@@ -1247,8 +1307,15 @@ pub struct DirEntry {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
/// Modification time, epoch seconds.
|
/// Modification time, epoch seconds.
|
||||||
pub modified: i64,
|
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,
|
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,
|
/// 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
|
/// non-stale blob). Always false for folders. Defaulted for wire-compat with
|
||||||
/// clients/daemons predating the field.
|
/// clients/daemons predating the field.
|
||||||
@@ -1571,6 +1638,10 @@ pub struct RedateSummary {
|
|||||||
/// name implies)` — what a dry run is read for.
|
/// name implies)` — what a dry run is read for.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub samples: Vec<(String, i64, i64)>,
|
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
|
/// How a Google Photos Takeout import is going, or how the last one ended
|
||||||
@@ -1600,6 +1671,10 @@ pub struct ImportSummary {
|
|||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
/// True when the run stopped early because it was cancelled.
|
/// True when the run stopped early because it was cancelled.
|
||||||
pub cancelled: bool,
|
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.
|
/// 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.
|
/// One worker thread in a [`Diagnostics`] report.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct WorkerState {
|
pub struct WorkerState {
|
||||||
@@ -1884,6 +1980,29 @@ pub struct Diagnostics {
|
|||||||
pub control_handler_limit: usize,
|
pub control_handler_limit: usize,
|
||||||
/// Queued mutations not yet drained.
|
/// Queued mutations not yet drained.
|
||||||
pub pending_ops: u64,
|
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`].
|
/// The daemon's reply to a [`Request`].
|
||||||
@@ -1903,17 +2022,17 @@ pub enum Response {
|
|||||||
/// The pin registry.
|
/// The pin registry.
|
||||||
pins: Vec<Pin>,
|
pins: Vec<Pin>,
|
||||||
/// False when the daemon is serving the cached tree because the API is
|
/// False when the daemon is serving the cached tree because the API is
|
||||||
/// unreachable (offline.md Phase 1). Cached and pinned content still
|
/// unreachable. Cached and pinned content still
|
||||||
/// reads; anything else fails until the network is back.
|
/// reads; anything else fails until the network is back.
|
||||||
#[serde(default = "default_online")]
|
#[serde(default = "default_online")]
|
||||||
online: bool,
|
online: bool,
|
||||||
/// Writes accepted locally but not yet uploaded (offline.md Phase 3).
|
/// Writes accepted locally but not yet uploaded.
|
||||||
/// Non-zero means the mount is ahead of the remote — either a copy is
|
/// Non-zero means the mount is ahead of the remote — either a copy is
|
||||||
/// still draining, or it cannot drain because we are offline.
|
/// still draining, or it cannot drain because we are offline.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_uploads: u64,
|
pending_uploads: u64,
|
||||||
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`
|
/// Queued mutations that carry no bytes: `mkdir`, `rename`, `trash`.
|
||||||
/// (offline.md Phase 3b). Counted apart from `pending_uploads` because
|
/// Counted apart from `pending_uploads` because
|
||||||
/// calling a queued `mkdir` an upload is a lie.
|
/// calling a queued `mkdir` an upload is a lie.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pending_changes: u64,
|
pending_changes: u64,
|
||||||
@@ -1946,6 +2065,21 @@ pub enum Response {
|
|||||||
/// resumed, or not paused.
|
/// resumed, or not paused.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
paused_until: Option<i64>,
|
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.
|
/// A human-readable success message.
|
||||||
Ok { message: String },
|
Ok { message: String },
|
||||||
@@ -2121,9 +2255,14 @@ pub enum Response {
|
|||||||
entries: Vec<ShareEntry>,
|
entries: Vec<ShareEntry>,
|
||||||
link: Option<PublicLinkInfo>,
|
link: Option<PublicLinkInfo>,
|
||||||
},
|
},
|
||||||
/// A freshly created public link, carrying the shareable URL (reply to
|
/// A freshly created or changed public link, carrying the shareable URL
|
||||||
/// [`Request::CreatePublicLink`]).
|
/// (reply to [`Request::CreatePublicLink`] and
|
||||||
|
/// [`Request::UpdatePublicLink`]).
|
||||||
PublicLink { link: PublicLinkInfo },
|
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 addressed to me (reply to [`Request::ListInvitations`]).
|
||||||
Invitations { items: Vec<InvitationInfo> },
|
Invitations { items: Vec<InvitationInfo> },
|
||||||
/// Saved public links (reply to [`Request::ListBookmarks`]).
|
/// 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
|
/// The mutation requests must survive a line-delimited JSON round-trip, since
|
||||||
/// that is exactly how they cross the control socket.
|
/// that is exactly how they cross the control socket.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2602,6 +2762,10 @@ mod tests {
|
|||||||
path: "a/b.txt".into(),
|
path: "a/b.txt".into(),
|
||||||
new_parent: "d".into(),
|
new_parent: "d".into(),
|
||||||
},
|
},
|
||||||
|
Request::Copy {
|
||||||
|
path: "a/b.txt".into(),
|
||||||
|
new_parent: "d".into(),
|
||||||
|
},
|
||||||
Request::Delete {
|
Request::Delete {
|
||||||
path: "a/b.txt".into(),
|
path: "a/b.txt".into(),
|
||||||
},
|
},
|
||||||
@@ -3073,6 +3237,7 @@ mod tests {
|
|||||||
id: 3,
|
id: 3,
|
||||||
paused: true,
|
paused: true,
|
||||||
},
|
},
|
||||||
|
Request::ResetSyncFolder { id: 3 },
|
||||||
Request::SetBandwidthLimits {
|
Request::SetBandwidthLimits {
|
||||||
upload: 1_000_000,
|
upload: 1_000_000,
|
||||||
download: 0,
|
download: 0,
|
||||||
@@ -3082,6 +3247,9 @@ mod tests {
|
|||||||
uid: Some("dev-1".into()),
|
uid: Some("dev-1".into()),
|
||||||
},
|
},
|
||||||
Request::AdoptDevice { uid: None },
|
Request::AdoptDevice { uid: None },
|
||||||
|
Request::MigrateDevice {
|
||||||
|
uid: "dev-2".into(),
|
||||||
|
},
|
||||||
Request::ListRestorableFolders,
|
Request::ListRestorableFolders,
|
||||||
Request::RestoreSyncFolders {
|
Request::RestoreSyncFolders {
|
||||||
items: vec![RestoreItem {
|
items: vec![RestoreItem {
|
||||||
@@ -3159,6 +3327,24 @@ mod tests {
|
|||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
id: "url-1".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::ListSharedByMe,
|
||||||
Request::ListActivity { limit: 100 },
|
Request::ListActivity { limit: 100 },
|
||||||
Request::ListSharedWithMe,
|
Request::ListSharedWithMe,
|
||||||
@@ -3533,6 +3719,16 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"RemovePublicLinkByUid",
|
"RemovePublicLinkByUid",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
Request::UpdatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "link".into(),
|
||||||
|
role: None,
|
||||||
|
password: LinkChange::Keep,
|
||||||
|
expires: LinkChange::Keep,
|
||||||
|
},
|
||||||
|
"UpdatePublicLinkByUid",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
Request::StopSharingByUid {
|
Request::StopSharingByUid {
|
||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
@@ -3558,6 +3754,7 @@ mod tests {
|
|||||||
| Request::RemoveShareEntryByUid { uid, .. }
|
| Request::RemoveShareEntryByUid { uid, .. }
|
||||||
| Request::CreatePublicLinkByUid { uid, .. }
|
| Request::CreatePublicLinkByUid { uid, .. }
|
||||||
| Request::RemovePublicLinkByUid { uid, .. }
|
| Request::RemovePublicLinkByUid { uid, .. }
|
||||||
|
| Request::UpdatePublicLinkByUid { uid, .. }
|
||||||
| Request::StopSharingByUid { uid } => uid,
|
| Request::StopSharingByUid { uid } => uid,
|
||||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
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]
|
#[test]
|
||||||
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
fn open_file_accepts_a_request_from_a_client_predating_the_uid_field() {
|
||||||
let legacy = r#"{"OpenFile":{"path":"Documents/ONBOARDING.md"}}"#;
|
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))?;
|
let count: i64 = tx.query_row("SELECT COUNT(*) FROM local_files", [], |r| r.get(0))?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
drop(conn);
|
||||||
|
self.compact_search_indexes_if_due(finished_at)?;
|
||||||
Ok(count)
|
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
|
/// 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
|
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||||
/// needs an immediate rescan or can serve the existing index.
|
/// needs an immediate rescan or can serve the existing index.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Database health reporting and repair (features.md 4.3).
|
//! Database health reporting and repair.
|
||||||
//!
|
//!
|
||||||
//! Everything here exists for the case where something has already gone wrong:
|
//! Everything here exists for the case where something has already gone wrong:
|
||||||
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
//! a crash mid-write, a full disk, a cache that has grown out of proportion to
|
||||||
@@ -91,8 +91,13 @@ impl Db {
|
|||||||
/// Returns an empty vector when the database is sound; otherwise each entry
|
/// 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,
|
/// 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.
|
/// 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>> {
|
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 mut stmt = conn.prepare("PRAGMA integrity_check")?;
|
||||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
let mut problems = Vec::new();
|
let mut problems = Vec::new();
|
||||||
@@ -107,9 +112,11 @@ impl Db {
|
|||||||
Ok(problems)
|
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> {
|
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 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 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))?;
|
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());
|
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]
|
#[test]
|
||||||
fn stats_report_the_schema_and_the_known_tables() {
|
fn stats_report_the_schema_and_the_known_tables() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// 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 {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -440,6 +440,33 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V42)?;
|
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(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -577,7 +604,7 @@ CREATE TABLE trash (
|
|||||||
);
|
);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v8: device sync (devices.md). This machine registers as one Proton Drive
|
/// Schema v8: device sync. This machine registers as one Proton Drive
|
||||||
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
/// **Device** (a share + root folder on the main volume); `device` is a singleton
|
||||||
/// row cached so we reuse the same device across restarts instead of creating a
|
/// row cached so we reuse the same device across restarts instead of creating a
|
||||||
/// new one each run. `sync_folder` is one row per local folder the user added,
|
/// new one each run. `sync_folder` is one row per local folder the user added,
|
||||||
@@ -638,8 +665,8 @@ ALTER TABLE sync_folder ADD COLUMN pending_mode TEXT;
|
|||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
/// Schema v11: writes no longer upload inside the FUSE handler. `release` stages
|
||||||
/// the bytes and records the intended upload here, and a drain worker performs it
|
/// the bytes and records the intended upload here, and a drain worker performs it.
|
||||||
/// (offline.md Phase 3). The row outlives the process, so a write survives both a
|
/// The row outlives the process, so a write survives both a
|
||||||
/// dead network and a restart.
|
/// dead network and a restart.
|
||||||
///
|
///
|
||||||
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
/// `blob_path` points into the content cache's `staging/` dir and `meta_json` is
|
||||||
@@ -666,7 +693,7 @@ CREATE INDEX pending_op_uid ON pending_op(uid);
|
|||||||
|
|
||||||
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
/// Schema v12: `pending_op` also carries mutations that *create* a node, which a
|
||||||
/// revision op never had to describe — it always addressed a node the server had
|
/// revision op never had to describe — it always addressed a node the server had
|
||||||
/// already minted a uid for (offline.md Phase 3b).
|
/// already minted a uid for.
|
||||||
///
|
///
|
||||||
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
/// An offline `create`/`mkdir` cannot get a real uid, so the node is invented
|
||||||
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
/// locally under a `local~<uuid>` placeholder and the op records where it goes
|
||||||
@@ -1056,7 +1083,7 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
|||||||
/// as the last sync and left the file the same length — a fixed-size record
|
/// as the last sync and left the file the same length — a fixed-size record
|
||||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||||
/// older content until something else about the file moved (bugs.md B25).
|
/// older content until something else about the file moved (`docs/BUGS.md` B25).
|
||||||
///
|
///
|
||||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
@@ -1168,8 +1195,8 @@ const MIGRATION_V35: &str = "
|
|||||||
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
/// Schema v36: every node row has a local id (`lid`), phase 2 of
|
/// Schema v36: every node row has a local id (`lid`); see
|
||||||
/// docs/MILESTONE-3.0.0.md.
|
/// docs/ARCHITECTURE.md §2.
|
||||||
///
|
///
|
||||||
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
/// The `uid` was the row's key, so a node had no identity until Drive minted
|
||||||
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
/// one, and a create that landed moved its row to a new key. The `lid` is given
|
||||||
@@ -1330,3 +1357,28 @@ const MIGRATION_V42: &str = "
|
|||||||
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
ALTER TABLE pending_op ADD COLUMN sent_sha1 TEXT;
|
||||||
ALTER TABLE pending_op ADD COLUMN sent_to 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.
|
/// 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
|
/// `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) {
|
let conn = match Self::open_configured(path) {
|
||||||
Ok(conn) => conn,
|
Ok(conn) => conn,
|
||||||
Err(e) if is_corrupt(&e) => {
|
Err(e) if is_corrupt(&e) => {
|
||||||
// Every byte in here is derived from the account or from the
|
// Most of what is in here is derived from the account and is
|
||||||
// content cache directory, so the recovery is to start over —
|
// fetched again. The queue is not: it holds writes accepted
|
||||||
// loudly, and keeping the damaged file for a post-mortem rather
|
// here that Drive does not have yet, and the mirror baselines
|
||||||
// than deleting evidence.
|
// 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!(
|
tracing::error!(
|
||||||
db = %path.display(),
|
db = %path.display(),
|
||||||
error = %e,
|
error = %e,
|
||||||
"cache database is corrupt — moving it aside and rebuilding \
|
"cache database is corrupt; moving it aside, rebuilding, and \
|
||||||
(metadata will be re-fetched; no user data is stored here)"
|
copying the upload queue, sync baselines and pins out of it"
|
||||||
);
|
);
|
||||||
quarantine_corrupt_db(path)?;
|
let damaged = quarantine_corrupt_db(path)?;
|
||||||
Self::open_configured(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),
|
Err(e) => return Err(e),
|
||||||
};
|
};
|
||||||
@@ -254,6 +309,12 @@ impl Db {
|
|||||||
Ok(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.
|
/// A connection for a `SELECT`-only method.
|
||||||
///
|
///
|
||||||
/// Reads see the last committed state, which is the same thing they saw
|
/// 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> {
|
fn open_configured(path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
// WAL: readers never block the single writer. NORMAL sync is the
|
// 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, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
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
|
/// The tables [`salvage`] copies out of a damaged database, in order, each
|
||||||
/// open starts from empty.
|
/// with the rows it keeps.
|
||||||
///
|
///
|
||||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
/// The queue goes with the node rows it names by local id: a create made here
|
||||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
/// has no other record of its node, and a real node keeps its local id when its
|
||||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
/// 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()
|
let stamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.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"),
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -568,8 +568,7 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load one persisted node back by uid. Used to recover the My Files root
|
/// Load one persisted node back by uid. Used to recover the My Files root
|
||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree.
|
||||||
/// (offline.md Phase 1).
|
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
|
|||||||
+161
-43
@@ -15,7 +15,7 @@ use crate::Result;
|
|||||||
pub const OP_REVISION: &str = "revision";
|
pub const OP_REVISION: &str = "revision";
|
||||||
|
|
||||||
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
|
/// The `kind` of a [`PendingOp`] that creates a file that so far exists only
|
||||||
/// locally, under a `local:` placeholder uid (offline.md Phase 3b).
|
/// locally, under a `local:` placeholder uid.
|
||||||
///
|
///
|
||||||
/// The written bytes ride along on the same row rather than as a follow-on
|
/// The written bytes ride along on the same row rather than as a follow-on
|
||||||
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
|
/// [`OP_REVISION`]: draining the create mints the node's real uid, which would
|
||||||
@@ -29,7 +29,7 @@ pub const OP_CREATE: &str = "create";
|
|||||||
pub const OP_MKDIR: &str = "mkdir";
|
pub const OP_MKDIR: &str = "mkdir";
|
||||||
|
|
||||||
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
|
/// The `kind` of a [`PendingOp`] that gives a node a new name, a new parent, or
|
||||||
/// both — the queued form of `mv` (offline.md Phase 3b).
|
/// both — the queued form of `mv`.
|
||||||
///
|
///
|
||||||
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
|
/// `parent_uid` and `name` hold the node's *desired end state*, not a delta, so
|
||||||
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
|
/// a second rename simply replaces the row (see [`Db::enqueue_op`]) and the
|
||||||
@@ -55,8 +55,7 @@ pub struct RenameMeta {
|
|||||||
pub original_name: Option<String>,
|
pub original_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about.
|
||||||
/// (offline.md Phase 3b).
|
|
||||||
///
|
///
|
||||||
/// A node that only ever existed locally never gets one of these: there is
|
/// A node that only ever existed locally never gets one of these: there is
|
||||||
/// nothing to trash remotely, so deleting it just drops its queued ops
|
/// nothing to trash remotely, so deleting it just drops its queued ops
|
||||||
@@ -253,6 +252,25 @@ const UID_NOW: &str = "COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.li
|
|||||||
const PARENT_NOW: &str =
|
const PARENT_NOW: &str =
|
||||||
"COALESCE((SELECT uid FROM nodes WHERE lid = pending_op.parent_lid), parent_uid)";
|
"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
|
/// 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
|
/// real [`NodeUid`] is `{volume}~{link}`, so a placeholder is `local~<lid>` and
|
||||||
/// round-trips through the same `Display`/parse path as any other uid.
|
/// round-trips through the same `Display`/parse path as any other uid.
|
||||||
@@ -261,6 +279,45 @@ const PARENT_NOW: &str =
|
|||||||
/// drain replaces it with the uid the server assigns.
|
/// drain replaces it with the uid the server assigns.
|
||||||
pub const LOCAL_VOLUME: &str = "local";
|
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
|
/// The uid a node made on this machine goes by until Drive gives it one: its
|
||||||
/// row's local id under [`LOCAL_VOLUME`].
|
/// row's local id under [`LOCAL_VOLUME`].
|
||||||
///
|
///
|
||||||
@@ -311,7 +368,7 @@ pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
|||||||
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// A mutation that has been accepted locally but not yet performed against the
|
||||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
/// API — the durable half of the write-back queue.
|
||||||
///
|
///
|
||||||
/// The daemon answers the FUSE call the moment this row and its staged blob are
|
/// The daemon answers the FUSE call the moment this row and its staged blob are
|
||||||
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
|
/// on disk, so a `cp` into the mount runs at disk speed and the upload happens
|
||||||
@@ -523,7 +580,7 @@ impl Db {
|
|||||||
/// land in the order they were made (`docs/BUGS.md` B141). Should it fail,
|
/// 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.
|
/// [`Db::record_op_failure`] or [`Db::release_op_claim`] drops it.
|
||||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
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 tx = conn.transaction()?;
|
||||||
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
let superseded: Option<String> = if op_supersedes(&op.kind) {
|
||||||
let lid = node_lid(&tx, &op.uid)?;
|
let lid = node_lid(&tx, &op.uid)?;
|
||||||
@@ -580,7 +637,7 @@ impl Db {
|
|||||||
name: &str,
|
name: &str,
|
||||||
created_at: i64,
|
created_at: i64,
|
||||||
) -> Result<(i64, Vec<String>)> {
|
) -> Result<(i64, Vec<String>)> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.durable()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let blobs = drop_doomed_ops(&tx, uid)?;
|
let blobs = drop_doomed_ops(&tx, uid)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
@@ -610,7 +667,7 @@ impl Db {
|
|||||||
blob_path: &str,
|
blob_path: &str,
|
||||||
meta_json: &str,
|
meta_json: &str,
|
||||||
) -> Result<Option<AttachedBlob>> {
|
) -> Result<Option<AttachedBlob>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.durable()?;
|
||||||
let lid = node_lid(&conn, uid)?;
|
let lid = node_lid(&conn, uid)?;
|
||||||
let existing: Option<(i64, Option<String>)> = conn
|
let existing: Option<(i64, Option<String>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -673,7 +730,7 @@ impl Db {
|
|||||||
rewrite: impl FnOnce(&str) -> Option<String>,
|
rewrite: impl FnOnce(&str) -> Option<String>,
|
||||||
) -> Result<CreateRetired> {
|
) -> Result<CreateRetired> {
|
||||||
let (local, real) = (landing.local, landing.real);
|
let (local, real) = (landing.local, landing.real);
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.durable()?;
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let row: Option<CreateRow> = tx
|
let row: Option<CreateRow> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -790,12 +847,19 @@ impl Db {
|
|||||||
Ok(())
|
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
|
/// 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
|
/// answer left stays where it was sent. The retry and the trash look
|
||||||
/// there too (`docs/BUGS.md` B184).
|
/// there too (`docs/BUGS.md` B184). A target sent before keeps its first
|
||||||
pub fn note_create_target(&self, id: i64, parent: &str, name: &str) -> Result<()> {
|
/// 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 mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
let sent: Option<String> = tx
|
let sent: Option<String> = tx
|
||||||
@@ -804,15 +868,26 @@ impl Db {
|
|||||||
})
|
})
|
||||||
.optional()?
|
.optional()?
|
||||||
.flatten();
|
.flatten();
|
||||||
let mut targets: Vec<(String, String)> = sent
|
let Some(mut targets) = parse_sent_to(sent.as_deref()) else {
|
||||||
.and_then(|json| serde_json::from_str(&json).ok())
|
tracing::warn!(
|
||||||
.unwrap_or_default();
|
id,
|
||||||
let target = (parent.to_string(), name.to_string());
|
"a queued create's send record does not parse; keeping it"
|
||||||
if !targets.contains(&target) {
|
);
|
||||||
targets.push(target);
|
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(
|
tx.execute(
|
||||||
"UPDATE pending_op SET sent_to = ?2 WHERE id = ?1",
|
"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()?;
|
tx.commit()?;
|
||||||
@@ -822,6 +897,24 @@ impl Db {
|
|||||||
/// The parent and name queued op `id` was sent under as a create, in the
|
/// The parent and name queued op `id` was sent under as a create, in the
|
||||||
/// order it was ([`Db::note_create_target`]).
|
/// order it was ([`Db::note_create_target`]).
|
||||||
pub fn create_targets(&self, id: i64) -> Result<Vec<(String, String)>> {
|
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
|
let sent: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.lock()
|
.lock()
|
||||||
@@ -830,9 +923,7 @@ impl Db {
|
|||||||
})
|
})
|
||||||
.optional()?
|
.optional()?
|
||||||
.flatten();
|
.flatten();
|
||||||
Ok(sent
|
Ok(parse_sent_to(sent.as_deref()).unwrap_or_default())
|
||||||
.and_then(|json| serde_json::from_str(&json).ok())
|
|
||||||
.unwrap_or_default())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The SHA-1 of each blob queued op `id` sent as a create
|
/// The SHA-1 of each blob queued op `id` sent as a create
|
||||||
@@ -880,6 +971,27 @@ impl Db {
|
|||||||
Ok(n > 0)
|
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
|
/// Point a queued create at a new parent and name, for a node renamed or
|
||||||
/// moved before it ever reached the server.
|
/// moved before it ever reached the server.
|
||||||
///
|
///
|
||||||
@@ -887,13 +999,13 @@ impl Db {
|
|||||||
/// make — the node is still only a queued intent, and rewriting that intent
|
/// make — the node is still only a queued intent, and rewriting that intent
|
||||||
/// *is* the rename. Returns false when the create has already drained, in
|
/// *is* the rename. Returns false when the create has already drained, in
|
||||||
/// which case the node has a real uid and the caller must rename it there
|
/// which case the node has a real uid and the caller must rename it there
|
||||||
/// instead (offline.md Phase 3b).
|
/// instead.
|
||||||
///
|
///
|
||||||
/// A create backing off is made due: what it failed on may have been the
|
/// A create backing off is made due: what it failed on may have been the
|
||||||
/// name it no longer wants, such as one held by a queued change. A parked
|
/// name it no longer wants, such as one held by a queued change. A parked
|
||||||
/// create stays parked.
|
/// create stays parked.
|
||||||
pub fn rewrite_op_target(&self, uid: &str, parent_uid: &str, name: &str) -> Result<bool> {
|
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 lid = node_lid(&conn, uid)?;
|
||||||
let parent_lid = node_lid(&conn, parent_uid)?;
|
let parent_lid = node_lid(&conn, parent_uid)?;
|
||||||
let n = conn.execute(
|
let n = conn.execute(
|
||||||
@@ -1128,15 +1240,9 @@ impl Db {
|
|||||||
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, {UID_NOW}, {PARENT_NOW}, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
FROM pending_op
|
FROM pending_op
|
||||||
WHERE next_attempt_at <= ?1
|
WHERE next_attempt_at <= ?1 AND {claimable}
|
||||||
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))
|
|
||||||
ORDER BY id LIMIT 1",
|
ORDER BY id LIMIT 1",
|
||||||
v = LOCAL_VOLUME,
|
claimable = claimable(),
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
|
||||||
))?;
|
))?;
|
||||||
stmt.query_row(params![now], |r| {
|
stmt.query_row(params![now], |r| {
|
||||||
Ok(PendingOp {
|
Ok(PendingOp {
|
||||||
@@ -1209,19 +1315,17 @@ impl Db {
|
|||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
///
|
///
|
||||||
/// Claimed ops are excluded: another worker is already on them, so they are
|
/// Only rows [`claim_next_due_op`](Self::claim_next_due_op) could take
|
||||||
/// not work this one is waiting for, and counting them would have an idle
|
/// count. A claimed op, or one held behind a claimed op on the same node,
|
||||||
/// worker spin on a row it cannot have.
|
/// 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>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
"SELECT MIN(next_attempt_at) FROM pending_op WHERE {claimable}",
|
||||||
WHERE claimed_at = 0 \
|
claimable = claimable(),
|
||||||
AND (parent_uid IS NULL OR substr({PARENT_NOW}, 1, {n}) <> '{v}~')",
|
|
||||||
v = LOCAL_VOLUME,
|
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
|
||||||
),
|
),
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
@@ -1379,12 +1483,26 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
/// [`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> {
|
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 conn = self.conn.lock();
|
||||||
let changed = conn.execute(
|
let changed = conn.execute(
|
||||||
"UPDATE pending_op SET next_attempt_at = ?1
|
&format!(
|
||||||
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
"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],
|
params![now, PARK_UNTIL],
|
||||||
)?;
|
)?;
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ pub struct StoredSyncFolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One per-file sync baseline row: what a path looked like on both sides at the
|
/// One per-file sync baseline row: what a path looked like on both sides at the
|
||||||
/// last successful sync, so the next reconcile can tell which side changed
|
/// last successful sync, so the next reconcile can tell which side changed.
|
||||||
/// (devices.md Phase 2). `remote_rev`/`remote_hash` hold the remote signature —
|
/// `remote_rev`/`remote_hash` hold the remote signature —
|
||||||
/// its modification time and size as strings — since no cheap content hash is
|
/// its modification time and size as strings — since no cheap content hash is
|
||||||
/// exposed; change detection is `(mtime, size)` on each side.
|
/// exposed; change detection is `(mtime, size)` on each side.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -39,12 +39,18 @@ pub struct StoredSyncEntry {
|
|||||||
///
|
///
|
||||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||||
/// second as the last sync — and left the file the same length — from no
|
/// second as the last sync — and left the file the same length — from no
|
||||||
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
/// edit at all, so those never uploaded (`docs/BUGS.md` B25). `None` on a row
|
||||||
/// written before schema 28, which the comparison reads as "compare whole
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
pub local_mtime_ns: Option<i64>,
|
pub local_mtime_ns: Option<i64>,
|
||||||
pub remote_rev: Option<String>,
|
pub remote_rev: Option<String>,
|
||||||
pub remote_hash: 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 {
|
impl Db {
|
||||||
@@ -139,6 +145,17 @@ impl Db {
|
|||||||
Ok(())
|
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
|
/// Queue (or, with `None`, withdraw) a mode the folder should move to once it
|
||||||
/// is safe to switch.
|
/// is safe to switch.
|
||||||
pub fn sync_folder_set_pending_mode(&self, id: i64, mode: Option<&str>) -> Result<()> {
|
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 conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
"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",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![folder_id], |r| {
|
let rows = stmt.query_map(params![folder_id], |r| {
|
||||||
@@ -189,6 +206,7 @@ impl Db {
|
|||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
local_mtime_ns: r.get(6)?,
|
local_mtime_ns: r.get(6)?,
|
||||||
|
remote_revision_id: r.get(7)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -227,15 +245,16 @@ impl Db {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_entry
|
"INSERT INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
remote_hash, local_mtime_ns)
|
remote_hash, local_mtime_ns, remote_revision_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||||
remote_uid = excluded.remote_uid,
|
remote_uid = excluded.remote_uid,
|
||||||
local_mtime = excluded.local_mtime,
|
local_mtime = excluded.local_mtime,
|
||||||
local_size = excluded.local_size,
|
local_size = excluded.local_size,
|
||||||
remote_rev = excluded.remote_rev,
|
remote_rev = excluded.remote_rev,
|
||||||
remote_hash = excluded.remote_hash,
|
remote_hash = excluded.remote_hash,
|
||||||
local_mtime_ns = excluded.local_mtime_ns",
|
local_mtime_ns = excluded.local_mtime_ns,
|
||||||
|
remote_revision_id = excluded.remote_revision_id",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
e.rel_path,
|
e.rel_path,
|
||||||
@@ -245,6 +264,7 @@ impl Db {
|
|||||||
e.remote_rev,
|
e.remote_rev,
|
||||||
e.remote_hash,
|
e.remote_hash,
|
||||||
e.local_mtime_ns,
|
e.local_mtime_ns,
|
||||||
|
e.remote_revision_id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -295,7 +315,7 @@ impl Db {
|
|||||||
{
|
{
|
||||||
let mut select = tx.prepare(
|
let mut select = tx.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
"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
|
FROM sync_entry
|
||||||
WHERE folder_id = ?1
|
WHERE folder_id = ?1
|
||||||
AND (rel_path = ?2 OR substr(rel_path, 1, length(?2) + 1) = ?2 || '/')",
|
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_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
local_mtime_ns: r.get(6)?,
|
local_mtime_ns: r.get(6)?,
|
||||||
|
remote_revision_id: r.get(7)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -321,8 +342,8 @@ impl Db {
|
|||||||
let mut insert = tx.prepare(
|
let mut insert = tx.prepare(
|
||||||
"INSERT OR REPLACE INTO sync_entry
|
"INSERT OR REPLACE INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
remote_hash, local_mtime_ns)
|
remote_hash, local_mtime_ns, remote_revision_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
)?;
|
)?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
let rel = format!("{to_rel}{}", &row.rel_path[from_rel.len()..]);
|
||||||
@@ -335,6 +356,7 @@ impl Db {
|
|||||||
row.remote_rev,
|
row.remote_rev,
|
||||||
row.remote_hash,
|
row.remote_hash,
|
||||||
row.local_mtime_ns,
|
row.local_mtime_ns,
|
||||||
|
row.remote_revision_id,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,7 +367,7 @@ impl Db {
|
|||||||
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
/// Drop the entire baseline for a folder. Used when flipping ondemand→mirror:
|
||||||
/// the local tree was evicted, so the old baseline is stale and would make the
|
/// the local tree was evicted, so the old baseline is stale and would make the
|
||||||
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
/// next reconcile mistake "locally deleted" for "must re-download". Clearing it
|
||||||
/// leaves an empty baseline + full remote = pure download (devices.md P3).
|
/// leaves an empty baseline + full remote = pure download.
|
||||||
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
pub fn sync_entries_clear(&self, folder_id: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -675,8 +675,8 @@ fn foreign_folder(volume: &str, link: &str, parent: NodeUid, name: &str) -> Node
|
|||||||
node
|
node
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recovering the root by uid is what lets the daemon mount offline
|
/// Recovering the root by uid is what lets the daemon mount offline:
|
||||||
/// (offline.md Phase 1): the uid is remembered in `sync_state`, the node
|
/// the uid is remembered in `sync_state`, the node
|
||||||
/// itself comes back out of `nodes`.
|
/// itself comes back out of `nodes`.
|
||||||
#[test]
|
#[test]
|
||||||
fn node_by_uid_recovers_a_stored_node() {
|
fn node_by_uid_recovers_a_stored_node() {
|
||||||
@@ -796,7 +796,7 @@ fn children_if_listed_gated_on_flag() {
|
|||||||
assert_eq!(kids.len(), 2);
|
assert_eq!(kids.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shape of the `mv`-loses-the-file bug (bugs.md B1): a rename deletes the
|
/// The shape of the `mv`-loses-the-file bug (`docs/BUGS.md` B1): a rename deletes the
|
||||||
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB
|
/// moved node's row, so a destination left marked `listed` is rebuilt from the DB
|
||||||
/// without it — the file is gone from the source and absent from the destination,
|
/// without it — the file is gone from the source and absent from the destination,
|
||||||
/// with `rename(2)` having reported success. Clearing the flag is what forces the
|
/// with `rename(2)` having reported success. Clearing the flag is what forces the
|
||||||
@@ -1283,6 +1283,90 @@ fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
|||||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
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 {
|
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||||
LocalEntry {
|
LocalEntry {
|
||||||
path: path.into(),
|
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.
|
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||||
#[test]
|
#[test]
|
||||||
fn local_search_short_query_like_fallback() {
|
fn local_search_short_query_like_fallback() {
|
||||||
@@ -1689,9 +1844,16 @@ fn writing_nodes_does_not_scale_with_the_size_of_the_index() {
|
|||||||
.collect();
|
.collect();
|
||||||
db.upsert_nodes(&batch).unwrap();
|
db.upsert_nodes(&batch).unwrap();
|
||||||
|
|
||||||
let t = Instant::now();
|
// The fastest of a few runs: the rest of the suite runs alongside, and
|
||||||
db.upsert_nodes(&batch).unwrap();
|
// one run that lost the CPU to it read as the regression.
|
||||||
t.elapsed()
|
(0..5)
|
||||||
|
.map(|_| {
|
||||||
|
let t = Instant::now();
|
||||||
|
db.upsert_nodes(&batch).unwrap();
|
||||||
|
t.elapsed()
|
||||||
|
})
|
||||||
|
.min()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
let small = cost_against_index_of(200);
|
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);
|
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.
|
/// A queued write whose baseline is restamped must keep everything else.
|
||||||
///
|
///
|
||||||
/// The restamp happens after our *own* upload seals a new revision under a
|
/// 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();
|
.unwrap();
|
||||||
assert!(db.create_sent(id).unwrap().is_empty());
|
assert!(db.create_sent(id).unwrap().is_empty());
|
||||||
assert!(db.create_targets(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", 10).unwrap();
|
||||||
db.note_create_target(id, &parent, "c").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.note_create_sent(id, "aaaa").unwrap();
|
db.note_create_sent(id, "aaaa").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("the create is still queued");
|
.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.note_create_sent(id, "bbbb").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.delete_ops_for_uid("local~sent").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);
|
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
|
/// 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
|
/// 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
|
/// 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,
|
next_attempt_at: 0,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.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.note_create_sent(id, "aaaa").unwrap();
|
||||||
db.record_op_failure(id, "no answer", 0).unwrap();
|
db.record_op_failure(id, "no answer", 0).unwrap();
|
||||||
db.attach_blob_to_create("local~sent", "/staging/second", "{}")
|
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);
|
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]
|
#[test]
|
||||||
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
fn mode_commit_clears_only_the_intent_it_satisfies() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -4999,6 +5344,7 @@ fn baseline_row(rel: &str) -> StoredSyncEntry {
|
|||||||
local_size: 7,
|
local_size: 7,
|
||||||
remote_rev: Some("1700000000".into()),
|
remote_rev: Some("1700000000".into()),
|
||||||
remote_hash: Some("7".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,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
local_mtime_ns: None,
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -5110,6 +5457,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
|||||||
local_size: 2,
|
local_size: 2,
|
||||||
remote_rev: Some("3".into()),
|
remote_rev: Some("3".into()),
|
||||||
remote_hash: Some("2".into()),
|
remote_hash: Some("2".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
local_mtime_ns: None,
|
local_mtime_ns: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -5735,7 +6083,7 @@ fn cache_eviction_candidates_are_lru_ordered_and_limited() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The B5 checkpoint.** `improvements.md` P2.3 proposes concurrent SQLite
|
/// **The B5 checkpoint.** A performance plan proposed concurrent SQLite
|
||||||
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
|
/// reads, on the premise that the single `Mutex<Connection>` serializes the FUSE
|
||||||
/// workers. This measures what the connection is actually asked to do now that
|
/// workers. This measures what the connection is actually asked to do now that
|
||||||
/// B3 and B4 have landed, so the proposal is decided on evidence.
|
/// B3 and B4 have landed, so the proposal is decided on evidence.
|
||||||
@@ -6287,11 +6635,76 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
|||||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
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
|
/// 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
|
/// 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
|
/// comparison fall back to whole seconds for rows written before the column
|
||||||
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||||||
/// every already-synced file in the folder (bugs.md B25).
|
/// every already-synced file in the folder (`docs/BUGS.md` B25).
|
||||||
#[test]
|
#[test]
|
||||||
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
@@ -6317,6 +6730,7 @@ fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline()
|
|||||||
local_size: 42,
|
local_size: 42,
|
||||||
remote_rev: Some("1700000000".into()),
|
remote_rev: Some("1700000000".into()),
|
||||||
remote_hash: Some("42".into()),
|
remote_hash: Some("42".into()),
|
||||||
|
remote_revision_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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:
|
/// 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.
|
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||||
#[test]
|
#[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]
|
#[test]
|
||||||
fn retry_all_touches_only_ops_that_have_failed() {
|
fn retry_all_touches_only_ops_that_have_failed() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
|||||||
@@ -102,5 +102,5 @@ impl Db {
|
|||||||
Ok(seen)
|
Ok(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync -----------------------------------------
|
||||||
}
|
}
|
||||||
+145
-22
@@ -32,6 +32,16 @@ pub enum Error {
|
|||||||
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
#[error("saved session can no longer unlock your keys — run `pdfs login` again")]
|
||||||
ReloginRequired,
|
ReloginRequired,
|
||||||
|
|
||||||
|
/// The account has a separate mailbox password that has not been entered
|
||||||
|
/// since login, so its keys cannot be unlocked. The fix is
|
||||||
|
/// [`auth::unlock`](crate::auth::unlock), not a new login.
|
||||||
|
#[error("account is locked — enter the mailbox password with `pdfs unlock`")]
|
||||||
|
Locked,
|
||||||
|
|
||||||
|
/// The mailbox password was checked against the account and is wrong.
|
||||||
|
#[error("that mailbox password does not unlock your account")]
|
||||||
|
WrongMailboxPassword,
|
||||||
|
|
||||||
/// The login was gated behind human verification. Not a failure the user can
|
/// The login was gated behind human verification. Not a failure the user can
|
||||||
/// fix by retrying: the challenge has to be solved and the login restarted
|
/// fix by retrying: the challenge has to be solved and the login restarted
|
||||||
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
/// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)).
|
||||||
@@ -144,19 +154,20 @@ impl CoreError {
|
|||||||
/// through the boxes the drain deals in, where the concrete type survives
|
/// through the boxes the drain deals in, where the concrete type survives
|
||||||
/// but the static one does not.
|
/// but the static one does not.
|
||||||
///
|
///
|
||||||
/// A failure that is not an API response at all — a transport error, a
|
/// A transport error, a timeout or a TLS failure is what being offline
|
||||||
/// timeout, a TLS failure — is what being offline looks like from here, so
|
/// looks like from here, so it classifies as [`ErrorKind::Offline`] rather
|
||||||
/// it classifies as [`ErrorKind::Offline`] rather than
|
/// than [`ErrorKind::Remote`]: the request never reached Proton, and
|
||||||
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
|
/// telling the user their connection is down beats telling them the
|
||||||
/// user their connection is down beats telling them the server refused.
|
/// 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 {
|
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
|
||||||
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
Self::new(api_kind_of(e), format!("{what}: {e}"))
|
||||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
|
||||||
// A `ProtonError` that is not an `Api` variant never reached the
|
|
||||||
// API; so does anything that is not a `ProtonError` at all.
|
|
||||||
_ => ErrorKind::Offline,
|
|
||||||
};
|
|
||||||
Self::new(kind, format!("{what}: {e}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prefix the message while keeping the classification.
|
/// Prefix the message while keeping the classification.
|
||||||
@@ -198,16 +209,57 @@ impl From<serde_json::Error> for CoreError {
|
|||||||
|
|
||||||
impl From<Error> for CoreError {
|
impl From<Error> for CoreError {
|
||||||
fn from(e: Error) -> Self {
|
fn from(e: Error) -> Self {
|
||||||
let kind = match &e {
|
Self::new(core_kind(&e), e.to_string())
|
||||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
}
|
||||||
ErrorKind::Denied
|
}
|
||||||
}
|
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
/// What this crate's [`Error`] means to a front-end.
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
fn core_kind(e: &Error) -> ErrorKind {
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
match e {
|
||||||
_ => ErrorKind::Internal,
|
Error::NotLoggedIn
|
||||||
};
|
| Error::ReloginRequired
|
||||||
Self::new(kind, e.to_string())
|
| 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,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.
|
/// The result every request-serving method in the daemon returns.
|
||||||
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
pub type CoreResult<T> = std::result::Result<T, CoreError>;
|
||||||
|
|
||||||
@@ -297,6 +375,25 @@ mod tests {
|
|||||||
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
|
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
|
/// 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
|
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
|
||||||
/// request that could never succeed.
|
/// request that could never succeed.
|
||||||
@@ -327,6 +424,32 @@ mod tests {
|
|||||||
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
|
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
|
/// `Error` is the plumbing type; converting it must classify as sharply as
|
||||||
/// the direct path, or the two disagree about the same failure.
|
/// the direct path, or the two disagree about the same failure.
|
||||||
#[test]
|
#[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
//! A secret held in the Linux kernel keyring, for as long as the user is logged in.
|
||||||
|
//!
|
||||||
|
//! This is where the key passphrases of a two-password account live by default:
|
||||||
|
//! the mailbox password is never stored, and the secrets derived from it are
|
||||||
|
//! kept in kernel memory — not on disk, not in the Secret Service — and vanish
|
||||||
|
//! when the user's last session ends or the machine reboots. Both the daemon and
|
||||||
|
//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring.
|
||||||
|
//! It is also where session tokens wait while the Secret Service refuses them
|
||||||
|
//! ([`UNSAVED_TOKENS`]), so they outlive the process that holds them.
|
||||||
|
//!
|
||||||
|
//! Kernel keys are not paged to swap, are invisible to other users, and are
|
||||||
|
//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a
|
||||||
|
//! process's heap. Processes of the same user can still read them, exactly as
|
||||||
|
//! they could ptrace the daemon: this protects against disk, backup and other
|
||||||
|
//! users, not against code already running as you.
|
||||||
|
//!
|
||||||
|
//! The keyring may be unavailable (a container, a kernel built without
|
||||||
|
//! `CONFIG_KEYS`); every operation then reports it as an error and the caller
|
||||||
|
//! falls back to asking again, never to storing the secret somewhere weaker.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// One secret in the user keyring, by its description. Descriptions are
|
||||||
|
/// namespaced so they cannot collide with another program's keys.
|
||||||
|
pub struct Key {
|
||||||
|
description: &'static [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account's key passphrases (see the module docs).
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub const PASSPHRASES: Key = Key {
|
||||||
|
description: b"pdfs:key-passphrases\0",
|
||||||
|
};
|
||||||
|
/// Tests must never touch a real unlock.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub const PASSPHRASES: Key = Key {
|
||||||
|
description: b"pdfs:key-passphrases:test\0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Session tokens the Secret Service refused to store, until it takes them.
|
||||||
|
/// A refresh token held only in a process's memory dies with the process.
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub const UNSAVED_TOKENS: Key = Key {
|
||||||
|
description: b"pdfs:unsaved-tokens\0",
|
||||||
|
};
|
||||||
|
#[cfg(test)]
|
||||||
|
pub const UNSAVED_TOKENS: Key = Key {
|
||||||
|
description: b"pdfs:unsaved-tokens:test\0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_TYPE: &[u8] = b"user\0";
|
||||||
|
|
||||||
|
const KEY_SPEC_USER_KEYRING: i32 = -4;
|
||||||
|
const KEYCTL_REVOKE: libc::c_long = 3;
|
||||||
|
const KEYCTL_SETPERM: libc::c_long = 5;
|
||||||
|
const KEYCTL_SEARCH: libc::c_long = 10;
|
||||||
|
const KEYCTL_READ: libc::c_long = 11;
|
||||||
|
|
||||||
|
/// Possessor and owner may view, read, write, search, link and set attributes;
|
||||||
|
/// group and others get nothing.
|
||||||
|
///
|
||||||
|
/// A new key's default grants the owner only *view*, and read access to the
|
||||||
|
/// possessor. A process possesses the user keyring only when its session
|
||||||
|
/// keyring links to it, which a systemd user service or a container does not
|
||||||
|
/// guarantee — so with the default, the daemon could store a key it then
|
||||||
|
/// cannot read back. The owner is the same user that could ptrace the daemon
|
||||||
|
/// anyway, so granting it read access gives up nothing.
|
||||||
|
const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000;
|
||||||
|
|
||||||
|
fn last_error<T>() -> io::Result<T> {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(description: &[u8]) -> io::Result<Option<libc::c_long>> {
|
||||||
|
// SAFETY: both strings are NUL-terminated and outlive the call.
|
||||||
|
let id = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_SEARCH,
|
||||||
|
KEY_SPEC_USER_KEYRING,
|
||||||
|
KEY_TYPE.as_ptr(),
|
||||||
|
description.as_ptr(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if id >= 0 {
|
||||||
|
return Ok(Some(id));
|
||||||
|
}
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
match err.raw_os_error() {
|
||||||
|
// ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone.
|
||||||
|
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||||
|
_ => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop this process from being core-dumped or ptraced by an unprivileged
|
||||||
|
/// process of the same user, so the secrets in its memory stay out of crash
|
||||||
|
/// reports. Call once at start of a long-lived process that holds them.
|
||||||
|
pub fn forbid_dumps() {
|
||||||
|
// SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process.
|
||||||
|
let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
|
||||||
|
if rc != 0 {
|
||||||
|
tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
/// Store `secret`, replacing any earlier one.
|
||||||
|
pub fn store(&self, secret: &[u8]) -> io::Result<()> {
|
||||||
|
// SAFETY: strings are NUL-terminated; the payload pointer/length pair
|
||||||
|
// describes `secret`, which outlives the call.
|
||||||
|
let id = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_add_key,
|
||||||
|
KEY_TYPE.as_ptr(),
|
||||||
|
self.description.as_ptr(),
|
||||||
|
secret.as_ptr(),
|
||||||
|
secret.len(),
|
||||||
|
KEY_SPEC_USER_KEYRING,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if id < 0 {
|
||||||
|
return last_error();
|
||||||
|
}
|
||||||
|
// SAFETY: plain integer arguments.
|
||||||
|
let rc = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_SETPERM,
|
||||||
|
id,
|
||||||
|
PERM_OWNER_AND_POSSESSOR,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
|
// A key whose permissions are unknown is not one to leave behind.
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
let _ = self.clear();
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored secret, or `None` when there is none.
|
||||||
|
pub fn load(&self) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||||
|
let Some(id) = find(self.description)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
// Size first, then read; retry if the key was replaced in between.
|
||||||
|
for _ in 0..3 {
|
||||||
|
// SAFETY: a null buffer asks the kernel only for the payload length.
|
||||||
|
let len = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_READ,
|
||||||
|
id,
|
||||||
|
std::ptr::null_mut::<u8>(),
|
||||||
|
0usize,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if len < 0 {
|
||||||
|
return match io::Error::last_os_error().raw_os_error() {
|
||||||
|
Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None),
|
||||||
|
_ => last_error(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut buf = Zeroizing::new(vec![0u8; len as usize]);
|
||||||
|
// SAFETY: `buf` is `len` writable bytes.
|
||||||
|
let read = unsafe {
|
||||||
|
libc::syscall(
|
||||||
|
libc::SYS_keyctl,
|
||||||
|
KEYCTL_READ,
|
||||||
|
id,
|
||||||
|
buf.as_mut_ptr(),
|
||||||
|
buf.len(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if read < 0 {
|
||||||
|
return last_error();
|
||||||
|
}
|
||||||
|
if read as usize <= buf.len() {
|
||||||
|
buf.truncate(read as usize);
|
||||||
|
return Ok(Some(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::other("the key changed while it was being read"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the stored secret. Succeeds when there is none.
|
||||||
|
pub fn clear(&self) -> io::Result<()> {
|
||||||
|
let Some(id) = find(self.description)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// SAFETY: plain integer arguments.
|
||||||
|
let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) };
|
||||||
|
if rc < 0 {
|
||||||
|
return match io::Error::last_os_error().raw_os_error() {
|
||||||
|
Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()),
|
||||||
|
_ => last_error(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// The kernel keyring is process-wide state and a sandbox may not offer it,
|
||||||
|
// so one test covers the whole round trip and skips when it is unavailable.
|
||||||
|
#[test]
|
||||||
|
fn stored_secret_round_trips_and_clears() {
|
||||||
|
if PASSPHRASES.store(b"probe").is_err() {
|
||||||
|
eprintln!("kernel keyring unavailable; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let saved = PASSPHRASES
|
||||||
|
.load()
|
||||||
|
.unwrap()
|
||||||
|
.expect("stored secret is readable");
|
||||||
|
assert_eq!(saved.as_slice(), b"probe");
|
||||||
|
|
||||||
|
PASSPHRASES.store(b"replaced").unwrap();
|
||||||
|
assert_eq!(PASSPHRASES.load().unwrap().unwrap().as_slice(), b"replaced");
|
||||||
|
|
||||||
|
PASSPHRASES.clear().unwrap();
|
||||||
|
assert!(PASSPHRASES.load().unwrap().is_none());
|
||||||
|
PASSPHRASES.clear().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +10,13 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod exiftime;
|
||||||
|
pub mod kernelkey;
|
||||||
|
pub mod keycheck;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
|
pub mod mountfail;
|
||||||
|
pub mod mountpoint;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
pub mod places;
|
pub mod places;
|
||||||
@@ -22,8 +27,10 @@ pub mod service;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod similar;
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
pub mod syncroot;
|
||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
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
|
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||||
/// database without bound. Reaching it stops the walk early.
|
/// 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
|
/// 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
|
/// amortises, small enough that the writer lock is released often (FUSE
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! 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,
|
||||||
|
/// Set when the mount was refused because the folder already has files in
|
||||||
|
/// it, so the app can explain that in the user's language. Absent in a
|
||||||
|
/// file an older daemon wrote.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub not_empty: Option<crate::mountpoint::NotEmpty>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
not_empty: None,
|
||||||
|
};
|
||||||
|
record(&dir, &failure).unwrap();
|
||||||
|
assert_eq!(read(&dir), Some(failure));
|
||||||
|
clear(&dir);
|
||||||
|
assert_eq!(read(&dir), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_an_older_daemon_wrote_still_reads() {
|
||||||
|
let old = r#"{"error":"missing key","since":1,"attempts":2,"next_attempt":3}"#;
|
||||||
|
let failure: MountFailure = serde_json::from_str(old).unwrap();
|
||||||
|
assert_eq!(failure.not_empty, None);
|
||||||
|
assert_eq!(failure.attempts, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! Whether a folder can take the My files mount without hiding anything.
|
||||||
|
//!
|
||||||
|
//! FUSE mounts over a folder that already has files in it without a word. The
|
||||||
|
//! files then sit under the mount, out of sight: not uploaded, and back only
|
||||||
|
//! once the service stops, so they look deleted (`docs/BUGS.md` B277, #67).
|
||||||
|
//! The daemon refuses such a folder, and the app refuses it when it is picked.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The file system type the kernel reports for this client's own mounts.
|
||||||
|
pub const OWN_FS_TYPE: &str = "fuse.protondrive";
|
||||||
|
|
||||||
|
/// How many of a folder's entries an error names.
|
||||||
|
const NAMED_ENTRIES: usize = 3;
|
||||||
|
|
||||||
|
/// A folder that already has something in it.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NotEmpty {
|
||||||
|
/// The folder.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// The first few names in it, sorted.
|
||||||
|
pub entries: Vec<String>,
|
||||||
|
/// How many entries it has in all.
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for NotEmpty {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} is not empty (holds ", self.path.display())?;
|
||||||
|
write!(f, "{}", self.entries.join(", "))?;
|
||||||
|
let rest = self.count.saturating_sub(self.entries.len());
|
||||||
|
if rest > 0 {
|
||||||
|
write!(f, " and {rest} more")?;
|
||||||
|
}
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"); My files would hide those files. Move them out, or choose another folder for My files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for NotEmpty {}
|
||||||
|
|
||||||
|
/// What is in `dir`, if anything. A folder that does not exist yet is empty.
|
||||||
|
/// Hidden entries count: a `.directory` or a `.Trash-1000` hidden under the
|
||||||
|
/// mount is as lost as any other file.
|
||||||
|
pub fn not_empty(dir: &Path) -> std::io::Result<Option<NotEmpty>> {
|
||||||
|
let read = match std::fs::read_dir(dir) {
|
||||||
|
Ok(read) => read,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let mut names = read
|
||||||
|
.map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
|
||||||
|
.collect::<std::io::Result<Vec<_>>>()?;
|
||||||
|
if names.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
names.sort();
|
||||||
|
let count = names.len();
|
||||||
|
names.truncate(NAMED_ENTRIES);
|
||||||
|
Ok(Some(NotEmpty {
|
||||||
|
path: dir.to_path_buf(),
|
||||||
|
entries: names,
|
||||||
|
count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file system type mounted at `path`, as `/proc/self/mountinfo` names it
|
||||||
|
/// (`ext4`, `fuse.protondrive`, …), or `None` when nothing is mounted there.
|
||||||
|
pub fn mounted_fs_type(path: &Path) -> Option<String> {
|
||||||
|
let wanted = path.canonicalize().ok()?;
|
||||||
|
let mounts = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let fields: Vec<_> = line.split_whitespace().collect();
|
||||||
|
let separator = fields.iter().position(|field| *field == "-")?;
|
||||||
|
let encoded = *fields.get(4)?;
|
||||||
|
let decoded = encoded.replace("\\040", " ").replace("\\011", "\t");
|
||||||
|
let mountpoint = PathBuf::from(decoded);
|
||||||
|
if mountpoint.canonicalize().ok()? == wanted {
|
||||||
|
Some(fields.get(separator + 1)?.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("pdfs-mountpoint-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_or_missing_folder_takes_the_mount() {
|
||||||
|
let dir = temp_dir("empty");
|
||||||
|
assert_eq!(not_empty(&dir).unwrap(), None);
|
||||||
|
assert_eq!(not_empty(&dir.join("missing")).unwrap(), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_with_files_is_refused_and_named() {
|
||||||
|
let dir = temp_dir("files");
|
||||||
|
for name in ["report.pdf", "Notes", ".directory", "zeta.txt"] {
|
||||||
|
std::fs::write(dir.join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 4);
|
||||||
|
assert_eq!(found.entries, [".directory", "Notes", "report.pdf"]);
|
||||||
|
let message = found.to_string();
|
||||||
|
assert!(message.contains("is not empty"), "{message}");
|
||||||
|
assert!(
|
||||||
|
message.contains(".directory, Notes, report.pdf and 1 more"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hidden_file_alone_is_enough_to_refuse() {
|
||||||
|
let dir = temp_dir("hidden");
|
||||||
|
std::fs::write(dir.join(".Trash-1000"), b"").unwrap();
|
||||||
|
let found = not_empty(&dir).unwrap().expect("must refuse");
|
||||||
|
assert_eq!(found.count, 1);
|
||||||
|
assert!(!found.to_string().contains("more"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_folder_is_not_a_mount() {
|
||||||
|
let dir = temp_dir("plain");
|
||||||
|
assert_eq!(mounted_fs_type(&dir), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,11 +189,21 @@ fn spawn(argv: &[String], path: &Path) {
|
|||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.process_group(0)
|
.process_group(0)
|
||||||
.spawn();
|
.spawn();
|
||||||
if let Err(e) = spawned {
|
match spawned {
|
||||||
tracing::error!(command = ?argv, "open failed: {e}");
|
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
|
/// [`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
|
/// 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.
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn rule(patterns: &[&str], command: &[&str], terminal: bool) -> OpenRule {
|
||||||
OpenRule {
|
OpenRule {
|
||||||
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The machine profile that travels with a device (features.md 5.3).
|
//! The machine profile that travels with a device.
|
||||||
//!
|
//!
|
||||||
//! Sync folder mappings, pins, and the settings that shape them live in
|
//! Sync folder mappings, pins, and the settings that shape them live in
|
||||||
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
//! `cache.db` and `config.json` — both on the local disk. When that disk dies,
|
||||||
@@ -19,9 +19,31 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::mounts::MountMode;
|
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";
|
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.
|
/// Folder, directly under the device root, that holds the profile.
|
||||||
///
|
///
|
||||||
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
/// The 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
|
/// Hostname of the machine that wrote it, so a restore prompt can say
|
||||||
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
/// *"restore settings from laptop-xyz?"* rather than quoting a uid.
|
||||||
pub hostname: String,
|
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.
|
/// When it was written, epoch seconds.
|
||||||
pub saved_at: i64,
|
pub saved_at: i64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -92,6 +118,25 @@ pub struct Profile {
|
|||||||
pub mountpoint: Option<String>,
|
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 {
|
impl Profile {
|
||||||
/// Parse a profile document, refusing one written by a newer client.
|
/// Parse a profile document, refusing one written by a newer client.
|
||||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||||
@@ -122,6 +167,7 @@ mod tests {
|
|||||||
version: PROFILE_VERSION,
|
version: PROFILE_VERSION,
|
||||||
device_uid: "dev-1".to_string(),
|
device_uid: "dev-1".to_string(),
|
||||||
hostname: "laptop-xyz".to_string(),
|
hostname: "laptop-xyz".to_string(),
|
||||||
|
machine_id: Some("0123456789abcdef0123456789abcdef".to_string()),
|
||||||
saved_at: 1_700_000_000,
|
saved_at: 1_700_000_000,
|
||||||
folders: vec![ProfileFolder {
|
folders: vec![ProfileFolder {
|
||||||
remote_uid: "vol~link".to_string(),
|
remote_uid: "vol~link".to_string(),
|
||||||
@@ -219,6 +265,52 @@ mod tests {
|
|||||||
assert!(p.ignore_patterns.is_none());
|
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]
|
#[test]
|
||||||
fn future_version_is_refused() {
|
fn future_version_is_refused() {
|
||||||
let mut p = sample();
|
let mut p = sample();
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const SCRIPTS: [(&str, &str); 2] = [
|
|||||||
/// `NAUTILUS_SCRIPT_SELECTED_FILE_PATHS`; the positional args carry the same
|
/// `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
|
/// 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.
|
/// 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 {
|
fn script_body(subcommand: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"#!/bin/sh\n\
|
"#!/bin/sh\n\
|
||||||
@@ -41,7 +46,7 @@ fn script_body(subcommand: &str) -> String {
|
|||||||
\n\
|
\n\
|
||||||
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
printf '%s' \"$paths\" | while IFS= read -r path; do\n\
|
||||||
\x20 [ -n \"$path\" ] || continue\n\
|
\x20 [ -n \"$path\" ] || continue\n\
|
||||||
\x20 pdfs {subcommand} \"$path\" || exit 1\n\
|
\x20 pdfs {subcommand} -- \"$path\" || exit 1\n\
|
||||||
done\n"
|
done\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,3 +100,13 @@ fn write_scripts(dir: &PathBuf) -> io::Result<()> {
|
|||||||
tracing::debug!("installed file-manager scripts in {dir:?}");
|
tracing::debug!("installed file-manager scripts in {dir:?}");
|
||||||
Ok(())
|
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""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Selective-sync ignore rules for mirror folders (features.md 1.1).
|
//! Selective-sync ignore rules for mirror folders.
|
||||||
//!
|
//!
|
||||||
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
//! A mirror folder otherwise uploads every path beneath its root, which for a
|
||||||
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
//! project directory means `node_modules/`, `.git/`, and `target/` — quota
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+253
-27
@@ -346,6 +346,11 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.insert(file.to_string(), body);
|
.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 scan = TakeoutScan::default();
|
||||||
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
let mut seen_albums: HashMap<String, ()> = HashMap::new();
|
||||||
|
|
||||||
@@ -364,9 +369,13 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let sidecar = per_folder_sidecars
|
let found = indexes
|
||||||
.get(&folder)
|
.get(folder.as_str())
|
||||||
.and_then(|in_folder| find_sidecar(&file_name, in_folder))
|
.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())
|
.and_then(|body| serde_json::from_slice::<PhotoSidecar>(body).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@@ -388,6 +397,7 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
let name = sidecar
|
let name = sidecar
|
||||||
.title
|
.title
|
||||||
.as_deref()
|
.as_deref()
|
||||||
|
.filter(|_| title_is_ours)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
.filter(|title| !title.is_empty() && same_extension(title, &file_name))
|
||||||
.unwrap_or(&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 between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||||
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
/// `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 year = year - i64::from(month <= 2);
|
||||||
let era = if year >= 0 { year } else { year - 399 } / 400;
|
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||||
let year_of_era = year - era * 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))
|
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
|
/// 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
|
/// unknown trailing suffix, a prefix match for the case where truncation cut
|
||||||
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
/// 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>(
|
fn find_sidecar<'a>(
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
index: &SidecarIndex<'a>,
|
||||||
) -> Option<&'a Vec<u8>> {
|
) -> Option<((u8, usize), &'a Vec<u8>)> {
|
||||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
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 {
|
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -645,7 +744,7 @@ fn find_sidecar<'a>(
|
|||||||
best = Some((rank, body));
|
best = Some((rank, body));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
best.map(|(_, body)| body)
|
best
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
/// 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
|
// 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
|
// 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
|
// localized suffix too; the longest surviving prefix wins.
|
||||||
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the
|
//
|
||||||
// longer photo.
|
// Only for a sidecar whose name is as long as Google lets one get: a name
|
||||||
let mut longest: Option<usize> = None;
|
// that short of the cap was not cut, so a prefix of it is the name of a
|
||||||
for head in suffix_boundaries(stem) {
|
// different photo. Without this, `IMG_1.jpg`'s sidecar gave its name and
|
||||||
if !head.is_empty()
|
// date to an `IMG_12.jpg` that had none (`docs/BUGS.md` B225).
|
||||||
&& file_name.starts_with(head)
|
if stem.chars().count() + ".json".len() >= SIDECAR_NAME_CAP - 4 {
|
||||||
&& longest.is_none_or(|len| head.len() > len)
|
let mut longest: Option<usize> = None;
|
||||||
{
|
for head in suffix_boundaries(stem) {
|
||||||
longest = Some(head.len());
|
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
|
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
||||||
@@ -994,21 +1118,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn longest_prefix_wins_when_the_photo_name_itself_was_truncated() {
|
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();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_1.json",
|
short,
|
||||||
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||||
);
|
);
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_12.json",
|
long,
|
||||||
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||||
);
|
);
|
||||||
let scan = assemble(
|
let scan = assemble(
|
||||||
vec![entry(
|
vec![entry(
|
||||||
0,
|
0,
|
||||||
"Takeout/Google Photos/Photos from 2019/IMG_12345.jpg",
|
"Takeout/Google Photos/Photos from 2019/PXL_20190101_120000000.PORTRAIT_ORIGINAL_EXPOSURE.jpg",
|
||||||
10,
|
10,
|
||||||
)],
|
)],
|
||||||
&sidecars,
|
&sidecars,
|
||||||
@@ -1017,6 +1145,104 @@ mod tests {
|
|||||||
assert_eq!(scan.photos[0].capture_time, Some(2));
|
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]
|
#[test]
|
||||||
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
fn sidecar_title_replaces_a_mangled_on_disk_name() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
@@ -1106,8 +1332,8 @@ mod tests {
|
|||||||
));
|
));
|
||||||
let file = std::fs::File::create(&path).unwrap();
|
let file = std::fs::File::create(&path).unwrap();
|
||||||
let mut zip = zip::ZipWriter::new(file);
|
let mut zip = zip::ZipWriter::new(file);
|
||||||
let options: zip::write::FileOptions<'_, ()> =
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
.compression_method(zip::CompressionMethod::Stored);
|
||||||
for (name, body) in files {
|
for (name, body) in files {
|
||||||
zip.start_file(*name, options).unwrap();
|
zip.start_file(*name, options).unwrap();
|
||||||
zip.write_all(body).unwrap();
|
zip.write_all(body).unwrap();
|
||||||
|
|||||||
@@ -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 super::*;
|
||||||
use crate::shutdown::Shutdown;
|
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
|
/// 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
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
@@ -143,7 +144,7 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
// create, and re-fetching would replace the size and mtime of the
|
// create, and re-fetching would replace the size and mtime of the
|
||||||
// write we just accepted with the stale revision's — making a file
|
// write we just accepted with the stale revision's — making a file
|
||||||
// that was copied in seconds ago read as empty until its upload
|
// that was copied in seconds ago read as empty until its upload
|
||||||
// lands (offline.md Phase 3).
|
// lands.
|
||||||
//
|
//
|
||||||
// A write that has already drained is the same story one step later:
|
// A write that has already drained is the same story one step later:
|
||||||
// the drain brought the tree level with the revision it sealed, so
|
// the drain brought the tree level with the revision it sealed, so
|
||||||
@@ -375,7 +376,7 @@ pub(super) async fn run_event_sync(
|
|||||||
// server head; persist it so the next restart resumes instead of
|
// server head; persist it so the next restart resumes instead of
|
||||||
// reseeding (which would skip everything that changed offline).
|
// reseeding (which would skip everything that changed offline).
|
||||||
// Seeding needs the network, and this task also runs on mounts that
|
// Seeding needs the network, and this task also runs on mounts that
|
||||||
// started offline (offline.md Phase 1) — so retry rather than giving up,
|
// started offline — so retry rather than giving up,
|
||||||
// which used to disable live sync for the life of the daemon.
|
// which used to disable live sync for the life of the daemon.
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Nothing says what changed before this cursor, so a persisted SDK
|
// Nothing says what changed before this cursor, so a persisted SDK
|
||||||
@@ -402,6 +403,12 @@ pub(super) async fn run_event_sync(
|
|||||||
}
|
}
|
||||||
break Some(c.clone());
|
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) => {
|
Err(e) => {
|
||||||
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
warn!(error = %e, ?delay, "seed event cursor failed; retrying");
|
||||||
tokio::time::sleep(delay).await;
|
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");
|
debug!("no photos volume; photo event sync not started");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Err(error) if session_revoked(&error) => {
|
||||||
|
core.mark_session_expired("read the photos root", &error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warn!(%error, "reading the photos root failed; retrying");
|
warn!(%error, "reading the photos root failed; retrying");
|
||||||
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||||
@@ -660,7 +671,7 @@ pub(super) fn run_local_index(
|
|||||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
// Interruptible, so teardown joins this thread instead of leaving it to
|
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||||
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
// keep rewriting the index for a mount that is gone (`docs/BUGS.md` B44).
|
||||||
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -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
|
// The walk has no idea how many files it will find, so the job counts what it
|
||||||
// has seen and stays indeterminate.
|
// 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");
|
job.detail("Scanning your files");
|
||||||
|
let mut failed = false;
|
||||||
let walked = localindex::scan(&[home], &excludes, |batch| {
|
let walked = localindex::scan(&[home], &excludes, |batch| {
|
||||||
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
if let Err(e) = db.local_upsert_batch(generation, &batch) {
|
||||||
warn!(error = %e, "local index: batch write failed");
|
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,
|
// Prune what this scan did not see, unless it did not see everything:
|
||||||
// even if some batches failed — a partial index still beats none.
|
// 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");
|
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!(
|
Ok(indexed) => info!(
|
||||||
walked,
|
walked,
|
||||||
indexed,
|
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 MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
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
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
/// into an error that names what is still shared.
|
/// into an error that names what is still shared.
|
||||||
fn stop_sharing_reply(
|
fn stop_sharing_reply(
|
||||||
@@ -99,7 +110,7 @@ struct ControlHandlerPermit;
|
|||||||
impl ControlHandlerPermit {
|
impl ControlHandlerPermit {
|
||||||
fn acquire() -> Option<Self> {
|
fn acquire() -> Option<Self> {
|
||||||
ACTIVE_CONTROL_HANDLERS
|
ACTIVE_CONTROL_HANDLERS
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
.try_update(Ordering::AcqRel, Ordering::Acquire, |active| {
|
||||||
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
(active < MAX_CONTROL_HANDLERS).then_some(active + 1)
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
@@ -334,6 +345,9 @@ pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) ->
|
|||||||
budget: core.cache.budget(),
|
budget: core.cache.budget(),
|
||||||
pins,
|
pins,
|
||||||
online: core.online.load(Ordering::Relaxed),
|
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_uploads: queued.uploads.max(0) as u64 + landing,
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
}
|
}
|
||||||
@@ -369,11 +383,12 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
RefreshScope::Photos { .. } => &[],
|
RefreshScope::Photos { .. } => &[],
|
||||||
},
|
},
|
||||||
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
R::AdoptDevice { .. } | R::MigrateDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
R::AddSyncFolder { .. }
|
R::AddSyncFolder { .. }
|
||||||
| R::RemoveSyncFolder { .. }
|
| R::RemoveSyncFolder { .. }
|
||||||
| R::SetSyncFolderMode { .. }
|
| R::SetSyncFolderMode { .. }
|
||||||
| R::SetSyncFolderPaused { .. }
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::ResetSyncFolder { .. }
|
||||||
| R::RestoreSyncFolders { .. }
|
| R::RestoreSyncFolders { .. }
|
||||||
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
R::RetryPendingOp { .. } => &[Topic::Queue],
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
@@ -389,6 +404,8 @@ fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
|||||||
| R::CreatePublicLinkByUid { .. }
|
| R::CreatePublicLinkByUid { .. }
|
||||||
| R::RemovePublicLink { .. }
|
| R::RemovePublicLink { .. }
|
||||||
| R::RemovePublicLinkByUid { .. }
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::UpdatePublicLink { .. }
|
||||||
|
| R::UpdatePublicLinkByUid { .. }
|
||||||
| R::StopSharing { .. }
|
| R::StopSharing { .. }
|
||||||
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
R::LeaveShared { .. }
|
R::LeaveShared { .. }
|
||||||
@@ -908,10 +925,22 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
))),
|
))),
|
||||||
Ok((file, len)) => {
|
Ok((file, len)) => {
|
||||||
let photos = core.photos();
|
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 {
|
let metadata = proton_drive_rs::PhotoUploadMetadata {
|
||||||
capture_time,
|
capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
let thumbnails = core.upload_thumbnails(Path::new(&source_path), &name);
|
||||||
let guard =
|
let guard =
|
||||||
core.transfers
|
core.transfers
|
||||||
.begin(name.clone(), "", TransferDirection::Upload, len);
|
.begin(name.clone(), "", TransferDirection::Upload, len);
|
||||||
@@ -921,7 +950,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
&media_type,
|
&media_type,
|
||||||
reader,
|
reader,
|
||||||
len as i64,
|
len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
metadata,
|
metadata,
|
||||||
false,
|
false,
|
||||||
)) {
|
)) {
|
||||||
@@ -938,6 +967,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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"))
|
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),
|
(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(CtlRequest::Delete { path }) => match route_node(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.delete(&rel) {
|
Ok((core, rel)) => match core.delete(&rel) {
|
||||||
Ok(name) => {
|
Ok(name) => {
|
||||||
@@ -1297,6 +1359,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(message) => CtlResponse::Ok { message },
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
Err(e) => CtlResponse::error(e),
|
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 }) => {
|
Ok(CtlRequest::AddSyncFolder { local_path }) => {
|
||||||
// Registering the device and uploading a folder tree far outlasts the
|
// Registering the device and uploading a folder tree far outlasts the
|
||||||
// socket read timeout, so ack immediately and work on a background
|
// 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),
|
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 }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -1453,9 +1526,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
match core.restore_sync_folders(&items, device.as_deref()) {
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
Ok(message) => {
|
Ok(outcome) => {
|
||||||
core.touch_profile();
|
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) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "restore failed");
|
warn!(error = %e, "restore failed");
|
||||||
@@ -1608,6 +1689,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
CtlResponse::error(e)
|
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(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -1816,7 +1922,7 @@ pub(crate) fn run_control_socket(
|
|||||||
info!("control socket listening");
|
info!("control socket listening");
|
||||||
for conn in listener.incoming() {
|
for conn in listener.incoming() {
|
||||||
// Checked on every accept, including the deliberate poke teardown sends
|
// Checked on every accept, including the deliberate poke teardown sends
|
||||||
// to wake this thread out of a blocking accept (bugs.md B44).
|
// to wake this thread out of a blocking accept (`docs/BUGS.md` B44).
|
||||||
if core.shutdown.is_stopping() {
|
if core.shutdown.is_stopping() {
|
||||||
info!("control socket closing");
|
info!("control socket closing");
|
||||||
return;
|
return;
|
||||||
|
|||||||
+261
-31
@@ -1,5 +1,5 @@
|
|||||||
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
//! This machine as a Proton Drive Device, and the local folders it keeps in
|
||||||
//! step with the remote (devices.md).
|
//! step with the remote.
|
||||||
//!
|
//!
|
||||||
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
//! A sync folder runs in one of two modes. `mirror` keeps a real local tree that
|
||||||
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
//! the engine in [`super::sync`] reconciles both ways; `ondemand` evicts that
|
||||||
@@ -19,6 +19,7 @@ use pdfs_core::control::{
|
|||||||
};
|
};
|
||||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
|
use pdfs_core::profile::PROFILE_DIR_NAME;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid};
|
||||||
use proton_drive_rs::{DeviceType, Node};
|
use proton_drive_rs::{DeviceType, Node};
|
||||||
@@ -104,6 +105,15 @@ pub(crate) fn adopted_device_uid() -> Option<String> {
|
|||||||
(!uid.is_empty()).then_some(uid)
|
(!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`.
|
/// Pin (or, with `None`, unpin) the adopted device uid in `config.json`.
|
||||||
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
||||||
let dirs = pdfs_core::config::AppDirs::new()
|
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}")))
|
.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 {
|
impl Core {
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
|
|
||||||
@@ -140,7 +166,7 @@ impl Core {
|
|||||||
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
pub(crate) fn list_devices(&self) -> CoreResult<Vec<DeviceInfo>> {
|
||||||
let devices = self
|
let devices = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "list devices"))?;
|
||||||
// No cached device row yet means this machine syncs nothing, so none of
|
// No cached device row yet means this machine syncs nothing, so none of
|
||||||
// the listed devices is ours.
|
// the listed devices is ours.
|
||||||
@@ -184,8 +210,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
/// Adopt an existing device as *this* machine's, pinning it in `config.json`
|
||||||
/// so hostname changes and reinstalls stop registering duplicates
|
/// so hostname changes and reinstalls stop registering duplicates.
|
||||||
/// (features.md 5.1). `None` clears the pin and returns to hostname matching.
|
/// `None` clears the pin and returns to hostname matching.
|
||||||
///
|
///
|
||||||
/// Refuses a uid that is not in the account: the whole point of the pin is
|
/// Refuses a uid that is not in the account: the whole point of the pin is
|
||||||
/// that it is authoritative, so accepting a bad one would defer the failure
|
/// that it is authoritative, so accepting a bad one would defer the failure
|
||||||
@@ -197,7 +223,7 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
let dev = remote
|
let dev = remote
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -240,7 +266,7 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
let d = remote
|
let d = remote
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -260,7 +286,108 @@ impl Core {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
/// 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
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
/// it so restarts reuse the same device. Recovery matches an existing remote
|
/// it so restarts reuse the same device. Recovery matches an existing remote
|
||||||
@@ -270,15 +397,14 @@ impl Core {
|
|||||||
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
/// Resolution order: the *adopted* uid pinned in `config.json`, then the
|
||||||
/// cached DB row (validated remotely), then a hostname match, then create.
|
/// cached DB row (validated remotely), then a hostname match, then create.
|
||||||
/// The pin exists because the hostname heuristic silently registers a second
|
/// The pin exists because the hostname heuristic silently registers a second
|
||||||
/// device after a rename or reinstall, orphaning the first one's folders
|
/// device after a rename or reinstall, orphaning the first one's folders.
|
||||||
/// (features.md 5.1).
|
|
||||||
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
pub(crate) fn ensure_device(&self) -> CoreResult<StoredDevice> {
|
||||||
let name = this_hostname();
|
let name = this_hostname();
|
||||||
// Enumerate the remote devices once: used to validate the pin, validate
|
// Enumerate the remote devices once: used to validate the pin, validate
|
||||||
// any cached record, and recover an existing device by name.
|
// any cached record, and recover an existing device by name.
|
||||||
let remote = self
|
let remote = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_devices())
|
.block_on(self.drive.enumerate_devices())
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
|
||||||
// An adopted uid is an explicit instruction, so it outranks everything
|
// An adopted uid is an explicit instruction, so it outranks everything
|
||||||
@@ -337,7 +463,7 @@ impl Core {
|
|||||||
None => {
|
None => {
|
||||||
let d = self
|
let d = self
|
||||||
.rt
|
.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"))?;
|
.map_err(|e| CoreError::from_api(&e, "create device"))?;
|
||||||
StoredDevice {
|
StoredDevice {
|
||||||
uid: d.uid.to_string(),
|
uid: d.uid.to_string(),
|
||||||
@@ -361,21 +487,25 @@ impl Core {
|
|||||||
root_uid: &NodeUid,
|
root_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> 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
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
.block_on(self.drive.enumerate_folder_children_node_uids(root_uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
.map_err(|e| CoreError::from_api(&e, "list device root"))?;
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(None);
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let nodes = self
|
self.rt
|
||||||
.rt
|
|
||||||
.block_on(self.drive.enumerate_nodes(&uids))
|
.block_on(self.drive.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))?;
|
.map_err(|e| CoreError::from_api(&e, "resolve device root children"))
|
||||||
Ok(nodes
|
|
||||||
.into_iter()
|
|
||||||
.find(|n| n.is_folder() && !n.trashed && n.name == name)
|
|
||||||
.map(|n| n.uid))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a local folder to this device's sync set: register the device if
|
/// Add a local folder to this device's sync set: register the device if
|
||||||
@@ -396,18 +526,20 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
.map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?;
|
||||||
let local_str = local.to_string_lossy().to_string();
|
let local_str = local.to_string_lossy().to_string();
|
||||||
|
|
||||||
// Reject duplicates up front for a clear error (UNIQUE would also catch it).
|
// `/`, the home folder, the mount, this app's own data, and a folder
|
||||||
if self
|
// 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
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.iter()
|
.iter()
|
||||||
.any(|f| f.local_path == local_str)
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
{
|
.collect::<Vec<_>>();
|
||||||
return Err(CoreError::invalid(format!(
|
let reserved = sync_root_reserved();
|
||||||
"{} is already synced",
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &others, &reserved) {
|
||||||
local.display()
|
return Err(CoreError::invalid(problem));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = local
|
let name = local
|
||||||
@@ -536,12 +668,16 @@ impl Core {
|
|||||||
} else {
|
} else {
|
||||||
p.total.max(p.done) as u64
|
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 {
|
SyncPhase::Applying => JobItem {
|
||||||
title: format!("Syncing {folder}"),
|
title: format!("Syncing {folder}"),
|
||||||
detail: p.current.clone(),
|
detail: p.current.clone(),
|
||||||
done: p.done as u64,
|
done: p.done as u64,
|
||||||
total: p.total.max(p.done) as u64,
|
total: p.total.max(p.done) as u64,
|
||||||
|
background: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -617,7 +753,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
/// A sibling Core for a secondary FUSE session rooted at an `ondemand` sync
|
||||||
/// folder (devices.md Phase 3). It shares everything with this one,
|
/// folder. It shares everything with this one,
|
||||||
/// the tree included, except what belongs to a session: its root and its
|
/// the tree included, except what belongs to a session: its root and its
|
||||||
/// kernel channel, both filled in when the session is built.
|
/// kernel channel, both filled in when the session is built.
|
||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
@@ -843,6 +979,29 @@ impl Core {
|
|||||||
if folder.state != "idle" {
|
if folder.state != "idle" {
|
||||||
return Err(SwitchBlocked::NotNow);
|
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(|| {
|
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
||||||
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
SwitchBlocked::Failed(format!("bad remote uid: {}", folder.remote_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -927,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
|
/// 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
|
/// [`Core::fork_state`] sibling. Clears any stale kernel mount first (a crashed run can leave
|
||||||
/// one, which would fail the fresh mount with EBUSY).
|
/// one, which would fail the fresh mount with EBUSY).
|
||||||
@@ -986,8 +1189,7 @@ impl Core {
|
|||||||
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
/// restart (their local dirs are empty on disk — the files live in the cloud).
|
||||||
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
/// Best-effort per folder: a missing local path or a failed remote fetch marks
|
||||||
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
/// the folder `error` and moves on rather than aborting the rest. Runs on its
|
||||||
/// own thread from `mount` so the network fetches never block startup
|
/// own thread from `mount` so the network fetches never block startup.
|
||||||
/// (devices.md Phase 4).
|
|
||||||
pub(crate) fn restore_ondemand_mounts(&self) {
|
pub(crate) fn restore_ondemand_mounts(&self) {
|
||||||
let folders = match self.db.sync_folder_list() {
|
let folders = match self.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
@@ -1109,10 +1311,38 @@ mod tests {
|
|||||||
use pdfs_core::db::StoredSyncFolder;
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
ApplicableMountMode, commit_after_teardown, migrated_name, pending_intent_matches,
|
||||||
restore_snapshot_matches, select_mode_transition,
|
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]
|
#[test]
|
||||||
fn unknown_mode_cannot_select_a_destructive_transition() {
|
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||||
let mut unmounts = 0;
|
let mut unmounts = 0;
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ impl Core {
|
|||||||
control_handlers: super::control::active_handlers(),
|
control_handlers: super::control::active_handlers(),
|
||||||
control_handler_limit: super::control::handler_limit(),
|
control_handler_limit: super::control::handler_limit(),
|
||||||
pending_ops: self.pending_ops_quickly(),
|
pending_ops: self.pending_ops_quickly(),
|
||||||
|
sync: Some(self.sync_engine.report()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+146
-49
@@ -1,7 +1,7 @@
|
|||||||
//! The write-back drain: turning queued mutations into remote calls.
|
//! The write-back drain: turning queued mutations into remote calls.
|
||||||
//!
|
//!
|
||||||
//! Every write the kernel hands us is answered the moment its bytes and its
|
//! Every write the kernel hands us is answered the moment its bytes and its
|
||||||
//! `pending_op` row are on disk (offline.md Phase 3), which is what lets a `cp`
|
//! `pending_op` row are on disk, which is what lets a `cp`
|
||||||
//! into the mount run at disk speed and lets an offline write succeed at all.
|
//! into the mount run at disk speed and lets an offline write succeed at all.
|
||||||
//! This module is the other half: the worker that walks that queue and performs
|
//! This module is the other half: the worker that walks that queue and performs
|
||||||
//! the uploads, creates, renames and trashes it recorded.
|
//! the uploads, creates, renames and trashes it recorded.
|
||||||
@@ -276,8 +276,7 @@ fn run_authorized_drain(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
/// Drain the pending-op queue: the background half of every write
|
/// Drain the pending-op queue: the background half of every write.
|
||||||
/// (offline.md Phase 3).
|
|
||||||
///
|
///
|
||||||
/// Runs for the life of the mount. Ops are replayed oldest-first, each
|
/// Runs for the life of the mount. Ops are replayed oldest-first, each
|
||||||
/// retried with doubling backoff and *never* dropped on failure — the staged
|
/// retried with doubling backoff and *never* dropped on failure — the staged
|
||||||
@@ -306,7 +305,7 @@ impl Core {
|
|||||||
loop {
|
loop {
|
||||||
// Between ops, never inside one: an op that has started is either
|
// Between ops, never inside one: an op that has started is either
|
||||||
// retired or released, so stopping here cannot leave the queue with
|
// retired or released, so stopping here cannot leave the queue with
|
||||||
// a row claimed by a worker that no longer exists (bugs.md B44).
|
// a row claimed by a worker that no longer exists (`docs/BUGS.md` B44).
|
||||||
if self.shutdown.is_stopping() {
|
if self.shutdown.is_stopping() {
|
||||||
debug!(primary, "drain worker stopping");
|
debug!(primary, "drain worker stopping");
|
||||||
return;
|
return;
|
||||||
@@ -348,17 +347,7 @@ impl Core {
|
|||||||
// its writer has to be noticed.
|
// its writer has to be noticed.
|
||||||
self.sweep_parked_creates();
|
self.sweep_parked_creates();
|
||||||
}
|
}
|
||||||
// Paused, nothing due changes that: sleep until a resume
|
self.wait_for_drain_work_or_due(online && !paused);
|
||||||
// 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();
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -400,13 +389,7 @@ impl Core {
|
|||||||
// A name a change of ours is about to free is a wait, not a
|
// 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.
|
// failure: the trash or rename holding it lands in moments.
|
||||||
let held = waits_for_name(e.as_ref());
|
let held = waits_for_name(e.as_ref());
|
||||||
let backoff = if held {
|
let backoff = drain_backoff(attempts, held, op.id);
|
||||||
DRAIN_BACKOFF_MIN
|
|
||||||
} else {
|
|
||||||
DRAIN_BACKOFF_MIN
|
|
||||||
.saturating_mul(1u32 << attempts.min(6))
|
|
||||||
.min(DRAIN_BACKOFF_MAX)
|
|
||||||
};
|
|
||||||
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
warn!(uid = %op.uid, attempts, error = %err_str, "pending upload failed; will retry");
|
||||||
if let Err(e) = self.db.record_attempt_failure(
|
if let Err(e) = self.db.record_attempt_failure(
|
||||||
op.id,
|
op.id,
|
||||||
@@ -733,14 +716,16 @@ impl Core {
|
|||||||
/// Like [`wait_for_drain_work`](Self::wait_for_drain_work), but first
|
/// 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
|
/// checks whether a debounced or backed-off op is due before the idle-poll
|
||||||
/// ceiling. A 2-second debounce must not sleep 30 seconds.
|
/// 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() {
|
/// `claiming` is false while the worker claims nothing (offline or
|
||||||
Ok(Some(ts)) => {
|
/// paused); see [`idle_wait`].
|
||||||
let remaining = (ts - now_millis()).max(0) as u64;
|
fn wait_for_drain_work_or_due(&self, claiming: bool) {
|
||||||
Duration::from_millis(remaining).min(DRAIN_IDLE_POLL)
|
let earliest = if claiming {
|
||||||
}
|
self.db.earliest_due_at().ok().flatten()
|
||||||
_ => DRAIN_IDLE_POLL,
|
} else {
|
||||||
|
None
|
||||||
};
|
};
|
||||||
|
let timeout = idle_wait(claiming, earliest, now_millis());
|
||||||
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
|
sleep_for_drain_work(&self.drain_wake, || self.shutdown.is_stopping(), timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,7 +1112,7 @@ impl Core {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let sent = self.create_sent_digests(op.id, current);
|
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
|
/// Record the name the remote actually gave a node, after a conflict forced
|
||||||
@@ -1150,7 +1135,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Make a node that so far exists only on this machine real, and adopt the
|
/// Make a node that so far exists only on this machine real, and adopt the
|
||||||
/// uid the server gives it (offline.md Phase 3b).
|
/// uid the server gives it.
|
||||||
pub(crate) fn drain_local_node(
|
pub(crate) fn drain_local_node(
|
||||||
&self,
|
&self,
|
||||||
op: &PendingOp,
|
op: &PendingOp,
|
||||||
@@ -1296,6 +1281,13 @@ impl Core {
|
|||||||
sent: (&sent.0, &sent.1),
|
sent: (&sent.0, &sent.1),
|
||||||
landed: (&home.to_string(), &name),
|
landed: (&home.to_string(), &name),
|
||||||
};
|
};
|
||||||
|
// Counted from the op's retirement until the bytes are cached and the
|
||||||
|
// tree has the real uid, as an upload is. Uncounted, the queue read
|
||||||
|
// empty while that still ran: a delete in between took an open file
|
||||||
|
// for closed and dropped what its handle reads, and a script that
|
||||||
|
// waited for an empty queue read the file before it was cached
|
||||||
|
// (`docs/BUGS.md` B257).
|
||||||
|
let _landing = LandingUpload::begin(&self.landing_uploads);
|
||||||
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
let newer = match self.retire_create(op, uploaded, &landing, &local, &real)? {
|
||||||
CreateRetired::Withdrawn { trash } => {
|
CreateRetired::Withdrawn { trash } => {
|
||||||
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
return self.trash_withdrawn_create(op, trash, &local, &real, &name);
|
||||||
@@ -1522,8 +1514,10 @@ impl Core {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|sha| holds_bytes(&twin, sha));
|
.is_some_and(|sha| holds_bytes(&twin, sha));
|
||||||
let sent = self.create_sent_digests(op.id, current);
|
let sent = self.create_sent_digests(op.id, current);
|
||||||
Ok(adoptable(op.kind == OP_MKDIR, &twin, op.created_at, &sent)
|
Ok(
|
||||||
.then_some((twin.uid, holds_blob)))
|
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:
|
/// The SHA-1s of the bytes queued create `id` may have made its file with:
|
||||||
@@ -1538,6 +1532,22 @@ impl Core {
|
|||||||
sent
|
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
|
/// 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.
|
/// the API's own error so the caller can tell a name clash from a failure.
|
||||||
///
|
///
|
||||||
@@ -1550,7 +1560,7 @@ impl Core {
|
|||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
) -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||||
self.db
|
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 {
|
match op.kind == OP_MKDIR {
|
||||||
true => Ok(self.block_on_bounded(self.drive.create_folder(
|
true => Ok(self.block_on_bounded(self.drive.create_folder(
|
||||||
parent,
|
parent,
|
||||||
@@ -1595,7 +1605,7 @@ impl Core {
|
|||||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||||
let uid = self.block_on_within(
|
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(
|
self.drive.upload_file_from(
|
||||||
parent,
|
parent,
|
||||||
name,
|
name,
|
||||||
@@ -1850,7 +1860,7 @@ impl Core {
|
|||||||
/// that window the node can be rewritten by another device, trashed, or
|
/// that window the node can be rewritten by another device, trashed, or
|
||||||
/// deleted outright. Sending the blob anyway would silently drop whatever
|
/// deleted outright. Sending the blob anyway would silently drop whatever
|
||||||
/// happened in between, which is exactly the thing the sync engine refuses
|
/// happened in between, which is exactly the thing the sync engine refuses
|
||||||
/// to do (offline.md Phase 3b).
|
/// to do.
|
||||||
///
|
///
|
||||||
/// Only checkable against a recorded baseline: a write staged before
|
/// Only checkable against a recorded baseline: a write staged before
|
||||||
/// [`StagedWrite::based_on`] existed, or one against a node that has never
|
/// [`StagedWrite::based_on`] existed, or one against a node that has never
|
||||||
@@ -1992,7 +2002,7 @@ impl Core {
|
|||||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
let reader = CountingReader::new(File::open(blob)?, &guard);
|
let reader = CountingReader::new(File::open(blob)?, &guard);
|
||||||
self.block_on_within(
|
self.block_on_within(
|
||||||
link::upload_deadline(meta.len),
|
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||||
self.drive.upload_file_from(
|
self.drive.upload_file_from(
|
||||||
&parent,
|
&parent,
|
||||||
&alt,
|
&alt,
|
||||||
@@ -2009,7 +2019,7 @@ impl Core {
|
|||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
// Dropping the pending entry hands the node back to the remote's truth:
|
// Dropping the pending entry hands the node back to the remote's truth:
|
||||||
// reads stop coming from the staged blob, and the event sync stops
|
// reads stop coming from the staged blob, and the event sync stops
|
||||||
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it
|
// skipping it as "ahead of the server". Only if it
|
||||||
// is still *this* write's entry — see [`Core::release_pending`].
|
// is still *this* write's entry — see [`Core::release_pending`].
|
||||||
self.release_pending(uid, blob);
|
self.release_pending(uid, blob);
|
||||||
self.cache.discard_staged(blob);
|
self.cache.discard_staged(blob);
|
||||||
@@ -2245,7 +2255,7 @@ impl Core {
|
|||||||
.clone()
|
.clone()
|
||||||
.ok_or("pending op has no staged blob")?;
|
.ok_or("pending op has no staged blob")?;
|
||||||
let blob = PathBuf::from(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")?;
|
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)? {
|
if let Some(reason) = self.revision_conflict(&uid, &meta, &blob)? {
|
||||||
@@ -2284,6 +2294,22 @@ impl Core {
|
|||||||
&written,
|
&written,
|
||||||
)
|
)
|
||||||
.map_err(|e| self.errno_error(e, "gap-fill from base failed"))?;
|
.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);
|
let name = self.node_name(&uid);
|
||||||
@@ -2304,7 +2330,7 @@ impl Core {
|
|||||||
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
let reader = CountingReader::new(File::open(&blob)?, &guard).with_cancel(cancel.flag());
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let sent = self.block_on_within(
|
let sent = self.block_on_within(
|
||||||
link::upload_deadline(meta.len),
|
link::upload_deadline(meta.len, self.transfers.upload_cap()),
|
||||||
self.drive
|
self.drive
|
||||||
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
|
.upload_new_revision_from(&uid, reader, meta.len as i64, thumbnails, None),
|
||||||
);
|
);
|
||||||
@@ -2679,7 +2705,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.
|
/// is, or `Some(None)` for a failure that is not a refusal.
|
||||||
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
fn refusal(e: &(dyn std::error::Error + 'static)) -> Option<Option<SyncIssue>> {
|
||||||
let proton = proton_error(e)?;
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let ProtonError::Api(api) = proton else {
|
let ProtonError::Api(api) = proton else {
|
||||||
@@ -2760,24 +2788,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>())
|
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
|
/// 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
|
/// the name it wants. Two files swapping names each wait for the other, so the
|
||||||
/// wait has to end; a conflict name ends it.
|
/// wait has to end; a conflict name ends it.
|
||||||
const NAME_HOLD_ATTEMPTS: i64 = 4;
|
const NAME_HOLD_ATTEMPTS: i64 = 4;
|
||||||
|
|
||||||
/// How much older than its op a remote node may be and still be the one an
|
/// How much older than its op's first send a remote node may be and still be
|
||||||
/// unanswered call made. The op is queued the moment the call gives up, so the
|
/// the one an unanswered call made. The node is made during that call, so it
|
||||||
/// node is at most one call's deadline older; the rest is clock skew between
|
/// is at most one call's deadline older; the rest is clock skew between this
|
||||||
/// this machine and Proton.
|
/// machine and Proton.
|
||||||
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
const ADOPT_WINDOW_MS: i64 = 2 * 60 * 1000;
|
||||||
|
|
||||||
/// Whether `twin`, found holding the name a queued create wants, is the node an
|
/// 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
|
/// earlier, unanswered attempt at that create made: the same kind, created no
|
||||||
/// around when the op was queued, and for a file still empty, as the mount's
|
/// 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
|
/// create leaves it, or holding exactly bytes the op uploaded, whose SHA-1 is
|
||||||
/// one of `sent`.
|
/// one of `sent`.
|
||||||
fn adoptable(is_dir: bool, twin: &Node, op_created_ms: i64, sent: &[String]) -> bool {
|
fn adoptable(is_dir: bool, twin: &Node, first_sent_ms: i64, sent: &[String]) -> bool {
|
||||||
if twin.creation_time.saturating_mul(1000) < op_created_ms - ADOPT_WINDOW_MS {
|
if twin.creation_time.saturating_mul(1000) < first_sent_ms - ADOPT_WINDOW_MS {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
match &twin.kind {
|
match &twin.kind {
|
||||||
@@ -2834,6 +2881,23 @@ fn sleep_for_drain_work(
|
|||||||
*woken = false;
|
*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>)> {
|
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))?;
|
let entry = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))?;
|
||||||
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
Some((entry.node.name.clone(), entry.node.parent_uid.clone()))
|
||||||
@@ -2859,6 +2923,23 @@ mod tests {
|
|||||||
assert!(started.elapsed() < Duration::from_secs(1));
|
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 {
|
fn pending(kind: &str) -> PendingOp {
|
||||||
PendingOp {
|
PendingOp {
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -3325,6 +3406,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]
|
#[test]
|
||||||
fn only_a_fresh_empty_twin_of_the_same_kind_is_adopted() {
|
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.
|
// The op was queued at t=1000 s, after a create that got no answer.
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
//! `Core` used to hold a concrete [`ProtonDriveClient`], so no test could make
|
||||||
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
//! Drive answer late, answer stale, or not answer at all, and the interleavings
|
||||||
//! behind most recent bugs could only be found on a real account over a slow
|
//! behind most recent bugs could only be found on a real account over a slow
|
||||||
//! link (`docs/MILESTONE-3.0.0.md` §2, §8.1). [`DriveApi`] is the part of the
|
//! link. [`DriveApi`] is the part of the
|
||||||
//! client the mount, the drain, the event feed and the mirror engine use; the
|
//! client the mount, the drain, the event feed and the mirror engine use; the
|
||||||
//! real client implements it by forwarding, and the simulation tests implement
|
//! real client implements it by forwarding, and the simulation tests implement
|
||||||
//! it with an in-memory Drive that injects Drive's known faults.
|
//! it with an in-memory Drive that injects Drive's known faults.
|
||||||
//!
|
//!
|
||||||
//! Sharing, Photos, devices, invitations and public links stay on the concrete
|
//! Sharing, Photos, invitations, public links and the rest of the device calls
|
||||||
//! client (`Core::client`) until a test needs them faked.
|
//! 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
|
//! 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
|
//! 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::error::Result;
|
||||||
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
use proton_drive_rs::proton_sdk::ids::{DriveEventId, NodeUid};
|
||||||
use proton_drive_rs::{
|
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`].
|
/// 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<()>;
|
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>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
@@ -154,6 +173,12 @@ pub(crate) trait DriveApi: Send + Sync {
|
|||||||
/// The account's storage, across every Proton product.
|
/// The account's storage, across every Proton product.
|
||||||
async fn quota(&self) -> Result<Quota>;
|
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
|
/// 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
|
/// `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
|
/// 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
|
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>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
@@ -470,4 +513,12 @@ impl DriveApi for ProtonDriveClient {
|
|||||||
async fn quota(&self) -> Result<Quota> {
|
async fn quota(&self) -> Result<Quota> {
|
||||||
ProtonDriveClient::quota(self).await
|
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,
|
used,
|
||||||
budget,
|
budget,
|
||||||
online,
|
online,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
..
|
..
|
||||||
@@ -247,7 +249,7 @@ fn status_keys(status: &CtlResponse) -> (String, String) {
|
|||||||
);
|
);
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||||
{used}/{budget}/{online}"
|
{used}/{budget}/{online}/{session_expired}/{tokens_unsaved}"
|
||||||
);
|
);
|
||||||
(key, queue)
|
(key, queue)
|
||||||
}
|
}
|
||||||
@@ -325,6 +327,9 @@ mod tests {
|
|||||||
budget: 0,
|
budget: 0,
|
||||||
pins: Vec::new(),
|
pins: Vec::new(),
|
||||||
online: true,
|
online: true,
|
||||||
|
session_expired: false,
|
||||||
|
tokens_unsaved: false,
|
||||||
|
version: "3.3.0".into(),
|
||||||
pending_uploads: 1,
|
pending_uploads: 1,
|
||||||
pending_changes: 0,
|
pending_changes: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
+390
-144
@@ -132,7 +132,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// A provisional size is the *ciphertext* size, which is larger than the
|
// A provisional size is the *ciphertext* size, which is larger than the
|
||||||
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
// file. Publishing it makes every reader that trusts `st_size` — rsync,
|
||||||
// mmap, sendfile, a sized read loop — run off the end of the file and
|
// mmap, sendfile, a sized read loop — run off the end of the file and
|
||||||
// fail. So resolve it before answering rather than after (bugs.md B14).
|
// fail. So resolve it before answering rather than after (`docs/BUGS.md` B14).
|
||||||
//
|
//
|
||||||
// The cost is one batched round trip for the whole folder, not one per
|
// The cost is one batched round trip for the whole folder, not one per
|
||||||
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
// file: `ls -l` is one `getattr` per entry and they collapse onto a
|
||||||
@@ -172,7 +172,7 @@ impl Filesystem for ProtonFs {
|
|||||||
/// here — once per `opendir`, which the kernel pairs exactly with one
|
/// here — once per `opendir`, which the kernel pairs exactly with one
|
||||||
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
/// `releasedir` — is what makes the offsets stable, so a create or a trash
|
||||||
/// during an enumeration can no longer make the caller skip or repeat an
|
/// during an enumeration can no longer make the caller skip or repeat an
|
||||||
/// entry (bugs.md B43).
|
/// entry (`docs/BUGS.md` B43).
|
||||||
///
|
///
|
||||||
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
/// Enumerating a cold folder is a remote call, so it goes to a worker for
|
||||||
/// the same reason `lookup` and `readdir` do.
|
/// the same reason `lookup` and `readdir` do.
|
||||||
@@ -261,10 +261,13 @@ impl Filesystem for ProtonFs {
|
|||||||
// in-flight (possibly unsaved) content: authored bytes come from the
|
// in-flight (possibly unsaved) content: authored bytes come from the
|
||||||
// scratch file, untouched bytes from the base.
|
// scratch file, untouched bytes from the base.
|
||||||
//
|
//
|
||||||
// This path stays on the dispatch loop. `write` runs there too, so
|
// Answered on the dispatch loop when everything it needs is on this
|
||||||
// keeping its reads there as well preserves the ordering the write path
|
// machine, which keeps the order `write` has with reads of the same
|
||||||
// has always had between a write and a read of the same handle; a mostly
|
// handle. A base range that has to be downloaded goes to a worker
|
||||||
// local read is not worth reasoning about that for.
|
// 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 handle = {
|
||||||
let st = self.core.state();
|
let st = self.core.state();
|
||||||
st.active_writes.get(&ino.0).map(|h| {
|
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 {
|
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,
|
&file,
|
||||||
len,
|
len,
|
||||||
&uid,
|
&uid,
|
||||||
@@ -289,7 +292,25 @@ impl Filesystem for ProtonFs {
|
|||||||
offset,
|
offset,
|
||||||
size as u64,
|
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),
|
Err(e) => reply.error(e),
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -346,7 +367,7 @@ impl Filesystem for ProtonFs {
|
|||||||
self.core.workers.run(Lane::Transfer, "read", move || {
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
// The revision disagrees with a provisional size, and B84 fails
|
// The revision disagrees with a provisional size, and B84 fails
|
||||||
// such a read outright, so settle the size first. On the network,
|
// such a read outright, so settle the size first. On the network,
|
||||||
// hence here and not above (bugs.md B100).
|
// hence here and not above (`docs/BUGS.md` B100).
|
||||||
let fsize = match provisional {
|
let fsize = match provisional {
|
||||||
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
true => core.resolve_provisional_size(ino.0, &uid).unwrap_or(fsize),
|
||||||
false => fsize,
|
false => fsize,
|
||||||
@@ -409,58 +430,10 @@ impl Filesystem for ProtonFs {
|
|||||||
reply.error(error);
|
reply.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fh = fh.0;
|
match self.stage_write(ino.0, fh.0, offset, data) {
|
||||||
// Stage the bytes straight into the scratch file (no base download): only
|
Ok(()) => reply.written(data.len() as u32),
|
||||||
// the untouched remainder is pulled from the remote, and only at commit.
|
Err(error) => reply.error(error),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -517,7 +490,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// `> file`. This is the second write path into the API, and
|
// `> file`. This is the second write path into the API, and
|
||||||
// queueing it is what lets a redirect work offline at all: it
|
// queueing it is what lets a redirect work offline at all: it
|
||||||
// never reaches `release`, so without this it failed before any
|
// never reaches `release`, so without this it failed before any
|
||||||
// byte was written (offline.md Phase 2/3b).
|
// byte was written.
|
||||||
//
|
//
|
||||||
// The one branch of `setattr` that can reach the network:
|
// The one branch of `setattr` that can reach the network:
|
||||||
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
// shrinking keeps the prefix, which `queue_truncate` gap-fills
|
||||||
@@ -556,48 +529,62 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let new_end = offset.saturating_add(length);
|
let new_end = offset.saturating_add(length);
|
||||||
let keep_size = (mode & 1) != 0; // FALLOC_FL_KEEP_SIZE
|
// Only the modes whose effect on the scratch file this handle can
|
||||||
|
// record. Anything else went through to the scratch file untracked:
|
||||||
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
// a zeroed range was refilled from the base at commit, and a collapse
|
||||||
|
// or insert shifted bytes that `len` and `written` did not follow
|
||||||
let res = {
|
// (`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();
|
let mut st = self.core.state();
|
||||||
match st
|
if let Some(aw) = st
|
||||||
.handles
|
.handles
|
||||||
.get(&_fh.0)
|
.get(&_fh.0)
|
||||||
.copied()
|
.copied()
|
||||||
.and_then(|i| st.active_writes.get_mut(&i))
|
.and_then(|i| st.active_writes.get_mut(&i))
|
||||||
{
|
{
|
||||||
Some(aw) => {
|
let len = aw.len;
|
||||||
use std::os::unix::io::AsRawFd;
|
let (authored, grown) = kind.effect(offset, new_end, len);
|
||||||
let fd = aw.file.as_raw_fd();
|
if let Some((from, to)) = authored {
|
||||||
let ret = unsafe { libc::fallocate(fd, mode, offset as i64, length as i64) };
|
// Zeroed or punched bytes read as zero. Marked authored so
|
||||||
if ret == 0 {
|
// queue_revision does not refill them from the remote
|
||||||
if is_punch_hole {
|
// baseline and silently undo them at commit.
|
||||||
// A punched range reads as zero. Mark it authored so
|
aw.written.add(from, to);
|
||||||
// queue_revision does not refill it from the remote
|
aw.dirty = true;
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => {
|
if let Some(end) = grown {
|
||||||
if st.entries.contains_key(&ino.0) {
|
aw.len = end;
|
||||||
Err(Errno::EBADF)
|
aw.dirty = true;
|
||||||
} else {
|
st.set_size(ino.0, end);
|
||||||
Err(Errno::ENOENT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
@@ -606,18 +593,47 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
/// `close(2)` calls flush before release, and flush is the only one of the
|
||||||
/// and performed in the background (offline.md Phase 3), so there is nothing
|
/// two whose answer reaches the program: the kernel drops `release`'s. So
|
||||||
/// to push here — the written bytes are already in the scratch file.
|
/// 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(
|
fn flush(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_ino: INodeNo,
|
_ino: INodeNo,
|
||||||
_fh: FileHandle,
|
fh: FileHandle,
|
||||||
_lock_owner: LockOwner,
|
_lock_owner: LockOwner,
|
||||||
reply: ReplyEmpty,
|
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
|
/// Durability here means the scratch file, not the remote: a caller asking
|
||||||
@@ -655,7 +671,7 @@ impl Filesystem for ProtonFs {
|
|||||||
reply: ReplyEmpty,
|
reply: ReplyEmpty,
|
||||||
) {
|
) {
|
||||||
let ino = self.tree(ino);
|
let ino = self.tree(ino);
|
||||||
let (handle, unlinked_uid) = {
|
let (handle, unlinked_uid, releasing) = {
|
||||||
let mut st = self.core.state();
|
let mut st = self.core.state();
|
||||||
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
let unlinked_uid = release_unlinked_entry(&mut st, ino.0);
|
||||||
let ino = st.handles.remove(&fh.0);
|
let ino = st.handles.remove(&fh.0);
|
||||||
@@ -668,7 +684,11 @@ impl Filesystem for ProtonFs {
|
|||||||
None
|
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
|
// Deliberately **not** handed to a worker, unlike the other slow
|
||||||
// handlers here. The kernel does not wait for a `release` reply before
|
// 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
|
// 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.
|
// insert, and at most a bounded copy out of the local cache.
|
||||||
self.finish_release(handle, unlinked_uid, reply);
|
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) {
|
fn unlink(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEmpty) {
|
||||||
@@ -952,20 +975,59 @@ impl Filesystem for ProtonFs {
|
|||||||
fn copy_file_range(
|
fn copy_file_range(
|
||||||
&self,
|
&self,
|
||||||
_req: &Request,
|
_req: &Request,
|
||||||
_ino_in: INodeNo,
|
ino_in: INodeNo,
|
||||||
_fh_in: FileHandle,
|
_fh_in: FileHandle,
|
||||||
_offset_in: u64,
|
offset_in: u64,
|
||||||
_ino_out: INodeNo,
|
ino_out: INodeNo,
|
||||||
_fh_out: FileHandle,
|
fh_out: FileHandle,
|
||||||
_offset_out: u64,
|
offset_out: u64,
|
||||||
_len: u64,
|
len: u64,
|
||||||
_flags: CopyFileRangeFlags,
|
flags: CopyFileRangeFlags,
|
||||||
reply: ReplyWrite,
|
reply: ReplyWrite,
|
||||||
) {
|
) {
|
||||||
// Also must stay ENOSYS: there is no server-side copy, and this is the
|
// Copied here when the source bytes are on this machine, as `write`
|
||||||
// errno that sends coreutils (and the kernel) down the read/write path
|
// would stage them, minus a FUSE round trip per page: `cp` of a cached
|
||||||
// instead of failing the copy outright.
|
// 256 MiB file ran at about 100 MiB/s through read and write. Anything
|
||||||
reply.error(Errno::ENOSYS);
|
// 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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,7 +1047,7 @@ pub struct ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How many times a write `open` will re-read its base after a `release`
|
/// How many times a write `open` will re-read its base after a `release`
|
||||||
/// published a newer queued revision underneath it (bugs.md B32).
|
/// published a newer queued revision underneath it (`docs/BUGS.md` B32).
|
||||||
///
|
///
|
||||||
/// One retry covers the race, which needs a close and an open of the same file
|
/// One retry covers the race, which needs a close and an open of the same file
|
||||||
/// to overlap on different threads. More than that is not contention, it is a
|
/// to overlap on different threads. More than that is not contention, it is a
|
||||||
@@ -993,6 +1055,11 @@ pub struct ProtonFs {
|
|||||||
/// worker busy indefinitely.
|
/// worker busy indefinitely.
|
||||||
const OPEN_BASE_ATTEMPTS: u32 = 3;
|
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
|
/// One directory's entries as `readdir` reports them: `.` and `..` first, then
|
||||||
/// the interned children in `children` order.
|
/// the interned children in `children` order.
|
||||||
///
|
///
|
||||||
@@ -1021,7 +1088,53 @@ fn build_listing(st: &State, ino: u64) -> DirListing {
|
|||||||
///
|
///
|
||||||
/// The cookie handed back is the index of the *next* entry, so a resumed call
|
/// 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
|
/// continues exactly where the last one stopped — which is only true because
|
||||||
/// the listing behind those indexes is a snapshot (bugs.md B43).
|
/// 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) {
|
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) {
|
for (i, (ino, is_dir, name)) in listing.iter().enumerate().skip(offset as usize) {
|
||||||
let ft = if *is_dir {
|
let ft = if *is_dir {
|
||||||
@@ -1094,7 +1207,7 @@ impl ProtonFs {
|
|||||||
// `getattr` would, so `ls -l` — which is one `lookup`
|
// `getattr` would, so `ls -l` — which is one `lookup`
|
||||||
// per entry and no `getattr` at all — takes its sizes
|
// per entry and no `getattr` at all — takes its sizes
|
||||||
// from here. Resolving only in `getattr` left the whole
|
// from here. Resolving only in `getattr` left the whole
|
||||||
// listing provisional (bugs.md B14).
|
// listing provisional (`docs/BUGS.md` B14).
|
||||||
let provisional = matches!(
|
let provisional = matches!(
|
||||||
&e.node.kind,
|
&e.node.kind,
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -1267,8 +1380,12 @@ impl ProtonFs {
|
|||||||
//
|
//
|
||||||
// Complete pending blobs are the common case and can be copied without
|
// Complete pending blobs are the common case and can be copied without
|
||||||
// any network access. An incomplete blob still has gaps referring to
|
// any network access. An incomplete blob still has gaps referring to
|
||||||
// the remote base; stacking another write on it cannot be represented
|
// the remote base, and stacking another write on it cannot be
|
||||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
// 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
|
// 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
|
// lock — the copy is the size of the file — so the base is re-checked at
|
||||||
@@ -1276,7 +1393,7 @@ impl ProtonFs {
|
|||||||
// revision in between sends this around again. Without the re-check the
|
// revision in between sends this around again. Without the re-check the
|
||||||
// new handle carried the *older* base, and its own release published
|
// new handle carried the *older* base, and its own release published
|
||||||
// content that silently discarded the revision closed a moment earlier
|
// content that silently discarded the revision closed a moment earlier
|
||||||
// (bugs.md B32). A second attempt is already unlikely and a third would
|
// (`docs/BUGS.md` B32). A second attempt is already unlikely and a third would
|
||||||
// mean something is republishing continuously, so give up rather than
|
// mean something is republishing continuously, so give up rather than
|
||||||
// spin: the caller's `open` fails instead of its data being lost.
|
// spin: the caller's `open` fails instead of its data being lost.
|
||||||
//
|
//
|
||||||
@@ -1286,7 +1403,7 @@ impl ProtonFs {
|
|||||||
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
for attempt in 0..OPEN_BASE_ATTEMPTS {
|
||||||
// The handle starts at the base's size. A provisional one is the
|
// The handle starts at the base's size. A provisional one is the
|
||||||
// ciphertext size, so the file would grow by the encryption
|
// ciphertext size, so the file would grow by the encryption
|
||||||
// overhead and reading the base would fail (bugs.md B100). A
|
// overhead and reading the base would fail (`docs/BUGS.md` B100). A
|
||||||
// queued revision stamps a real size, so this only goes to the
|
// queued revision stamps a real size, so this only goes to the
|
||||||
// network for a file the listing has not sized yet.
|
// network for a file the listing has not sized yet.
|
||||||
if provisional && self.core.pending_blob(&uid).is_none() {
|
if provisional && self.core.pending_blob(&uid).is_none() {
|
||||||
@@ -1302,10 +1419,6 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
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() {
|
let (file, path) = match self.core.cache.create_scratch() {
|
||||||
Ok(x) => x,
|
Ok(x) => x,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1321,10 +1434,37 @@ impl ProtonFs {
|
|||||||
// (`ContentCache::clone_or_copy`).
|
// (`ContentCache::clone_or_copy`).
|
||||||
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||||
let _ = std::fs::remove_file(&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,
|
error!(%uid, source = %pending.path.display(), error = %e,
|
||||||
"copy queued revision into write scratch failed");
|
"copy queued revision into write scratch failed");
|
||||||
break 'install Err(Errno::EIO);
|
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);
|
initial_written.add(0, pending.meta.len);
|
||||||
}
|
}
|
||||||
// Sampled *before* the state lock, never under it: no site in
|
// 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 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) {
|
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
||||||
// Everything the durability sidecar needs, copied out so no lock is held
|
// Everything the durability sidecar needs, copied out so no lock is held
|
||||||
// across the sync.
|
// 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)) =
|
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||||
handle
|
handle
|
||||||
else {
|
else {
|
||||||
reply.error(Errno::EBADF);
|
reply.ok();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Err(e) = f.sync_all() {
|
if let Err(e) = f.sync_all() {
|
||||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||||
// Delayed allocation means a full disk usually surfaces here rather
|
reply.error(self.scratch_sync_errno(&e));
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A clean handle has nothing on it that the remote does not already
|
// 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.
|
/// 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) {
|
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||||
if let Err(error) = self.core.require_writable(parent) {
|
if let Err(error) = self.core.require_writable(parent) {
|
||||||
reply.error(error);
|
reply.error(error);
|
||||||
@@ -1541,7 +1733,7 @@ impl ProtonFs {
|
|||||||
let transient = is_transient_name(name);
|
let transient = is_transient_name(name);
|
||||||
// Offline the server cannot mint a uid, so invent one and queue the
|
// Offline the server cannot mint a uid, so invent one and queue the
|
||||||
// create. The file is real to the caller either way; only its identity is
|
// create. The file is real to the caller either way; only its identity is
|
||||||
// provisional until the drain (offline.md Phase 3b).
|
// provisional until the drain.
|
||||||
//
|
//
|
||||||
// A parent that is itself still queued forces the same path even when we
|
// A parent that is itself still queued forces the same path even when we
|
||||||
// are online: the API has no folder to put this in yet.
|
// are online: the API has no folder to put this in yet.
|
||||||
@@ -1652,7 +1844,7 @@ impl ProtonFs {
|
|||||||
.ok();
|
.ok();
|
||||||
// As in `create`: offline — or under a parent that is itself still
|
// As in `create`: offline — or under a parent that is itself still
|
||||||
// queued — the folder becomes a placeholder that the drain turns into a
|
// queued — the folder becomes a placeholder that the drain turns into a
|
||||||
// real one (offline.md Phase 3b).
|
// real one.
|
||||||
// Losing the network on the way queues it the same way.
|
// Losing the network on the way queues it the same way.
|
||||||
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
let minted = if self.core.sends_inline() && !is_local_uid(&parent_uid) {
|
||||||
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
if let Err(error) = self.core.require_uid_writable(&parent_uid) {
|
||||||
@@ -1698,9 +1890,7 @@ impl ProtonFs {
|
|||||||
// empty, which it is, so reads stay offline-clean across a restart too.
|
// empty, which it is, so reads stay offline-clean across a restart too.
|
||||||
if local {
|
if local {
|
||||||
st.children.insert(ino, Vec::new());
|
st.children.insert(ino, Vec::new());
|
||||||
if let Err(e) = self.core.db.set_listed(&uid, true) {
|
st.record_listed(uid);
|
||||||
warn!(%uid, error = %e, "db set_listed(true) failed for local folder");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let entry = st.entries.get(&ino).unwrap();
|
let entry = st.entries.get(&ino).unwrap();
|
||||||
let attr = self.attr(ino, &entry.node, entry.access);
|
let attr = self.attr(ino, &entry.node, entry.access);
|
||||||
@@ -1874,7 +2064,7 @@ impl ProtonFs {
|
|||||||
// rename, so the victim has to be removed first and the operation stops
|
// rename, so the victim has to be removed first and the operation stops
|
||||||
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
// being atomic. Without this the 422 surfaced as a blanket EIO and every
|
||||||
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
// write-to-temp-then-rename tool — rsync, atomic editor saves — failed
|
||||||
// at the very end of its transfer (bugs.md B13).
|
// at the very end of its transfer (`docs/BUGS.md` B13).
|
||||||
//
|
//
|
||||||
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
// `RENAME_EXCHANGE` has no Proton primitive and cannot be emulated
|
||||||
// without a window in which one of the two names does not exist.
|
// without a window in which one of the two names does not exist.
|
||||||
@@ -1956,8 +2146,7 @@ impl ProtonFs {
|
|||||||
};
|
};
|
||||||
// A node whose own creation is still queued has no server-side identity
|
// A node whose own creation is still queued has no server-side identity
|
||||||
// to rename: the queued op *is* the node, so rewriting its target is the
|
// to rename: the queued op *is* the node, so rewriting its target is the
|
||||||
// whole rename. Nothing reaches the API, which is why this works offline
|
// whole rename. Nothing reaches the API, which is why this works offline.
|
||||||
// (offline.md Phase 3b).
|
|
||||||
// A create that lands meanwhile leaves no op to rewrite; the node is
|
// A create that lands meanwhile leaves no op to rewrite; the node is
|
||||||
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
|
// then renamed by the uid Drive gave it (docs/BUGS.md B142).
|
||||||
if is_local_uid(&uid) {
|
if is_local_uid(&uid) {
|
||||||
@@ -2156,7 +2345,7 @@ impl ProtonFs {
|
|||||||
.is_some_and(|e| e.open_count > 0);
|
.is_some_and(|e| e.open_count > 0);
|
||||||
// A node the server has never heard of cannot be trashed there; deleting
|
// A node the server has never heard of cannot be trashed there; deleting
|
||||||
// it just means its queued creation is no longer wanted. This works
|
// it just means its queued creation is no longer wanted. This works
|
||||||
// offline, which the remote path below cannot (offline.md Phase 3b).
|
// offline, which the remote path below cannot.
|
||||||
if is_local_uid(&uid) {
|
if is_local_uid(&uid) {
|
||||||
let dropped = if open_now {
|
let dropped = if open_now {
|
||||||
self.core.withdraw_queued_ops(&uid)
|
self.core.withdraw_queued_ops(&uid)
|
||||||
@@ -2185,9 +2374,9 @@ impl ProtonFs {
|
|||||||
}
|
}
|
||||||
// Offline: queue it. Trashing is the one mutation a user expects to work
|
// Offline: queue it. Trashing is the one mutation a user expects to work
|
||||||
// regardless — the file is gone from their point of view the moment the
|
// regardless — the file is gone from their point of view the moment the
|
||||||
// command returns (offline.md Phase 3b). The same when the network goes
|
// command returns. The same when the network goes
|
||||||
// away under the remote call below.
|
// away under the remote call below.
|
||||||
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name) {
|
let queue_it = |reply: ReplyEmpty| match self.core.queue_trash(&uid, name, open_now) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
};
|
};
|
||||||
@@ -2234,7 +2423,7 @@ impl ProtonFs {
|
|||||||
self.core.invalidate_trash();
|
self.core.invalidate_trash();
|
||||||
// Every other trash site records itself; this one did not, which made a
|
// Every other trash site records itself; this one did not, which made a
|
||||||
// file found in the trash impossible to attribute after the fact — the
|
// file found in the trash impossible to attribute after the fact — the
|
||||||
// activity log was the only record and it showed nothing (bugs.md B2).
|
// activity log was the only record and it showed nothing (`docs/BUGS.md` B2).
|
||||||
self.core
|
self.core
|
||||||
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
.log_activity(ActivityKind::Trash, name, "trashed from the mount", true);
|
||||||
reply.ok();
|
reply.ok();
|
||||||
@@ -2319,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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+514
-115
File diff suppressed because it is too large.
Load diff
+113
-10
@@ -9,10 +9,11 @@
|
|||||||
//! queued path, and the probe thread here flips it back when Proton answers.
|
//! queued path, and the probe thread here flips it back when Proton answers.
|
||||||
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use parking_lot::{Condvar, Mutex};
|
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::api::ResponseCode;
|
||||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError};
|
||||||
use tracing::{debug, info, warn};
|
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.
|
/// keep waiting for; queueing the change is the better answer.
|
||||||
pub(crate) const INTERACTIVE_CALL_TIMEOUT: Duration = Duration::from_secs(20);
|
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,
|
/// 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
|
/// and a probe stuck in the SDK's retry budget would delay noticing the link is
|
||||||
/// back by as long as it waits.
|
/// back by as long as it waits.
|
||||||
@@ -54,6 +62,26 @@ pub(crate) struct Link {
|
|||||||
changed: Condvar,
|
changed: Condvar,
|
||||||
/// Folders listed from the DB alone while offline, to relist once back.
|
/// Folders listed from the DB alone while offline, to relist once back.
|
||||||
pub(crate) stale_listings: Mutex<std::collections::HashSet<u64>>,
|
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
|
/// 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.
|
/// transfer that has stopped moving runs into it.
|
||||||
const SLOWEST_UPLOAD: u64 = 32 * 1024;
|
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
|
/// 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`].
|
/// 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`].
|
/// Run a remote call, giving up after [`INTERACTIVE_CALL_TIMEOUT`].
|
||||||
@@ -161,10 +204,20 @@ impl Core {
|
|||||||
self.rt.block_on(bounded_by(limit, call))
|
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
|
/// 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
|
/// network, or of a session, in which case the mount is now offline and the
|
||||||
/// take its queued path instead of failing.
|
/// caller should take its queued path instead of failing.
|
||||||
pub(crate) fn lost_link(&self, e: &ProtonError, what: &str) -> bool {
|
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) {
|
if !is_network_error(e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -172,6 +225,29 @@ impl Core {
|
|||||||
true
|
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
|
/// Flip the mount offline and wake the probe. Only the first caller of an
|
||||||
/// outage logs it; the rest are the same outage.
|
/// outage logs it; the rest are the same outage.
|
||||||
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
pub(crate) fn mark_offline(&self, what: &str, error: &dyn std::fmt::Display) {
|
||||||
@@ -204,8 +280,9 @@ impl Core {
|
|||||||
self.relist_offline_listings();
|
self.relist_offline_listings();
|
||||||
// Anything written while offline is queued and waiting on exactly this,
|
// Anything written while offline is queued and waiting on exactly this,
|
||||||
// including ops that failed on the way down and are sitting out a
|
// including ops that failed on the way down and are sitting out a
|
||||||
// backoff sized for a server that keeps refusing.
|
// backoff sized for a server that keeps refusing. Ops Drive refused
|
||||||
if let Err(e) = self.db.retry_failed_ops_now(crate::now_millis()) {
|
// 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");
|
warn!(error = %e, "moving backed-off ops up after a reconnect failed");
|
||||||
}
|
}
|
||||||
self.wake_drain();
|
self.wake_drain();
|
||||||
@@ -248,7 +325,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Watch the link for the life of the mount: sleep while it is up, and
|
/// Watch the link for the life of the mount: sleep while it is up, and
|
||||||
/// probe it back while it is down (offline.md Phase 1).
|
/// probe it back while it is down.
|
||||||
///
|
///
|
||||||
/// A mount that started from the cache is down from the first moment; one
|
/// A mount that started from the cache is down from the first moment; one
|
||||||
/// that started online goes down when [`Core::mark_offline`] says so.
|
/// that started online goes down when [`Core::mark_offline`] says so.
|
||||||
@@ -276,6 +353,9 @@ impl Core {
|
|||||||
|
|
||||||
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
/// Probe until Proton answers, then flip the mount online. Returns `false`
|
||||||
/// if the daemon is stopping.
|
/// 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 {
|
fn probe_until_online(&self) -> bool {
|
||||||
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
let started = self.link.lost_at.lock().unwrap_or_else(Instant::now);
|
||||||
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
let mut delay = if started.elapsed() < YOUNG_OUTAGE {
|
||||||
@@ -287,6 +367,10 @@ impl Core {
|
|||||||
if !self.shutdown.sleep(delay) {
|
if !self.shutdown.sleep(delay) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if self.session_expired() {
|
||||||
|
delay = ONLINE_PROBE_MAX;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let probe = self.rt.block_on(async {
|
let probe = self.rt.block_on(async {
|
||||||
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
match tokio::time::timeout(PROBE_TIMEOUT, self.drive.get_my_files_folder()).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
@@ -308,6 +392,7 @@ impl Core {
|
|||||||
self.mark_online();
|
self.mark_online();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
Err(e) if session_revoked(&e) => self.mark_session_expired("online probe", &e),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
debug!(error = %e, ?delay, "online probe failed; still offline");
|
debug!(error = %e, ?delay, "online probe failed; still offline");
|
||||||
delay = next_probe_delay(delay, started.elapsed());
|
delay = next_probe_delay(delay, started.elapsed());
|
||||||
@@ -341,13 +426,31 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_upload_gets_longer_the_bigger_it_is() {
|
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!(
|
assert_eq!(
|
||||||
upload_deadline(1 << 30),
|
upload_deadline(1 << 30, 0),
|
||||||
INTERACTIVE_CALL_TIMEOUT + Duration::from_secs(32_768)
|
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]
|
#[test]
|
||||||
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
fn a_call_past_its_deadline_reads_as_a_lost_link() {
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
|||||||
+154
-38
@@ -12,6 +12,10 @@ pub enum MountOutcome {
|
|||||||
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
/// The kernel mount ended on its own (e.g. an external `fusermount -u`).
|
||||||
/// The caller may want to remount.
|
/// The caller may want to remount.
|
||||||
Unmounted,
|
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
|
/// Whether `path` is a mountpoint whose FUSE connection is dead — the state a
|
||||||
@@ -192,8 +196,31 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refuse to mount My files over a folder that already has files in it: FUSE
|
||||||
|
/// would hide them, unsent and out of sight, until the service stops
|
||||||
|
/// (`docs/BUGS.md` B277). Runs before the root fetch and before any worker
|
||||||
|
/// starts, so a refusal costs nothing to retry.
|
||||||
|
///
|
||||||
|
/// A dead mount is cleared first, since it cannot be listed. One of this
|
||||||
|
/// client's own live mounts is left to [`spawn_session`], which clears it as it
|
||||||
|
/// always has; listing it would show Drive's files, not the folder's.
|
||||||
|
pub(super) fn check_mountpoint(mountpoint: &Path) -> std::io::Result<()> {
|
||||||
|
if is_stale_mount(mountpoint) {
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
}
|
||||||
|
if pdfs_core::mountpoint::mounted_fs_type(mountpoint).as_deref()
|
||||||
|
== Some(pdfs_core::mountpoint::OWN_FS_TYPE)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match pdfs_core::mountpoint::not_empty(mountpoint)? {
|
||||||
|
Some(found) => Err(std::io::Error::other(found)),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
/// `sync_state` key holding the uid of the My Files root, so a later run can
|
||||||
/// recover it from `nodes` without the API (offline.md Phase 1).
|
/// recover it from `nodes` without the API.
|
||||||
const ROOT_UID_KEY: &str = "root_uid";
|
const ROOT_UID_KEY: &str = "root_uid";
|
||||||
|
|
||||||
/// The My Files root, and whether we got it from the API (`true`) or from the
|
/// The My Files root, and whether we got it from the API (`true`) or from the
|
||||||
@@ -210,30 +237,60 @@ fn fetch_or_recall_root(
|
|||||||
drive: &dyn DriveApi,
|
drive: &dyn DriveApi,
|
||||||
rt: &tokio::runtime::Handle,
|
rt: &tokio::runtime::Handle,
|
||||||
db: &Db,
|
db: &Db,
|
||||||
) -> std::io::Result<(Node, bool)> {
|
) -> std::io::Result<(Node, Start)> {
|
||||||
let err = match rt.block_on(drive.get_my_files_folder()) {
|
let err = match rt.block_on(drive.get_my_files_folder()) {
|
||||||
Ok(root) => {
|
Ok(root) => {
|
||||||
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
if let Err(e) = db.set_state_str(ROOT_UID_KEY, &root.uid.to_string()) {
|
||||||
warn!(error = %e, "persist root uid failed");
|
warn!(error = %e, "persist root uid failed");
|
||||||
}
|
}
|
||||||
return Ok((root, true));
|
return Ok((root, Start::Online));
|
||||||
}
|
}
|
||||||
Err(e) => e,
|
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
|
let cached = db
|
||||||
.state_str(ROOT_UID_KEY)
|
.state_str(ROOT_UID_KEY)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
.and_then(|uid| db.node_by_uid(&uid).ok().flatten());
|
||||||
match cached {
|
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) => {
|
Some(root) => {
|
||||||
warn!(error = %err, "fetch My Files root failed; mounting from cache (offline)");
|
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}"))),
|
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
|
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||||
/// caller, so `mount` itself never reads config or the environment.
|
/// caller, so `mount` itself never reads config or the environment.
|
||||||
pub struct MountOptions {
|
pub struct MountOptions {
|
||||||
@@ -393,7 +450,9 @@ pub(crate) fn mount_with(
|
|||||||
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
// so the age it reports is the daemon's own.
|
// so the age it reports is the daemon's own.
|
||||||
super::diagnostics::start_clock();
|
super::diagnostics::start_clock();
|
||||||
let (root, online) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
check_mountpoint(mountpoint)?;
|
||||||
|
let (root, start) = fetch_or_recall_root(drive.as_ref(), &rt, &db)?;
|
||||||
|
let online = start == Start::Online;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
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}")))?;
|
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||||
@@ -401,7 +460,7 @@ pub(crate) fn mount_with(
|
|||||||
.all_share_access()
|
.all_share_access()
|
||||||
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||||
|
|
||||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
// The folder-sync engine runs on its own thread and is
|
||||||
// nudged over this channel; the sender lives in Core so control-socket
|
// nudged over this channel; the sender lives in Core so control-socket
|
||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
@@ -429,7 +488,7 @@ pub(crate) fn mount_with(
|
|||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
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)),
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
@@ -472,9 +531,11 @@ pub(crate) fn mount_with(
|
|||||||
events: Arc::new(Default::default()),
|
events: Arc::new(Default::default()),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sync_engine: Arc::default(),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
sync_unscanned: Arc::new(Mutex::new(HashMap::new())),
|
||||||
states: Arc::new(StateRegistry::default()),
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
@@ -501,15 +562,14 @@ pub(crate) fn mount_with(
|
|||||||
// Every long-lived thread started from here on is kept in `workers` and
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
// joined at the end of this function. Leaving them running turned an
|
// joined at the end of this function. Leaving them running turned an
|
||||||
// in-process remount into a second generation of drain workers, sync engines
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
// and sweeps all operating on a mount that no longer exists (`docs/BUGS.md` B44).
|
||||||
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
for worker in 0..DRAIN_WORKERS {
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let drain_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
.name(format!("pdfs-drain-{worker}"))
|
||||||
.name(format!("pdfs-drain-{worker}"))
|
.spawn(move || drain_core.run_pending_drain(worker == 0));
|
||||||
.spawn(move || 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
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
@@ -529,24 +589,22 @@ pub(crate) fn mount_with(
|
|||||||
info!("conflict sweep disabled by configuration");
|
info!("conflict sweep disabled by configuration");
|
||||||
} else {
|
} else {
|
||||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
let core = core.clone();
|
let sweep_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
.name("pdfs-conflict-sweep".into())
|
||||||
.name("pdfs-conflict-sweep".into())
|
.spawn(move || sweep_core.run_conflict_sweep_loop());
|
||||||
.spawn(move || 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
|
// 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
|
// the network to come back, and an online one can lose it at any moment and
|
||||||
// must notice it coming back just the same.
|
// must notice it coming back just the same.
|
||||||
{
|
{
|
||||||
let core = core.clone();
|
let probe_core = core.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
.name("pdfs-online-probe".into())
|
||||||
.name("pdfs-online-probe".into())
|
.spawn(move || probe_core.run_online_probe());
|
||||||
.spawn(move || 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:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
@@ -557,11 +615,10 @@ pub(crate) fn mount_with(
|
|||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
let shutdown = core.shutdown.clone();
|
let shutdown = core.shutdown.clone();
|
||||||
workers.push(
|
let spawned = std::thread::Builder::new()
|
||||||
std::thread::Builder::new()
|
.name("pdfs-localindex".into())
|
||||||
.name("pdfs-localindex".into())
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown));
|
||||||
.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
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
@@ -570,12 +627,18 @@ pub(crate) fn mount_with(
|
|||||||
let old_umask = unsafe { libc::umask(0o77) };
|
let old_umask = unsafe { libc::umask(0o77) };
|
||||||
let listener_res = UnixListener::bind(control_socket);
|
let listener_res = UnixListener::bind(control_socket);
|
||||||
unsafe { libc::umask(old_umask) };
|
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
|
// Owner-only before anything can connect: a peer on this socket commands the
|
||||||
// daemon's authenticated session without a credential of its own (B6).
|
// daemon's authenticated session without a credential of its own (B6).
|
||||||
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
|
if let Err(e) = pdfs_core::config::restrict_socket(control_socket) {
|
||||||
error!(error = %e, "cannot restrict control socket permissions; refusing to serve");
|
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!(
|
return Err(std::io::Error::other(format!(
|
||||||
"control socket permissions: {e}"
|
"control socket permissions: {e}"
|
||||||
)));
|
)));
|
||||||
@@ -586,7 +649,7 @@ pub(crate) fn mount_with(
|
|||||||
let bg = match spawn_session(&core, mountpoint, root) {
|
let bg = match spawn_session(&core, mountpoint, root) {
|
||||||
Ok(session) => session,
|
Ok(session) => session,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = std::fs::remove_file(control_socket);
|
abandon_workers(&core, control_socket, workers);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -606,7 +669,7 @@ pub(crate) fn mount_with(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
// A failure here abandons the mount, so the background threads
|
// A failure here abandons the mount, so the background threads
|
||||||
// started above have to be told to stop with it.
|
// 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();
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
for (id, mount) in secondaries {
|
for (id, mount) in secondaries {
|
||||||
if let Err(unmount_error) = mount.teardown() {
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
@@ -656,8 +719,12 @@ pub(crate) fn mount_with(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The mount is live and the control socket answers: this is the moment the
|
// 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();
|
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.
|
// 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
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
@@ -718,6 +785,18 @@ pub(crate) fn mount_with(
|
|||||||
}
|
}
|
||||||
break MountOutcome::Shutdown;
|
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) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
|
||||||
if bg.guard.is_finished() {
|
if bg.guard.is_finished() {
|
||||||
info!("mount ended externally");
|
info!("mount ended externally");
|
||||||
@@ -738,7 +817,7 @@ pub(crate) fn mount_with(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
// Unmount every on-demand sync folder too, or the kernel mounts linger as
|
||||||
// stale and the next start fails with EBUSY (devices.md Phase 3).
|
// stale and the next start fails with EBUSY.
|
||||||
if let Some(worker) = restore_worker
|
if let Some(worker) = restore_worker
|
||||||
&& worker.join().is_err()
|
&& worker.join().is_err()
|
||||||
{
|
{
|
||||||
@@ -798,7 +877,7 @@ const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
|||||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
/// probe, indexer and control listener all still running on `Core` clones. The
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
/// process usually exited straight afterwards and hid it; an in-process remount
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
/// did not, and started a second full set on top of the first (bugs.md B44).
|
/// did not, and started a second full set on top of the first (`docs/BUGS.md` B44).
|
||||||
///
|
///
|
||||||
/// Two of the waits need waking rather than merely flagging: the drain workers
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
@@ -811,6 +890,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
|
// 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.
|
// a missed watchdog ping and turned into a kill.
|
||||||
systemd::stopping();
|
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.shutdown.stop();
|
||||||
core.wake_drain();
|
core.wake_drain();
|
||||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
|
|||||||
@@ -226,11 +226,7 @@ fn scale_thumbnail_with_exiftool(
|
|||||||
exiftool: &OsStr,
|
exiftool: &OsStr,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> ScaleAttempt {
|
) -> ScaleAttempt {
|
||||||
let direct = image::ImageReader::new(std::io::Cursor::new(bytes))
|
let image = match decode_upright(bytes) {
|
||||||
.with_guessed_format()
|
|
||||||
.ok()
|
|
||||||
.and_then(|reader| reader.decode().ok());
|
|
||||||
let image = match direct {
|
|
||||||
Some(image) => image,
|
Some(image) => image,
|
||||||
None if is_raw_image_name(name) => {
|
None if is_raw_image_name(name) => {
|
||||||
let (preview, orientation) =
|
let (preview, orientation) =
|
||||||
@@ -256,6 +252,27 @@ fn scale_thumbnail_with_exiftool(
|
|||||||
encode_thumb(&image)
|
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.
|
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||||||
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||||||
let (width, height) = (image.width(), image.height());
|
let (width, height) = (image.width(), image.height());
|
||||||
@@ -417,6 +434,16 @@ fn exiftool_query(
|
|||||||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
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
|
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||||||
/// than a picture the image decoder can scale.
|
/// than a picture the image decoder can scale.
|
||||||
fn is_video_name(name: &str) -> bool {
|
fn is_video_name(name: &str) -> bool {
|
||||||
@@ -1317,7 +1344,7 @@ impl Core {
|
|||||||
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
|
let next = |current| if current == u64::MAX { 1 } else { current + 1 };
|
||||||
let previous = self
|
let previous = self
|
||||||
.file_thumb_generation
|
.file_thumb_generation
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
|
.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
|
||||||
Some(next(current))
|
Some(next(current))
|
||||||
})
|
})
|
||||||
.expect("generation update always succeeds");
|
.expect("generation update always succeeds");
|
||||||
@@ -2092,9 +2119,10 @@ impl Core {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumb_tests {
|
mod thumb_tests {
|
||||||
use super::{
|
use super::{
|
||||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_IMAGE_MAX_BYTES, UPLOAD_THUMB_MAX_BYTES,
|
||||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
can_make_thumbnail, exiftool_binary, ffmpeg_available, fit_upload_thumb, is_video_name,
|
||||||
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
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 pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||||
use proton_drive_rs::ThumbnailType;
|
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));
|
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]
|
#[test]
|
||||||
fn undecodable_bytes_are_not_a_thumbnail() {
|
fn undecodable_bytes_are_not_a_thumbnail() {
|
||||||
// What a photo in a format this build has no decoder for looks like: the
|
// What a photo in a format this build has no decoder for looks like: the
|
||||||
|
|||||||
+160
-58
@@ -1,5 +1,5 @@
|
|||||||
//! Reading and writing this machine's `profile.json` in its device root, and
|
//! Reading and writing this machine's `profile.json` in its device root, and
|
||||||
//! the restore that rebuilds a machine from one (features.md 5.2 and 5.3).
|
//! the restore that rebuilds a machine from one.
|
||||||
//!
|
//!
|
||||||
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
//! The document itself is defined in [`pdfs_core::profile`]; this is the half
|
||||||
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
//! that talks to Drive. Saving is best-effort and never blocks a user action:
|
||||||
@@ -15,6 +15,7 @@ use pdfs_core::control::RestorableFolder;
|
|||||||
use pdfs_core::mounts::MountMode;
|
use pdfs_core::mounts::MountMode;
|
||||||
use pdfs_core::profile::{
|
use pdfs_core::profile::{
|
||||||
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
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 pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
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`
|
/// Serialize this machine's arrangement and upload it to its own
|
||||||
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
/// `profile-<machine id>.json` inside the device's `.proton-drive-linux`
|
||||||
/// one.
|
/// 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
|
/// 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
|
/// 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))
|
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 bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||||
let len = bytes.len() as i64;
|
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
|
// same name: the profile is a single mutable record, and a folder with
|
||||||
// `profile.json` and `profile (1).json` in it teaches the restore
|
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||||
// nothing about which to trust.
|
// 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 {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
@@ -114,7 +124,7 @@ impl Core {
|
|||||||
self.rt
|
self.rt
|
||||||
.block_on(self.drive.upload_file_replacing_draft_from(
|
.block_on(self.drive.upload_file_replacing_draft_from(
|
||||||
&dir_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
&file_name,
|
||||||
"application/json",
|
"application/json",
|
||||||
Cursor::new(bytes),
|
Cursor::new(bytes),
|
||||||
len,
|
len,
|
||||||
@@ -130,7 +140,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot the local arrangement into a [`Profile`].
|
/// 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
|
let folders = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -162,6 +172,7 @@ impl Core {
|
|||||||
version: PROFILE_VERSION,
|
version: PROFILE_VERSION,
|
||||||
device_uid: device_uid.to_string(),
|
device_uid: device_uid.to_string(),
|
||||||
hostname: this_hostname(),
|
hostname: this_hostname(),
|
||||||
|
machine_id: Some(machine_id.to_string()),
|
||||||
saved_at: now_secs(),
|
saved_at: now_secs(),
|
||||||
folders,
|
folders,
|
||||||
pins,
|
pins,
|
||||||
@@ -197,6 +208,31 @@ impl Core {
|
|||||||
.map(|n| n.uid))
|
.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
|
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||||
/// first save on this device.
|
/// first save on this device.
|
||||||
///
|
///
|
||||||
@@ -218,35 +254,46 @@ impl Core {
|
|||||||
Ok(uid)
|
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,
|
/// Reads every profile in `.proton-drive-linux/`: one per machine, and the
|
||||||
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
/// shared `profile.json` of clients before 3.5.0. Falls back to the device
|
||||||
/// next save writes to the folder, and the legacy document is left alone
|
/// root, where clients before the B78 fix wrote it. The fallback is
|
||||||
/// rather than trashed, so an older client on another machine keeps working.
|
/// read-only: the next save writes to the folder, and the legacy document
|
||||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
/// is left alone rather than trashed, so an older client on another
|
||||||
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
/// machine keeps working. A profile that cannot be read is left out and
|
||||||
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
/// logged; the others still count.
|
||||||
None => None,
|
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 {
|
if uids.is_empty() {
|
||||||
Some(uid) => uid,
|
uids = self.device_child_files(root_uid, |name| name == PROFILE_FILE_NAME)?;
|
||||||
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
if !uids.is_empty() {
|
||||||
Some(uid) => {
|
info!("reading legacy profile from the device root");
|
||||||
info!("reading legacy profile from the device root");
|
}
|
||||||
uid
|
}
|
||||||
|
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),
|
}
|
||||||
},
|
}
|
||||||
};
|
Ok(profiles)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore ----------------------------------------
|
||||||
|
|
||||||
/// The folders under a device that could be synced here, each carrying a
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// proposed local path. `device` names another registered device by uid;
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
@@ -265,15 +312,16 @@ impl Core {
|
|||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
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,
|
Ok(p) => p,
|
||||||
// A missing or unreadable profile is not fatal: the remote tree
|
// A missing or unreadable profile is not fatal: the remote tree
|
||||||
// alone is enough to offer a restore, just with plainer defaults.
|
// alone is enough to offer a restore, just with plainer defaults.
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e.message, "could not read profile; proposing defaults");
|
warn!(error = %e.message, "could not read profiles; proposing defaults");
|
||||||
None
|
Vec::new()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let hostname = this_hostname();
|
||||||
|
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -304,11 +352,12 @@ impl Core {
|
|||||||
// offering it as a restorable folder would sync the bookkeeping
|
// offering it as a restorable folder would sync the bookkeeping
|
||||||
// back onto the machine it describes.
|
// back onto the machine it describes.
|
||||||
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
.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| {
|
.map(|n| {
|
||||||
let uid = n.uid.to_string();
|
let uid = n.uid.to_string();
|
||||||
let saved = profile
|
let saved = saved_folder(&profiles, &uid, &hostname);
|
||||||
.as_ref()
|
|
||||||
.and_then(|p| p.folders.iter().find(|f| f.remote_uid == uid));
|
|
||||||
let proposed = saved
|
let proposed = saved
|
||||||
.map(|f| PathBuf::from(&f.local_path))
|
.map(|f| PathBuf::from(&f.local_path))
|
||||||
.filter(|p| path_is_usable_here(p))
|
.filter(|p| path_is_usable_here(p))
|
||||||
@@ -339,7 +388,7 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
device: Option<&str>,
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<RestoreOutcome> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
@@ -349,23 +398,29 @@ impl Core {
|
|||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.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 restored = 0usize;
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
let local = PathBuf::from(&item.local_path);
|
// The path is proposed from the profile another machine wrote, and
|
||||||
if !local.is_absolute() {
|
// may hold a folder name from Drive: checked like any new root.
|
||||||
skipped.push(format!("{}: local path must be absolute", item.local_path));
|
let local = match pdfs_core::syncroot::resolve(Path::new(&item.local_path)) {
|
||||||
continue;
|
Ok(local) => local,
|
||||||
}
|
Err(e) => {
|
||||||
|
skipped.push(format!("{}: {e}", item.local_path));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
|
if existing.iter().any(|f| f.remote_uid == item.remote_uid) {
|
||||||
skipped.push(format!("{}: already synced", item.local_path));
|
skipped.push(format!("{}: already synced", item.local_path));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if existing
|
if let Some(problem) = pdfs_core::syncroot::problem(&local, &roots, &reserved) {
|
||||||
.iter()
|
skipped.push(format!("{}: {problem}", item.local_path));
|
||||||
.any(|f| Path::new(&f.local_path) == local.as_path())
|
|
||||||
{
|
|
||||||
skipped.push(format!("{}: path already in use", item.local_path));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Err(e) = std::fs::create_dir_all(&local) {
|
if let Err(e) = std::fs::create_dir_all(&local) {
|
||||||
@@ -379,10 +434,19 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let id = self
|
let id = match self.db.sync_folder_add(
|
||||||
.db
|
&local.to_string_lossy(),
|
||||||
.sync_folder_add(&local.to_string_lossy(), &item.remote_uid, &device.share_id)
|
&item.remote_uid,
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
&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
|
// `ondemand` is applied through the normal request path, which
|
||||||
// mounts the FUSE session and handles the folder being busy — the
|
// mounts the FUSE session and handles the folder being busy — the
|
||||||
// restore does not get its own copy of that logic.
|
// 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.request_sync_folder_mode(id, "ondemand");
|
||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
roots.push(local);
|
||||||
restored += 1;
|
restored += 1;
|
||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
|
||||||
let mut message = format!("restored {restored} folder(s)");
|
// The reply is gone once the app has shown it; the log keeps why.
|
||||||
if !skipped.is_empty() {
|
for reason in &skipped {
|
||||||
message.push_str(&format!("; skipped: {}", skipped.join(", ")));
|
info!(reason, "restore skipped a folder");
|
||||||
}
|
}
|
||||||
info!(restored, skipped = skipped.len(), "restore complete");
|
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]
|
#[test]
|
||||||
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||||
let source = include_str!("profile.rs");
|
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.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||||
assert!(
|
assert!(
|
||||||
load.find("PROFILE_DIR_NAME").unwrap()
|
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
|
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||||
/// picker nor `add_sync_folder` may treat it as a user folder.
|
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+215
-37
@@ -464,7 +464,7 @@ impl Core {
|
|||||||
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
/// it, and the [`BLOCK_SIZE`] assumption when it has not — which is every
|
||||||
/// first read, because learning it means opening a `RevisionReader`, and
|
/// first read, because learning it means opening a `RevisionReader`, and
|
||||||
/// opening one per read is precisely the per-file key derivation the block
|
/// opening one per read is precisely the per-file key derivation the block
|
||||||
/// cache exists to avoid (bugs.md B12, B85).
|
/// cache exists to avoid (`docs/BUGS.md` B12, B85).
|
||||||
///
|
///
|
||||||
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
/// Being wrong here is not a correctness problem: `read_at` plans over the
|
||||||
/// real sizes and clamps to what was asked for either way. It costs a
|
/// real sizes and clamps to what was asked for either way. It costs a
|
||||||
@@ -493,13 +493,13 @@ impl Core {
|
|||||||
) -> Result<Vec<u8>, Errno> {
|
) -> Result<Vec<u8>, Errno> {
|
||||||
// A queued write has not reached the remote yet, so the remote's current
|
// A queued write has not reached the remote yet, so the remote's current
|
||||||
// revision is stale and the staged blob is the truth. Serve from it until
|
// revision is stale and the staged blob is the truth. Serve from it until
|
||||||
// the drain worker lands the upload (offline.md Phase 3).
|
// the drain worker lands the upload.
|
||||||
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
if let Some(pending) = self.pending.lock().get(uid).cloned() {
|
||||||
return self.read_pending(&pending, offset, len);
|
return self.read_pending(&pending, offset, len);
|
||||||
}
|
}
|
||||||
// A node created offline and never written has no blob and no remote: it
|
// A node created offline and never written has no blob and no remote: it
|
||||||
// is an empty file, and asking the API about a `local~` uid would only
|
// is an empty file, and asking the API about a `local~` uid would only
|
||||||
// earn a 404 (offline.md Phase 3b). Unless its create landed since the
|
// earn a 404. Unless its create landed since the
|
||||||
// read looked: the landing moved the queued write to the uid Drive
|
// read looked: the landing moved the queued write to the uid Drive
|
||||||
// gave it, and the row says which (`docs/BUGS.md` B179).
|
// gave it, and the row says which (`docs/BUGS.md` B179).
|
||||||
if is_local_uid(uid) {
|
if is_local_uid(uid) {
|
||||||
@@ -636,7 +636,7 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// A block that comes back shorter than the file's size says it must be is
|
/// A block that comes back shorter than the file's size says it must be is
|
||||||
/// never served, cached or promoted: it is repaired from the whole-file
|
/// never served, cached or promoted: it is repaired from the whole-file
|
||||||
/// download, or the read fails. See [`Core::repair_block`] (bugs.md B84).
|
/// download, or the read fails. See [`Core::repair_block`] (`docs/BUGS.md` B84).
|
||||||
async fn read_block(
|
async fn read_block(
|
||||||
&self,
|
&self,
|
||||||
reader: &Arc<dyn RevisionRead>,
|
reader: &Arc<dyn RevisionRead>,
|
||||||
@@ -651,7 +651,7 @@ impl Core {
|
|||||||
// file's boundaries are wrong, and the failure is not confined to short
|
// file's boundaries are wrong, and the failure is not confined to short
|
||||||
// reads: sizes that *overstate* a block shift every block after it and
|
// reads: sizes that *overstate* a block shift every block after it and
|
||||||
// return full-length reads of the wrong bytes. So a disagreement of any
|
// return full-length reads of the wrong bytes. So a disagreement of any
|
||||||
// kind takes the file off the range path entirely (bugs.md B84).
|
// kind takes the file off the range path entirely (`docs/BUGS.md` B84).
|
||||||
let bytes = if reader.size() != fsize {
|
let bytes = if reader.size() != fsize {
|
||||||
warn!(
|
warn!(
|
||||||
%uid, bidx = span.idx, fsize,
|
%uid, bidx = span.idx, fsize,
|
||||||
@@ -664,7 +664,7 @@ impl Core {
|
|||||||
// Learned here rather than on a path of its own: this is where a
|
// Learned here rather than on a path of its own: this is where a
|
||||||
// reader is open anyway, and recording the real geometry is what
|
// reader is open anyway, and recording the real geometry is what
|
||||||
// lets the *next* read plan its blocks on the revision's own
|
// lets the *next* read plan its blocks on the revision's own
|
||||||
// boundaries instead of the 4 MiB fallback (bugs.md B85).
|
// boundaries instead of the 4 MiB fallback (`docs/BUGS.md` B85).
|
||||||
if cache_blocks {
|
if cache_blocks {
|
||||||
self.cache
|
self.cache
|
||||||
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
.store_block_geometry(uid, mtime, fsize, reader.block_sizes());
|
||||||
@@ -706,7 +706,7 @@ impl Core {
|
|||||||
/// The range path derives every block boundary from the revision's recorded
|
/// The range path derives every block boundary from the revision's recorded
|
||||||
/// block sizes. When those understate the file, reads come back short with
|
/// block sizes. When those understate the file, reads come back short with
|
||||||
/// no error anywhere, and the caller hands userspace a truncated file that
|
/// no error anywhere, and the caller hands userspace a truncated file that
|
||||||
/// looks like a clean EOF (bugs.md B84). `download_file_to` does not do that
|
/// looks like a clean EOF (`docs/BUGS.md` B84). `download_file_to` does not do that
|
||||||
/// arithmetic — it streams the block list — so it returns the whole file
|
/// arithmetic — it streams the block list — so it returns the whole file
|
||||||
/// where the range path cannot, which is why pinning was the workaround.
|
/// where the range path cannot, which is why pinning was the workaround.
|
||||||
/// This does the same thing on the user's behalf, once, and the resulting
|
/// This does the same thing on the user's behalf, once, and the resulting
|
||||||
@@ -918,7 +918,7 @@ impl Core {
|
|||||||
let end = offset.saturating_add(len).min(fsize);
|
let end = offset.saturating_add(len).min(fsize);
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||||
// On the revision's own boundaries where they are known, and on the
|
// On the revision's own boundaries where they are known, and on the
|
||||||
// 4 MiB fallback where they are not (bugs.md B85).
|
// 4 MiB fallback where they are not (`docs/BUGS.md` B85).
|
||||||
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
let wanted = self.block_geometry(uid, mtime, fsize).spans(offset, end);
|
||||||
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
let (Some(&head), Some(&tail)) = (wanted.first(), wanted.last()) else {
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
@@ -1038,7 +1038,7 @@ impl Core {
|
|||||||
// — the cache validates it on read, the network path in `read_block`
|
// — the cache validates it on read, the network path in `read_block`
|
||||||
// — so this refuses rather than quietly contributing fewer bytes
|
// — so this refuses rather than quietly contributing fewer bytes
|
||||||
// than the range covers, which is what turned a bad block into a
|
// than the range covers, which is what turned a bad block into a
|
||||||
// truncated file (bugs.md B84).
|
// truncated file (`docs/BUGS.md` B84).
|
||||||
if block.len() as u64 != span.len {
|
if block.len() as u64 != span.len {
|
||||||
error!(
|
error!(
|
||||||
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
%uid, bidx = span.idx, len = block.len(), expected = span.len, fsize,
|
||||||
@@ -1070,37 +1070,121 @@ impl Core {
|
|||||||
offset: u64,
|
offset: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
) -> Result<Vec<u8>, Errno> {
|
) -> Result<Vec<u8>, Errno> {
|
||||||
if offset >= len || size == 0 {
|
let read = assemble_open_read(file, len, uid, base_size, written, offset, size, |s, n| {
|
||||||
return Ok(Vec::new());
|
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 (uid, mtime, fsize, provisional) = {
|
||||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
let st = self.state();
|
||||||
for (s, e, authored) in written.segments(offset, end) {
|
match st.entries.get(&ino) {
|
||||||
if authored {
|
Some(e) if e.node.is_file() => (
|
||||||
let mut buf = vec![0u8; (e - s) as usize];
|
e.uid.clone(),
|
||||||
file.read_exact_at(&mut buf, s).map_err(|err| {
|
e.node.modification_time,
|
||||||
warn!(%uid, error = %err, "scratch read failed");
|
node_size(&e.node),
|
||||||
Errno::EIO
|
matches!(
|
||||||
})?;
|
&e.node.kind,
|
||||||
out.extend_from_slice(&buf);
|
NodeKind::File {
|
||||||
} else {
|
claimed_size: None,
|
||||||
let bend = e.min(base_size);
|
..
|
||||||
if s < bend {
|
}
|
||||||
out.extend_from_slice(&self.read_range(
|
),
|
||||||
uid,
|
),
|
||||||
base_mtime,
|
Some(_) => return Err(Errno::EISDIR),
|
||||||
base_size,
|
None => return Err(Errno::ENOENT),
|
||||||
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 = 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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod block_ring_tests {
|
mod block_ring_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1394,3 +1521,54 @@ mod prefetch_tests {
|
|||||||
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
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())))?
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||||
.len();
|
.len();
|
||||||
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||||
|
let thumbnails = self.upload_thumbnails(&path, &candidate.name);
|
||||||
|
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
@@ -206,7 +207,7 @@ impl Core {
|
|||||||
media_type,
|
media_type,
|
||||||
reader,
|
reader,
|
||||||
size as i64,
|
size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
metadata,
|
metadata,
|
||||||
false,
|
false,
|
||||||
));
|
));
|
||||||
@@ -291,7 +292,14 @@ impl Core {
|
|||||||
let mut state = REDATE.lock();
|
let mut state = REDATE.lock();
|
||||||
state.running = false;
|
state.running = false;
|
||||||
state.cancel = None;
|
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 {
|
match &outcome {
|
||||||
Ok(summary) => info!(
|
Ok(summary) => info!(
|
||||||
candidates = summary.candidates,
|
candidates = summary.candidates,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use proton_drive_rs::NodeKind;
|
|||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::state::State;
|
||||||
use super::sync::mirror_subtree_unsynced;
|
use super::sync::mirror_subtree_unsynced;
|
||||||
use super::{Core, is_local_uid, parse_uid};
|
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.
|
/// Whether `uid` is `root` or lies under it, as far as the nodes table knows.
|
||||||
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
fn under(core: &Core, root: &NodeUid, uid: &str) -> bool {
|
||||||
let root = root.to_string();
|
let root = root.to_string();
|
||||||
@@ -336,7 +355,7 @@ impl Core {
|
|||||||
core.require_uid_writable(authority)
|
core.require_uid_writable(authority)
|
||||||
.map_err(|e| core.errno_error(e, "move access"))?;
|
.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(
|
return Err(CoreError::conflict(
|
||||||
"it has changes that are not uploaded yet; wait for the upload to finish",
|
"it has changes that are not uploaded yet; wait for the upload to finish",
|
||||||
));
|
));
|
||||||
@@ -410,10 +429,11 @@ impl Core {
|
|||||||
Ok(uid)
|
Ok(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the queue owes Drive anything for `uid` or a node under it, or a
|
/// Whether the queue owes Drive anything for `uid` (inode `ino`) or a node
|
||||||
/// file under it is open for writing. Moving such a node away would strand
|
/// under it, or a file under it is open for writing. Moving such a node away
|
||||||
/// the pending change against a path that no longer leads to it.
|
/// would strand the pending change against a path that no longer leads to
|
||||||
fn has_pending_work_under(&self, uid: &NodeUid) -> CoreResult<bool> {
|
/// it.
|
||||||
|
fn has_pending_work_under(&self, ino: u64, uid: &NodeUid) -> CoreResult<bool> {
|
||||||
let ops = self
|
let ops = self
|
||||||
.db
|
.db
|
||||||
.pending_ops()
|
.pending_ops()
|
||||||
@@ -427,12 +447,19 @@ impl Core {
|
|||||||
}) {
|
}) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
// Collected first so no State lock is held across the nodes-table reads.
|
// Open handles are placed in the tree by inode, not by uid in the nodes
|
||||||
let mut writing = Vec::new();
|
// 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| {
|
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.
|
/// Bring the source location level with a move Drive has made.
|
||||||
|
|||||||
+106
-13
@@ -9,7 +9,8 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use pdfs_core::control::{
|
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 pdfs_core::{CoreError, CoreResult};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -66,6 +67,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
size,
|
size,
|
||||||
modified: n.modification_time,
|
modified: n.modification_time,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
@@ -366,6 +368,89 @@ impl Core {
|
|||||||
Ok(public_link_info(link))
|
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`.
|
/// Remove the public link `id` from the node at `rel`.
|
||||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
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
|
/// 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
|
/// node racing with an unshare drops from the list rather than failing the whole
|
||||||
/// request.
|
/// 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>> {
|
pub(crate) fn list_shared_by_me(&self) -> CoreResult<Vec<SharedItem>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
@@ -678,24 +769,26 @@ impl Core {
|
|||||||
let members = self
|
let members = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(&uid))
|
||||||
.map(|m| m.len())
|
.map(|m| m.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let proton_invites = self
|
let proton_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
|
||||||
let external_invites = self
|
let external_invites = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(&uid))
|
||||||
.map(|i| i.len())
|
.map(|i| i.len());
|
||||||
.unwrap_or(0);
|
let link = self.rt.block_on(self.client.get_public_link(&uid));
|
||||||
let link = self
|
if let (Ok(0), Ok(0), Ok(0), Ok(None)) =
|
||||||
.rt
|
(&members, &proton_invites, &external_invites, &link)
|
||||||
.block_on(self.client.get_public_link(&uid))
|
{
|
||||||
.ok()
|
debug!(%uid, "shared by me: skipping an emptied share");
|
||||||
.flatten()
|
continue;
|
||||||
.map(public_link_info);
|
}
|
||||||
|
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 {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||||
//! mount going away underneath it — it leaks a whole generation of threads that
|
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||||
//! keep writing to the database and calling the API on behalf of a mount that no
|
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||||
//! longer exists (bugs.md B44).
|
//! longer exists (`docs/BUGS.md` B44).
|
||||||
//!
|
//!
|
||||||
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
|||||||
+1148
-25
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
//! An in-memory Drive for the simulation tests (`docs/MILESTONE-3.0.0.md` §8.1).
|
//! An in-memory Drive for the simulation tests (`docs/TESTING.md`).
|
||||||
//!
|
//!
|
||||||
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
//! [`FakeDrive`] is the server: one tree of nodes with uids, revisions and
|
||||||
//! names, and an event log. Each simulated daemon reaches it through its own
|
//! names, and an event log. Each simulated daemon reaches it through its own
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
//! - **A full account** ([`FakeDrive::set_quota`]) refusing uploads with
|
||||||
//! `InsufficientQuota`.
|
//! `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
|
//! 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
|
//! ([`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`]),
|
//! 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::account::Quota;
|
||||||
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
use proton_drive_rs::proton_sdk::api::ResponseCode;
|
||||||
use proton_drive_rs::proton_sdk::error::{ProtonApiError, ProtonError, Result};
|
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::{
|
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};
|
use sha1::{Digest as _, Sha1};
|
||||||
|
|
||||||
@@ -111,6 +117,9 @@ struct Server {
|
|||||||
block_pattern: Vec<u64>,
|
block_pattern: Vec<u64>,
|
||||||
/// The account's storage limit in bytes.
|
/// The account's storage limit in bytes.
|
||||||
max_space: i64,
|
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 {
|
impl Server {
|
||||||
@@ -162,6 +171,53 @@ impl Server {
|
|||||||
false
|
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 {
|
fn mint_uid(&mut self) -> NodeUid {
|
||||||
self.next_link += 1;
|
self.next_link += 1;
|
||||||
uid(&format!("L{}", self.next_link))
|
uid(&format!("L{}", self.next_link))
|
||||||
@@ -305,6 +361,7 @@ impl FakeDrive {
|
|||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
block_pattern: vec![pdfs_core::cache::BLOCK_SIZE],
|
||||||
max_space: 1 << 40,
|
max_space: 1 << 40,
|
||||||
|
devices: Vec::new(),
|
||||||
};
|
};
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
server.nodes.insert(
|
server.nodes.insert(
|
||||||
@@ -338,6 +395,8 @@ impl FakeDrive {
|
|||||||
hold: Mutex::new(None),
|
hold: Mutex::new(None),
|
||||||
hold_answer: Mutex::new(None),
|
hold_answer: Mutex::new(None),
|
||||||
hold_trash: Mutex::new(None),
|
hold_trash: Mutex::new(None),
|
||||||
|
hold_download: Mutex::new(None),
|
||||||
|
hold_any_landing: AtomicBool::new(false),
|
||||||
hold_landing: Mutex::new(None),
|
hold_landing: Mutex::new(None),
|
||||||
drop_after_create: AtomicBool::new(false),
|
drop_after_create: AtomicBool::new(false),
|
||||||
lose_create_reply: AtomicBool::new(false),
|
lose_create_reply: AtomicBool::new(false),
|
||||||
@@ -616,8 +675,13 @@ struct ClientInner {
|
|||||||
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
hold_answer: Mutex<Option<Arc<HoldState>>>,
|
||||||
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
/// The trash [`FakeClient::hold_next_trash`] is waiting for.
|
||||||
hold_trash: Mutex<Option<Arc<HoldState>>>,
|
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.
|
/// The landing [`FakeClient::hold_next_landing`] is waiting for.
|
||||||
hold_landing: Mutex<Option<Arc<HoldState>>>,
|
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`].
|
/// Set by [`FakeClient::drop_link_after_next_create`].
|
||||||
drop_after_create: AtomicBool,
|
drop_after_create: AtomicBool,
|
||||||
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
/// Set by [`FakeClient::lose_reply_to_next_create`].
|
||||||
@@ -755,6 +819,18 @@ impl FakeClient {
|
|||||||
Held(state)
|
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
|
/// 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
|
/// is let go of, before the tree has the node (`DriveApi::landing`), until
|
||||||
/// the answer is dropped.
|
/// the answer is dropped.
|
||||||
@@ -768,6 +844,14 @@ impl FakeClient {
|
|||||||
Held(state)
|
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
|
/// Make the next file create on Drive, then hold its answer until the
|
||||||
/// answer is dropped, as a slow link does.
|
/// answer is dropped, as a slow link does.
|
||||||
pub(crate) fn hold_reply_to_next_create(&self) -> Held {
|
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"))
|
.ok_or_else(|| api(ResponseCode::DoesNotExist, "file not found"))
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
self.wait_if_held(&self.0.hold_download, std::slice::from_ref(uid))
|
||||||
|
.await;
|
||||||
output.write_all(&content).map_err(io_error)
|
output.write_all(&content).map_err(io_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,6 +1523,61 @@ impl DriveApi for FakeClient {
|
|||||||
.await
|
.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>(
|
fn move_nodes_streaming<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
items: Vec<NodeMoveItem>,
|
items: Vec<NodeMoveItem>,
|
||||||
@@ -1484,8 +1625,12 @@ impl DriveApi for FakeClient {
|
|||||||
fn landing(&self, uid: &NodeUid) {
|
fn landing(&self, uid: &NodeUid) {
|
||||||
let state = {
|
let state = {
|
||||||
let mut hold = self.0.hold_landing.lock();
|
let mut hold = self.0.hold_landing.lock();
|
||||||
|
let any = self.0.hold_any_landing.load(Ordering::SeqCst);
|
||||||
match hold.as_ref() {
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1506,6 +1651,42 @@ impl DriveApi for FakeClient {
|
|||||||
.await
|
.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>> {
|
async fn enumerate_trash_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||||
self.request(false, |server| {
|
self.request(false, |server| {
|
||||||
Ok(server
|
Ok(server
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The simulation tests of `docs/MILESTONE-3.0.0.md` §8: a fake Drive with
|
//! The simulation tests of `docs/TESTING.md`: a fake Drive with
|
||||||
//! the faults the real one has, and the tools to drive daemons against it.
|
//! the faults the real one has, and the tools to drive daemons against it.
|
||||||
|
|
||||||
pub(crate) mod daemon;
|
pub(crate) mod daemon;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! The reference file system of the simulation (`docs/MILESTONE-3.0.0.md`
|
//! The reference file system of the simulation (`docs/TESTING.md`,
|
||||||
//! §8.2, property 4): what each syscall a simulated client makes should
|
//! "Simulation runs", property 1): what each syscall a simulated client makes should
|
||||||
//! answer, and the tree it should leave, on a POSIX file system.
|
//! answer, and the tree it should leave, on a POSIX file system.
|
||||||
//!
|
//!
|
||||||
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
//! A [`Model`] covers one client's own folder; paths are relative to it. The
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The simulation runs of `docs/MILESTONE-3.0.0.md` §8.2.
|
//! The simulation runs described in `docs/TESTING.md`.
|
||||||
//!
|
//!
|
||||||
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
//! A run starts one or more daemons on a shared [`FakeDrive`], each owning a
|
||||||
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
//! folder `c<n>` that only it writes, so there is one writer per file. A seed
|
||||||
|
|||||||
+101
-11
@@ -183,7 +183,7 @@ impl WriteHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A released write whose upload has not happened yet (offline.md Phase 3).
|
/// A released write whose upload has not happened yet.
|
||||||
///
|
///
|
||||||
/// The bytes live in the content cache's staging dir and the intent lives in the
|
/// The bytes live in the content cache's staging dir and the intent lives in the
|
||||||
/// `pending_op` table; this pairs them in memory so a read can be served without
|
/// `pending_op` table; this pairs them in memory so a read can be served without
|
||||||
@@ -212,6 +212,31 @@ pub(crate) fn lid_ino(lid: i64) -> u64 {
|
|||||||
lid.unsigned_abs() + crate::ROOT_INO
|
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`]:
|
/// The local id of the node at inode `ino`, the inverse of [`lid_ino`]:
|
||||||
/// `None` for the root and a fallback inode.
|
/// `None` for the root and a fallback inode.
|
||||||
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
pub(crate) fn ino_lid(ino: u64) -> Option<i64> {
|
||||||
@@ -241,6 +266,11 @@ pub(crate) struct State {
|
|||||||
pub(crate) next_ino: u64,
|
pub(crate) next_ino: u64,
|
||||||
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
/// Shared write state keyed by inode. Concurrent writers share the scratch file.
|
||||||
pub(crate) active_writes: HashMap<u64, WriteHandle>,
|
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
|
/// Maps file handle id (fh) to inode (ino). Read-only opens use fh 0 and
|
||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
pub(crate) handles: HashMap<u64, u64>,
|
pub(crate) handles: HashMap<u64, u64>,
|
||||||
@@ -253,7 +283,7 @@ pub(crate) struct State {
|
|||||||
/// two pages shifted every later entry, and the caller silently skipped or
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
/// repeated whatever crossed the page boundary. Each entry is
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
/// module stays free of `fuser` (bugs.md B43).
|
/// module stays free of `fuser` (`docs/BUGS.md` B43).
|
||||||
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
@@ -403,6 +433,7 @@ impl State {
|
|||||||
roots: HashSet::new(),
|
roots: HashSet::new(),
|
||||||
next_ino: FALLBACK_INO,
|
next_ino: FALLBACK_INO,
|
||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
|
releasing: HashSet::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
dir_snapshots: HashMap::new(),
|
dir_snapshots: HashMap::new(),
|
||||||
@@ -1074,20 +1105,34 @@ impl State {
|
|||||||
///
|
///
|
||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// 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 {
|
for node in &mut nodes {
|
||||||
self.keep_revision_mtime(node);
|
self.keep_revision_mtime(node);
|
||||||
}
|
}
|
||||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
let fresh: Vec<&Node> = nodes
|
let fresh: Vec<&Node> = nodes
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let mut inos = if fresh.is_empty() {
|
if !fresh.is_empty() {
|
||||||
HashMap::new()
|
let allocated = self.new_inos(&fresh);
|
||||||
} else {
|
inos.extend(allocated);
|
||||||
self.new_inos(&fresh)
|
}
|
||||||
};
|
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| {
|
.map(|node| {
|
||||||
@@ -1098,6 +1143,13 @@ impl State {
|
|||||||
.collect()
|
.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.
|
/// Check if a directory inode has any child nodes in memory or in the database.
|
||||||
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
pub(crate) fn has_children(&self, parent: u64) -> bool {
|
||||||
// A resident listing is authoritative even when empty. Falling through
|
// A resident listing is authoritative even when empty. Falling through
|
||||||
@@ -1198,7 +1250,7 @@ impl State {
|
|||||||
/// re-enumerate, which is the cheaper way to stay honest about what the
|
/// re-enumerate, which is the cheaper way to stay honest about what the
|
||||||
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
/// server did. A queued rename cannot: re-enumerating needs the network, and
|
||||||
/// the server has not been told yet in any case — so this *is* the tree's
|
/// the server has not been told yet in any case — so this *is* the tree's
|
||||||
/// new truth until the op drains (offline.md Phase 3b).
|
/// new truth until the op drains.
|
||||||
pub(crate) fn rename_in_place(
|
pub(crate) fn rename_in_place(
|
||||||
&mut self,
|
&mut self,
|
||||||
ino: u64,
|
ino: u64,
|
||||||
@@ -1317,7 +1369,7 @@ impl State {
|
|||||||
/// created offline, nothing at all), so this row is the only record that the
|
/// created offline, nothing at all), so this row is the only record that the
|
||||||
/// file is as long as the caller was told it is. Without it a restart serves
|
/// file is as long as the caller was told it is. Without it a restart serves
|
||||||
/// the stale size and the file reads as truncated — or empty — while its
|
/// the stale size and the file reads as truncated — or empty — while its
|
||||||
/// bytes sit safely in staging (offline.md Phase 3).
|
/// bytes sit safely in staging.
|
||||||
/// Returns the node to persist rather than queueing it in the outbox, and is
|
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||||
/// the one mutation here whose write-through the caller must do itself:
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
/// failure has to be reported, not logged. Dropping the row silently means
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
@@ -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
|
/// 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
|
/// claiming a complete listing, so once the hot cache drops it, remote
|
||||||
/// changes under it stop being seen. `relocate` has to clear both sides.
|
/// 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.
|
/// would train everyone to ignore the real one.
|
||||||
const STALL_AFTER: Duration = Duration::from_secs(120);
|
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.
|
/// How often resident size goes into the log.
|
||||||
const RSS_EVERY: Duration = Duration::from_secs(300);
|
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 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
|
stalled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +194,7 @@ fn probe(control_socket: &Path) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
use pdfs_core::control::{Diagnostics, InflightRequest, SyncEngineState, WorkerState};
|
||||||
|
|
||||||
fn busy_worker(age_secs: u64) -> WorkerState {
|
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||||
WorkerState {
|
WorkerState {
|
||||||
@@ -226,4 +251,45 @@ mod tests {
|
|||||||
assert!(!report_stalls(&report, (0, 30)));
|
assert!(!report_stalls(&report, (0, 30)));
|
||||||
assert!(report_stalls(&report, (0, 40)));
|
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).
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||||
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
/// Strip a ` (sync-conflict <label> <stamp>)` (optionally `-<n>`) marker from
|
||||||
/// returning the original file name it was a copy of. `None` when `name` is not a
|
/// `name`, returning the original file name it was a copy of. The label names
|
||||||
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
/// the computer that made the copy (B251); copies made before it have the bare
|
||||||
/// formats, so the two halves of the product agree on what a conflict is.
|
/// 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> {
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||||
const MARKER: &str = " (sync-conflict ";
|
const MARKER: &str = " (sync-conflict ";
|
||||||
let start = name.find(MARKER)?;
|
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_rel = name[after..].find(')')?;
|
||||||
let close = after + close_rel;
|
let close = after + close_rel;
|
||||||
let inner = &name[after..close];
|
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
|
// 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.
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||||
let mut parts = inner.splitn(2, '-');
|
let mut parts = inner.splitn(2, '-');
|
||||||
@@ -68,7 +84,7 @@ impl Core {
|
|||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
// Both waits are interruptible, so teardown does not have to sit out a
|
// Both waits are interruptible, so teardown does not have to sit out a
|
||||||
// five-minute interval to join this thread (bugs.md B44).
|
// five-minute interval to join this thread (`docs/BUGS.md` B44).
|
||||||
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -486,6 +502,24 @@ mod tests {
|
|||||||
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||||
Some("notes.txt")
|
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.
|
// Not a conflict copy.
|
||||||
assert_eq!(conflict_base_name("report.xml"), None);
|
assert_eq!(conflict_base_name("report.xml"), None);
|
||||||
// Looks close but the token is not a stamp.
|
// Looks close but the token is not a stamp.
|
||||||
|
|||||||
+1397
-256
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,11 @@
|
|||||||
//! Filesystem watcher, polling, and debounce control plane.
|
//! 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::*;
|
use super::*;
|
||||||
|
|
||||||
/// How long the watcher must go quiet before a burst counts as finished.
|
/// 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
|
/// 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
|
/// unusable done one-at-a-time; this bounds concurrency like the bulk-upload
|
||||||
/// engine does.
|
/// 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.
|
/// A message to the sync engine's control thread.
|
||||||
pub(crate) enum SyncMsg {
|
pub(crate) enum SyncMsg {
|
||||||
@@ -33,7 +42,7 @@ pub(crate) enum SyncMsg {
|
|||||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
/// The daemon is tearing down: finish the current pass and end the engine
|
/// The daemon is tearing down: finish the current pass and end the engine
|
||||||
/// thread, so the mount can join it (bugs.md B44).
|
/// thread, so the mount can join it (`docs/BUGS.md` B44).
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
/// The engine control loop. Single-threaded so reconcile passes never overlap.
|
||||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
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
|
// Paths the watcher currently covers, mapped to their folder id, so an event
|
||||||
// path can be resolved back to the folder it belongs to.
|
// 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 tx_events = core.sync_tx.clone();
|
||||||
let mut watcher =
|
let watched_events = watched.clone();
|
||||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
let handler = move |res: notify::Result<notify::Event>| {
|
||||||
let Ok(event) = res else { return };
|
let event = match res {
|
||||||
// Ignore pure metadata/access noise; content changes are what matter.
|
Ok(event) => event,
|
||||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
// Running out of inotify watches for a new subfolder, say: that
|
||||||
return;
|
// subfolder's changes are then found by the poll only (B262).
|
||||||
}
|
|
||||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
|
||||||
}) {
|
|
||||||
Ok(w) => w,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = %e, "filesystem watcher unavailable; sync is poll-only");
|
warn!(error = %e, "sync: filesystem watcher error");
|
||||||
// Still run the loop so polling and explicit SyncNow work.
|
|
||||||
poll_only_loop(&core, rx);
|
|
||||||
return;
|
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.
|
// Periodic remote poll.
|
||||||
{
|
{
|
||||||
@@ -94,8 +232,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
rewatch(&core, &mut watcher, &watched);
|
let mut unwatched = rewatch(&core, &mut watcher, &watched);
|
||||||
reconcile_all(&core);
|
let mut last_rewatch = Instant::now();
|
||||||
|
run_burst(&core, &HashSet::new(), true);
|
||||||
|
|
||||||
while let Ok(msg) = rx.recv() {
|
while let Ok(msg) = rx.recv() {
|
||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
@@ -108,21 +247,16 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
return;
|
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 {
|
if do_rewatch {
|
||||||
rewatch(&core, &mut watcher, &watched);
|
unwatched = rewatch(&core, &mut watcher, &watched);
|
||||||
}
|
last_rewatch = Instant::now();
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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() {
|
while let Ok(msg) = rx.recv() {
|
||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
@@ -150,21 +284,59 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
info!("sync engine stopping");
|
info!("sync engine stopping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if all {
|
run_burst(core, &ids, all);
|
||||||
reconcile_all(core);
|
}
|
||||||
} else {
|
}
|
||||||
for id in ids {
|
|
||||||
if core.sync_paused() {
|
/// Run the passes one burst asked for: the folders named by id, then, when the
|
||||||
break;
|
/// burst also holds a `ReconcileAll`, every other folder.
|
||||||
}
|
///
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
/// The named folders go first. A poll's `ReconcileAll` in the same burst used
|
||||||
core.reconcile_folder(&folder);
|
/// 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
|
/// 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
|
/// for `quiet` or `cap` has elapsed since the first event — whichever comes
|
||||||
/// first. The caller has already classified the event that opened the burst.
|
/// first. The caller has already classified the event that opened the burst.
|
||||||
@@ -236,33 +408,37 @@ pub(super) fn classify(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||||
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||||
/// watcher reported meanwhile.
|
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||||
fn reconcile_all(core: &Core) {
|
/// out of date), falls back to every folder.
|
||||||
if core.sync_paused() {
|
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||||
debug!("sync: paused, skipping reconcile");
|
let mut ids = paths.iter().map(|path| {
|
||||||
return;
|
watched
|
||||||
}
|
.iter()
|
||||||
let folders = match core.db.sync_folder_list() {
|
.filter(|(root, _)| path.starts_with(root))
|
||||||
Ok(f) => f,
|
// The deepest root, should one mirror folder sit inside another.
|
||||||
Err(e) => {
|
.max_by_key(|(root, _)| root.components().count())
|
||||||
warn!(error = ?e, "sync: cannot list folders");
|
.map(|(_, id)| *id)
|
||||||
return;
|
});
|
||||||
}
|
match ids.next() {
|
||||||
};
|
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||||
for folder in folders {
|
_ => SyncMsg::ReconcileAll,
|
||||||
core.reconcile_folder(&folder);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
/// 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(
|
fn rewatch(
|
||||||
core: &Core,
|
core: &Core,
|
||||||
watcher: &mut notify::RecommendedWatcher,
|
watcher: &mut notify::RecommendedWatcher,
|
||||||
watched: &Mutex<Vec<(PathBuf, i64)>>,
|
watched: &Mutex<Vec<(PathBuf, i64)>>,
|
||||||
) {
|
) -> bool {
|
||||||
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
|
let want: Vec<(PathBuf, i64)> = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f
|
Ok(f) => f
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -271,29 +447,60 @@ fn rewatch(
|
|||||||
.collect(),
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(error = ?e, "sync: cannot list folders to watch");
|
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.
|
// Drop watches no longer wanted.
|
||||||
have.retain(|(path, _)| {
|
for path in keep_wanted(&mut have, &want) {
|
||||||
if want.iter().any(|(p, _)| p == path) {
|
let _ = watcher.unwatch(&path);
|
||||||
true
|
}
|
||||||
} else {
|
|
||||||
let _ = watcher.unwatch(path);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Add newly wanted watches.
|
// Add newly wanted watches.
|
||||||
|
let mut failed = false;
|
||||||
for (path, id) in &want {
|
for (path, id) in &want {
|
||||||
if have.iter().any(|(p, _)| p == path) {
|
if have.iter().any(|(p, _)| p == path) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match watcher.watch(path, RecursiveMode::Recursive) {
|
match watcher.watch(path, RecursiveMode::Recursive) {
|
||||||
Ok(()) => have.push((path.clone(), *id)),
|
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 ------------------------------------------------------------
|
// ---- reconcile ------------------------------------------------------------
|
||||||
@@ -20,7 +20,7 @@ impl LocalSig {
|
|||||||
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
/// falls back to whole seconds, which is what a baseline row written before
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
/// (bugs.md B25).
|
/// (`docs/BUGS.md` B25).
|
||||||
pub(super) fn same_content(&self, other: &Self) -> bool {
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
if self.size != other.size {
|
if self.size != other.size {
|
||||||
return false;
|
return false;
|
||||||
@@ -83,6 +83,46 @@ pub(super) struct RemoteItem {
|
|||||||
pub(super) is_dir: bool,
|
pub(super) is_dir: bool,
|
||||||
pub(super) mtime: i64,
|
pub(super) mtime: i64,
|
||||||
pub(super) size: 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
|
/// 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,
|
pub(super) errors: usize,
|
||||||
/// Files skipped because another process held them open for writing.
|
/// Files skipped because another process held them open for writing.
|
||||||
pub(super) deferred: usize,
|
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 {
|
impl Outcome {
|
||||||
@@ -108,7 +153,7 @@ impl Outcome {
|
|||||||
Applied::Uploaded => self.uploaded += 1,
|
Applied::Uploaded => self.uploaded += 1,
|
||||||
Applied::Downloaded => self.downloaded += 1,
|
Applied::Downloaded => self.downloaded += 1,
|
||||||
Applied::Conflict => self.conflicts += 1,
|
Applied::Conflict => self.conflicts += 1,
|
||||||
Applied::Vanished => {}
|
Applied::Vanished | Applied::Adopted => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,29 +197,45 @@ pub(super) enum Pending {
|
|||||||
CreateDir { rel: String, parent: NodeUid },
|
CreateDir { rel: String, parent: NodeUid },
|
||||||
/// Upload a brand-new local file into `parent`.
|
/// Upload a brand-new local file into `parent`.
|
||||||
UploadNew { rel: String, parent: NodeUid },
|
UploadNew { rel: String, parent: NodeUid },
|
||||||
/// Upload a changed local file as a new revision of `uid`.
|
/// Upload a changed local file as a new revision of `uid`, if Drive still
|
||||||
UploadRevision { rel: String, uid: NodeUid },
|
/// holds the revision `seen` describes (B249).
|
||||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
UploadRevision {
|
||||||
/// remote's reported size, used as the transfer's expected total.
|
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
|
/// `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
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
/// against it immediately before it renames over the destination: a writer
|
/// against it immediately before it renames over the destination: a writer
|
||||||
/// that finished an edit in the meantime must not be overwritten without a
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
/// conflict copy (bugs.md B39).
|
/// conflict copy (`docs/BUGS.md` B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
seen: RemoteSeen,
|
||||||
size: i64,
|
|
||||||
local: Option<LocalSig>,
|
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`.
|
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||||
Conflict {
|
Conflict {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
seen: RemoteSeen,
|
||||||
size: i64,
|
|
||||||
},
|
},
|
||||||
/// Both sides changed during a [`push pass`](Core::push_pass): upload the local
|
/// 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.
|
/// copy into `parent` under a conflict name, leaving the remote file as it is.
|
||||||
@@ -189,6 +250,7 @@ impl Pending {
|
|||||||
| Pending::UploadNew { rel, .. }
|
| Pending::UploadNew { rel, .. }
|
||||||
| Pending::UploadRevision { rel, .. }
|
| Pending::UploadRevision { rel, .. }
|
||||||
| Pending::Download { rel, .. }
|
| Pending::Download { rel, .. }
|
||||||
|
| Pending::Adopt { rel, .. }
|
||||||
| Pending::Conflict { rel, .. }
|
| Pending::Conflict { rel, .. }
|
||||||
| Pending::PushConflict { rel, .. } => rel,
|
| Pending::PushConflict { rel, .. } => rel,
|
||||||
}
|
}
|
||||||
@@ -201,7 +263,9 @@ impl Pending {
|
|||||||
Pending::UploadNew { .. }
|
Pending::UploadNew { .. }
|
||||||
| Pending::UploadRevision { .. }
|
| Pending::UploadRevision { .. }
|
||||||
| Pending::PushConflict { .. } => ActivityKind::Upload,
|
| 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::UploadNew { .. } => "new file",
|
||||||
Pending::UploadRevision { .. } => "new version",
|
Pending::UploadRevision { .. } => "new version",
|
||||||
Pending::Download { .. } => "from Drive",
|
Pending::Download { .. } => "from Drive",
|
||||||
|
Pending::Adopt { .. } => "already the same on Drive",
|
||||||
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
Pending::Conflict { .. } => "local changes kept as a conflict copy",
|
||||||
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
Pending::PushConflict { .. } => "local changes uploaded as a conflict copy",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,33 +90,310 @@ pub(super) fn filter_baseline<'a, B>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// 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.
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||||
/// mount or unreadable folder rather than a deliberate whole-tree deletion.
|
/// covers from classification, exactly as it drops ignored paths, so a
|
||||||
/// One surviving path is enough to clear the guard.
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many tracked paths a folder needs before a mass disappearance, short of
|
||||||
|
/// a total one, trips a wipe guard. Below this, a few survivors say little.
|
||||||
|
const MASS_LOSS_MIN_PATHS: usize = 20;
|
||||||
|
|
||||||
|
/// The share of a large folder's tracked paths that may vanish in one pass, in
|
||||||
|
/// tenths, before a wipe guard stops it: 9 of 10 and up is refused.
|
||||||
|
const MASS_LOSS_TENTHS: usize = 9;
|
||||||
|
|
||||||
|
/// Whether `missing` of `total` tracked paths gone at once looks like a lost
|
||||||
|
/// tree rather than deletions. All of them always does. Most of a large
|
||||||
|
/// folder does too: one surviving file used to clear the guard, so a
|
||||||
|
/// half-mounted disk, or a script that emptied the folder and wrote one file
|
||||||
|
/// back, trashed everything else on Drive (B270).
|
||||||
|
fn looks_like_a_wipe(missing: usize, total: usize) -> bool {
|
||||||
|
total > 0
|
||||||
|
&& (missing == total
|
||||||
|
|| (total >= MASS_LOSS_MIN_PATHS && missing * 10 >= total * MASS_LOSS_TENTHS))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to run a pass whose local side has vanished in its entirety, or
|
||||||
|
/// nearly so ([`looks_like_a_wipe`]).
|
||||||
|
///
|
||||||
|
/// When the synced paths are all or nearly all absent locally, the likely
|
||||||
|
/// cause is an unavailable mount or unreadable folder rather than a deliberate
|
||||||
|
/// deletion of the whole tree.
|
||||||
pub(super) fn guard_local_wipe<B, L>(
|
pub(super) fn guard_local_wipe<B, L>(
|
||||||
baseline: &HashMap<String, B>,
|
baseline: &HashMap<String, B>,
|
||||||
local: &HashMap<String, L>,
|
local: &HashMap<String, L>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if !baseline.is_empty() && baseline.keys().all(|rel| !local.contains_key(rel)) {
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"every one of the {} synced paths is missing locally; refusing to trash \
|
"{missing} of the {} synced paths are 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()
|
baseline.len()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refuse to run a pass whose remote side has vanished in its entirety, or
|
||||||
|
/// nearly so: 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 all or
|
||||||
|
/// nearly all synced paths are gone from Drive at once, a failed listing is far
|
||||||
|
/// likelier than a deliberate wipe of the folder, so the pass stops instead.
|
||||||
|
pub(super) fn guard_remote_wipe<B, R>(
|
||||||
|
baseline: &HashMap<String, B>,
|
||||||
|
remote: &HashMap<String, R>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let missing = baseline
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !remote.contains_key(*rel))
|
||||||
|
.count();
|
||||||
|
if looks_like_a_wipe(missing, baseline.len()) {
|
||||||
|
return Err(format!(
|
||||||
|
"{missing} of the {} synced paths are 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
|
/// 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(
|
pub(super) fn unchanged_remote_size(
|
||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
|
revision: Option<&str>,
|
||||||
) -> Option<i64> {
|
) -> 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),
|
Some((recorded, size)) if recorded == mtime => Some(size),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -131,6 +408,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.
|
/// The stored remote signature of a baseline row, if it has one.
|
||||||
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
|
pub(super) fn remote_sig(e: &StoredSyncEntry) -> Option<(i64, i64)> {
|
||||||
match (&e.remote_rev, &e.remote_hash) {
|
match (&e.remote_rev, &e.remote_hash) {
|
||||||
@@ -161,7 +449,8 @@ pub(super) fn rel_to_path(rel: &str) -> PathBuf {
|
|||||||
rel.split('/').collect()
|
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)]
|
#[cfg(test)]
|
||||||
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
||||||
conflict_path_with_suffix(path, stamp, 0)
|
conflict_path_with_suffix(path, stamp, 0)
|
||||||
@@ -170,16 +459,17 @@ pub(super) fn conflict_path(path: &Path, stamp: i64) -> PathBuf {
|
|||||||
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
/// As [`conflict_path`], with a deterministic suffix used when that name already
|
||||||
/// exists. This keeps conflict preservation from ever replacing an older copy.
|
/// 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 {
|
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 stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("file");
|
||||||
let ext = path.extension().and_then(|s| s.to_str());
|
let ext = path.extension().and_then(|s| s.to_str());
|
||||||
let suffix = if suffix == 0 {
|
let tag = crate::conflict_tag(label, stamp, suffix);
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("-{suffix}")
|
|
||||||
};
|
|
||||||
let name = match ext {
|
let name = match ext {
|
||||||
Some(ext) => format!("{stem} (sync-conflict {stamp}{suffix}).{ext}"),
|
Some(ext) => format!("{stem} (sync-conflict {tag}).{ext}"),
|
||||||
None => format!("{stem} (sync-conflict {stamp}{suffix})"),
|
None => format!("{stem} (sync-conflict {tag})"),
|
||||||
};
|
};
|
||||||
match path.parent() {
|
match path.parent() {
|
||||||
Some(dir) => dir.join(name),
|
Some(dir) => dir.join(name),
|
||||||
@@ -200,11 +490,14 @@ pub(super) enum FilePlan {
|
|||||||
DeleteLocal,
|
DeleteLocal,
|
||||||
DeleteRemote,
|
DeleteRemote,
|
||||||
ForgetBaseline,
|
ForgetBaseline,
|
||||||
/// First sync of a file that already exists on both sides with identical
|
/// First sync of a file that exists on both sides at the same size, with a
|
||||||
/// content: record the baseline without transferring anything. Without this,
|
/// 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
|
/// 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
|
/// 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,
|
AdoptBaseline,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,13 +511,18 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
let local_changed = local.is_some_and(|item| {
|
||||||
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
// Nanoseconds where both sides have them (`docs/BUGS.md` B25): whole seconds
|
||||||
// could not see an edit made in the same second as the last sync that
|
// could not see an edit made in the same second as the last sync that
|
||||||
// left the file the same length, so it was never uploaded.
|
// left the file the same length, so it was never uploaded.
|
||||||
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
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| {
|
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) {
|
match (local, remote) {
|
||||||
@@ -234,10 +532,13 @@ pub(super) fn plan_file(
|
|||||||
(false, true) => FilePlan::Download,
|
(false, true) => FilePlan::Download,
|
||||||
// With a baseline, both-changed is a genuine concurrent edit and must
|
// With a baseline, both-changed is a genuine concurrent edit and must
|
||||||
// be preserved as a conflict. Without one, "both changed" only means
|
// be preserved as a conflict. Without one, "both changed" only means
|
||||||
// this is the first sync and there was nothing to compare to: adopt
|
// this is the first sync and there was nothing to compare to: when the
|
||||||
// the baseline when the sizes already match rather than conflict-copy
|
// sizes match and Drive has a digest to compare against, the apply
|
||||||
// an already-identical file. Divergent sizes stay a real conflict.
|
// step hashes the local file before adopting it. Divergent sizes, or
|
||||||
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
|
// 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,
|
(true, true) => FilePlan::Conflict,
|
||||||
},
|
},
|
||||||
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||||
@@ -269,6 +570,8 @@ mod file_plan_tests {
|
|||||||
is_dir: false,
|
is_dir: false,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
|
revision: None,
|
||||||
|
sha1: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,12 +589,13 @@ mod file_plan_tests {
|
|||||||
local_size,
|
local_size,
|
||||||
remote_rev: Some(remote_mtime.to_string()),
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
remote_hash: Some(remote_size.to_string()),
|
remote_hash: Some(remote_size.to_string()),
|
||||||
|
remote_revision_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||||
/// file the same length, so a mirror silently stopped uploading it. With a
|
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||||
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
/// nanosecond time on both sides it is an ordinary local change (`docs/BUGS.md` B25).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
let mut base = baseline(10, 20, 30, 40);
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
@@ -407,12 +711,28 @@ mod file_plan_tests {
|
|||||||
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
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
|
// 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
|
// 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 local = local(10, 40);
|
||||||
let remote = remote(30, 40);
|
let remote = remote(30, 40);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan_file(Some(&local), Some(&remote), None),
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
FilePlan::AdoptBaseline
|
FilePlan::Conflict
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +748,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]
|
#[test]
|
||||||
fn a_missing_remote_signature_is_treated_as_changed() {
|
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||||
let local = local(10, 20);
|
let local = local(10, 20);
|
||||||
@@ -440,4 +827,153 @@ mod file_plan_tests {
|
|||||||
FilePlan::Download
|
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_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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One path left of a large folder no longer clears either guard: most
|
||||||
|
/// of it gone at once is a lost tree, not a cleanup (B270).
|
||||||
|
#[test]
|
||||||
|
fn one_survivor_of_a_large_folder_does_not_clear_the_wipe_guards() {
|
||||||
|
let base: HashMap<String, ()> = (0..40).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let survivors = |n: usize| -> HashMap<String, ()> {
|
||||||
|
(0..n).map(|n| (format!("f{n}.txt"), ())).collect()
|
||||||
|
};
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(1)).is_err());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(1)).is_err());
|
||||||
|
// 36 of 40 gone is nine tenths: still refused.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(4)).is_err());
|
||||||
|
// 35 of 40 gone is under nine tenths: a pass may delete that much.
|
||||||
|
assert!(guard_local_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &survivors(5)).is_ok());
|
||||||
|
let reason = guard_local_wipe(&base, &survivors(1)).unwrap_err();
|
||||||
|
assert!(reason.starts_with("39 of the 40 synced paths"), "{reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small folder still loses most of its files to an ordinary cleanup, so
|
||||||
|
/// only a total loss stops it.
|
||||||
|
#[test]
|
||||||
|
fn a_small_folder_trips_the_wipe_guards_only_when_everything_is_gone() {
|
||||||
|
let base: HashMap<String, ()> = (0..19).map(|n| (format!("f{n}.txt"), ())).collect();
|
||||||
|
let one: HashMap<String, ()> = [("f0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&base, &one).is_ok());
|
||||||
|
assert!(guard_remote_wipe(&base, &one).is_ok());
|
||||||
|
let none: HashMap<String, ()> = HashMap::new();
|
||||||
|
assert!(guard_local_wipe(&base, &none).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,16 +64,44 @@ fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
|||||||
/// so `systemctl --user start` returns when the mount actually works rather than
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
/// when the process exists.
|
/// when the process exists.
|
||||||
pub(crate) fn ready() {
|
pub(crate) fn ready() {
|
||||||
if notify("READY=1\n") {
|
if notify("READY=1\nSTATUS=Mounted\n") {
|
||||||
debug!("notified systemd: ready");
|
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.
|
/// Tell the manager the daemon is still healthy.
|
||||||
pub(crate) fn watchdog_ping() {
|
pub(crate) fn watchdog_ping() {
|
||||||
let _ = notify("WATCHDOG=1\n");
|
let _ = notify("WATCHDOG=1\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sleep for `duration`, pinging the watchdog on the way.
|
||||||
|
///
|
||||||
|
/// For the stretches where the daemon is alive but has no mount, and so no
|
||||||
|
/// supervisor: waiting for a login, or backing off after a failed mount. The
|
||||||
|
/// manager arms the watchdog when the process starts, so without these pings a
|
||||||
|
/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a
|
||||||
|
/// coredump instead of the error it was logging. Never use this while mounted:
|
||||||
|
/// there the ping must prove the control socket answers.
|
||||||
|
pub fn idle_sleep(duration: Duration) {
|
||||||
|
const CHUNK: Duration = Duration::from_secs(10);
|
||||||
|
let mut left = duration;
|
||||||
|
watchdog_ping();
|
||||||
|
while !left.is_zero() {
|
||||||
|
let step = left.min(CHUNK);
|
||||||
|
std::thread::sleep(step);
|
||||||
|
left -= step;
|
||||||
|
watchdog_ping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
/// for a hang by the watchdog it is racing.
|
/// for a hang by the watchdog it is racing.
|
||||||
pub(crate) fn stopping() {
|
pub(crate) fn stopping() {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use std::collections::{BTreeMap, HashMap};
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
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::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
@@ -36,6 +36,7 @@ use sha1::{Digest, Sha1};
|
|||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use super::Core;
|
use super::Core;
|
||||||
|
use crate::photos::can_make_thumbnail;
|
||||||
use crate::transfers::CountingReader;
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
/// The one import this daemon may be running, and how the last one ended.
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
@@ -136,6 +137,30 @@ fn read_entry<R>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Core {
|
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.
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
///
|
///
|
||||||
/// `cancel` stops the run at the next photo boundary — an upload already on
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
@@ -321,18 +346,65 @@ impl Core {
|
|||||||
capture_time: photo.capture_time,
|
capture_time: photo.capture_time,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
// A thumbnail is made from a file, so a photo that can have one
|
||||||
let counting = CountingReader::new(reader, &guard);
|
// is copied out of the archive first and uploaded from the copy.
|
||||||
self.rt.block_on(photos.upload_photo_from(
|
// Without one, Proton's apps show a blank tile until they make
|
||||||
&photo.name,
|
// their own.
|
||||||
&photo.media_type,
|
let staged = can_make_thumbnail(&photo.name, photo.size)
|
||||||
counting,
|
.then(|| self.stage_takeout_entry(&mut archive, &photo.entry))
|
||||||
photo.size as i64,
|
.and_then(|staged| {
|
||||||
Vec::new(),
|
staged
|
||||||
metadata,
|
.inspect_err(|e| {
|
||||||
false,
|
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);
|
drop(guard);
|
||||||
job.step();
|
job.step();
|
||||||
|
|
||||||
@@ -480,7 +552,15 @@ impl Core {
|
|||||||
let mut state = IMPORT.lock();
|
let mut state = IMPORT.lock();
|
||||||
state.running = false;
|
state.running = false;
|
||||||
state.cancel = None;
|
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 {
|
match outcome {
|
||||||
Ok(summary) => {
|
Ok(summary) => {
|
||||||
@@ -545,7 +625,7 @@ pub(crate) fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
|||||||
Ok(hex_lower(&hasher.finalize()))
|
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);
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
for byte in bytes {
|
for byte in bytes {
|
||||||
out.push_str(&format!("{byte:02x}"));
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ struct JobEntry {
|
|||||||
detail: Mutex<String>,
|
detail: Mutex<String>,
|
||||||
done: AtomicU64,
|
done: AtomicU64,
|
||||||
total: 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
|
/// The set of transfers and jobs currently in flight. Cloned `Arc`-style across
|
||||||
@@ -123,6 +125,11 @@ impl TransferRegistry {
|
|||||||
self.download_limit.set(download);
|
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 {
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
match direction {
|
match direction {
|
||||||
TransferDirection::Upload => &self.upload_limit,
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
@@ -160,12 +167,25 @@ impl TransferRegistry {
|
|||||||
/// that carries its progress and deregisters the job when dropped. The job
|
/// that carries its progress and deregisters the job when dropped. The job
|
||||||
/// starts indeterminate; give it a `total` once one is known.
|
/// starts indeterminate; give it a `total` once one is known.
|
||||||
pub fn begin_job(self: &Arc<Self>, title: impl Into<String>) -> JobGuard {
|
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 id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||||
let entry = Arc::new(JobEntry {
|
let entry = Arc::new(JobEntry {
|
||||||
title: title.into(),
|
title,
|
||||||
detail: Mutex::new(String::new()),
|
detail: Mutex::new(String::new()),
|
||||||
done: AtomicU64::new(0),
|
done: AtomicU64::new(0),
|
||||||
total: AtomicU64::new(0),
|
total: AtomicU64::new(0),
|
||||||
|
background,
|
||||||
});
|
});
|
||||||
self.jobs.lock().insert(id, entry.clone());
|
self.jobs.lock().insert(id, entry.clone());
|
||||||
JobGuard {
|
JobGuard {
|
||||||
@@ -191,6 +211,7 @@ impl TransferRegistry {
|
|||||||
detail: e.detail.lock().clone(),
|
detail: e.detail.lock().clone(),
|
||||||
done: e.done.load(Ordering::Relaxed),
|
done: e.done.load(Ordering::Relaxed),
|
||||||
total: e.total.load(Ordering::Relaxed),
|
total: e.total.load(Ordering::Relaxed),
|
||||||
|
background: e.background,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ pangocairo = "0.22"
|
|||||||
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
# The system libintl (glibc) rather than a bundled gettext build.
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
gettext-rs = { version = "0.8", features = ["gettext-system"] }
|
||||||
@@ -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-details-pane-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-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-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-people-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-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>
|
<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.warning { color: var(--warning-color); }
|
||||||
.sidebar-status image.error { color: var(--error-color); }
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
.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 */
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
.sidebar-badge {
|
.sidebar-badge {
|
||||||
min-width: 10px;
|
min-width: 10px;
|
||||||
@@ -48,6 +50,9 @@
|
|||||||
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
.badge-issue { color: var(--error-color); }
|
.badge-issue { color: var(--error-color); }
|
||||||
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
.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; }
|
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); }
|
.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; }
|
.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.
|
//! `dead_code` allowances below are for — not code nobody uses.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use pdfs_core::control::{PhotoKind, SearchHit};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum DriveActivation {
|
pub(crate) enum DriveActivation {
|
||||||
Folder,
|
Folder,
|
||||||
@@ -55,18 +60,32 @@ pub(crate) fn mounted_target(mountpoint: &Path, hit: &SearchHit) -> Option<PathB
|
|||||||
present(mounted_or_relative(mountpoint, hit))
|
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> {
|
fn present(path: PathBuf) -> Option<PathBuf> {
|
||||||
path.symlink_metadata().is_ok().then_some(path)
|
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
|
/// 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
|
/// that have no fallback (a folder cannot be materialised) or already know the
|
||||||
/// entry came from a mount listing.
|
/// entry came from a mount listing.
|
||||||
|
|||||||
+270
-55
@@ -10,6 +10,7 @@ pub(crate) mod pages;
|
|||||||
pub(crate) mod reload;
|
pub(crate) mod reload;
|
||||||
#[path = "../theme.rs"]
|
#[path = "../theme.rs"]
|
||||||
pub(crate) mod theme;
|
pub(crate) mod theme;
|
||||||
|
pub(crate) mod update;
|
||||||
pub(crate) mod widgets;
|
pub(crate) mod widgets;
|
||||||
|
|
||||||
use compat::*;
|
use compat::*;
|
||||||
@@ -33,7 +34,9 @@ use pages::trash::*;
|
|||||||
use pages::verify::*;
|
use pages::verify::*;
|
||||||
use reload::{LoadTicket, Loader, replace_items};
|
use reload::{LoadTicket, Loader, replace_items};
|
||||||
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
use theme::{load_resources, proton_theme_active, set_proton_theme};
|
||||||
|
use update::*;
|
||||||
use widgets::compat::*;
|
use widgets::compat::*;
|
||||||
|
use widgets::crumb::*;
|
||||||
use widgets::details::*;
|
use widgets::details::*;
|
||||||
use widgets::file_list::*;
|
use widgets::file_list::*;
|
||||||
use widgets::menu::*;
|
use widgets::menu::*;
|
||||||
@@ -68,15 +71,15 @@ use gtk4::glib::BoxedAnyObject;
|
|||||||
|
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
|
|
||||||
use pdfs_core::config::{AppDirs, FileSort, FilesView};
|
use pdfs_core::config::{AppDirs, FileSort, FilesView, WindowState};
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo,
|
||||||
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, MappingProgress, PendingOpInfo,
|
DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, LinkChange, MappingProgress,
|
||||||
PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope, Request, Response,
|
PendingOpInfo, PhotoItem, PhotoKind, PhotoMonth, PlaceInfo, PublicLinkInfo, RefreshScope,
|
||||||
RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
Request, Response, RestorableFolder, RestoreItem, RevisionInfo, SearchHit, ShareEntry,
|
||||||
SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress, ThumbnailBuildStatus, Topic,
|
ShareEntryKind, SharedItem, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
|
||||||
TransferDirection, TransferItem, send,
|
ThumbnailBuildStatus, Topic, TransferDirection, TransferItem, send,
|
||||||
};
|
};
|
||||||
|
|
||||||
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
@@ -119,6 +122,12 @@ struct Ui {
|
|||||||
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
/// keyring (a DBus round-trip). Populated at startup and updated only on
|
||||||
/// login / logout. `None` = signed out.
|
/// login / logout. `None` = signed out.
|
||||||
session: RefCell<Option<auth::StoredSession>>,
|
session: RefCell<Option<auth::StoredSession>>,
|
||||||
|
/// Whether this run already asked for the mailbox password of a locked
|
||||||
|
/// account, so a refresh never re-opens a dialog the user cancelled.
|
||||||
|
unlock_offered: Cell<bool>,
|
||||||
|
/// The `since` of the run of mount failures whose "folder isn't empty"
|
||||||
|
/// dialog this run already showed, so a page reload never shows it again.
|
||||||
|
not_empty_offered: Cell<i64>,
|
||||||
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
/// Whether the last refresh saw a live mount daemon. Gates the unpin buttons
|
||||||
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
/// (which need the daemon to evict + re-hydrate) and every mutating action.
|
||||||
mounted: RefCell<bool>,
|
mounted: RefCell<bool>,
|
||||||
@@ -126,8 +135,12 @@ struct Ui {
|
|||||||
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
/// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere.
|
||||||
sidebar: gtk4::ListBox,
|
sidebar: gtk4::ListBox,
|
||||||
/// The sidebar/content split. Collapsed while signed out, so the login page
|
/// 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,
|
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.
|
/// Shared thumbnails for ordinary image files outside the Photos gallery.
|
||||||
/// One cache and request queue serves Files, search, Shared and Trash, so
|
/// One cache and request queue serves Files, search, Shared and Trash, so
|
||||||
/// the same image is downloaded and decoded only once.
|
/// the same image is downloaded and decoded only once.
|
||||||
@@ -147,6 +160,7 @@ struct Ui {
|
|||||||
pub(crate) locations: LocationsState,
|
pub(crate) locations: LocationsState,
|
||||||
pub(crate) activity: ActivityState,
|
pub(crate) activity: ActivityState,
|
||||||
pub(crate) takeout: TakeoutState,
|
pub(crate) takeout: TakeoutState,
|
||||||
|
pub(crate) update: UpdateState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ui {
|
impl Ui {
|
||||||
@@ -199,12 +213,20 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> glib::ExitCode {
|
fn main() -> glib::ExitCode {
|
||||||
tracing_subscriber::fmt()
|
let verify = std::env::args().nth(1).as_deref() == Some(VERIFY_ARG);
|
||||||
.with_env_filter(
|
let logs = tracing_subscriber::fmt().with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||||
)
|
);
|
||||||
.init();
|
// The verification window's stdout carries its token back to the app.
|
||||||
|
if verify {
|
||||||
|
logs.with_writer(std::io::stderr).init();
|
||||||
|
} else {
|
||||||
|
logs.init();
|
||||||
|
}
|
||||||
i18n::init();
|
i18n::init();
|
||||||
|
if verify {
|
||||||
|
return run_verification_window();
|
||||||
|
}
|
||||||
|
|
||||||
// The tray launches the app with arguments ("--page locations",
|
// The tray launches the app with arguments ("--page locations",
|
||||||
// "--confirm-stop"), which a running instance must receive too.
|
// "--confirm-stop"), which a running instance must receive too.
|
||||||
@@ -285,7 +307,10 @@ fn present_window(app: &adw::Application) {
|
|||||||
/// Spawn the tray icon process in the background.
|
/// Spawn the tray icon process in the background.
|
||||||
pub(crate) fn spawn_tray() {
|
pub(crate) fn spawn_tray() {
|
||||||
match Command::new("pdfs-tray").spawn() {
|
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}"),
|
Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,8 +325,15 @@ fn build_window(app: &adw::Application) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// On a fresh install the daemon has not run yet to make these, and the
|
||||||
|
// Takeout page keeps its staged archives in the state dir (B194).
|
||||||
|
if let Err(e) = dirs.ensure() {
|
||||||
|
tracing::warn!("cannot create the app's directories: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
let stack = adw::ViewStack::new();
|
// 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 (login_page, login_widgets) = build_login_page();
|
||||||
let main_widgets = build_main_page();
|
let main_widgets = build_main_page();
|
||||||
let (browser_page, browser_widgets) = build_browser_page();
|
let (browser_page, browser_widgets) = build_browser_page();
|
||||||
@@ -319,8 +351,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
login_frame.add_top_bar(&{
|
login_frame.add_top_bar(&{
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
header.set_show_title(false);
|
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
|
header
|
||||||
});
|
});
|
||||||
|
login_frame.add_top_bar(&login_widgets.keyring_banner);
|
||||||
login_frame.set_content(Some(&login_page));
|
login_frame.set_content(Some(&login_page));
|
||||||
stack.add_named(&login_frame, Some("login"));
|
stack.add_named(&login_frame, Some("login"));
|
||||||
stack.add_named(&browser_page, Some("browser"));
|
stack.add_named(&browser_page, Some("browser"));
|
||||||
@@ -335,7 +371,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
// Sidebar: the signed-in destinations. Selecting a row swaps the page stack;
|
||||||
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
// `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g.
|
||||||
// login lands on Files).
|
// 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
|
// Every page brings its own header bar (see `page_frame`), so the content
|
||||||
// side is the bare stack.
|
// side is the bare stack.
|
||||||
@@ -348,6 +385,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
.content(&content_page)
|
.content(&content_page)
|
||||||
.min_sidebar_width(200.0)
|
.min_sidebar_width(200.0)
|
||||||
.max_sidebar_width(240.0)
|
.max_sidebar_width(240.0)
|
||||||
|
// A narrow window opens on the page, not the sidebar.
|
||||||
|
.show_content(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Toasts float over everything, so a report from a background action reaches
|
// Toasts float over everything, so a report from a background action reaches
|
||||||
@@ -355,16 +394,23 @@ fn build_window(app: &adw::Application) {
|
|||||||
let toasts = adw::ToastOverlay::new();
|
let toasts = adw::ToastOverlay::new();
|
||||||
toasts.set_child(Some(&split));
|
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 {
|
let ui = Rc::new(Ui {
|
||||||
dirs,
|
dirs,
|
||||||
stack: stack.clone(),
|
stack: stack.clone(),
|
||||||
toasts: toasts.clone(),
|
toasts: toasts.clone(),
|
||||||
busy: Cell::new(0),
|
busy: Cell::new(0),
|
||||||
opening: RefCell::new(HashSet::new()),
|
opening: RefCell::new(HashSet::new()),
|
||||||
session: RefCell::new(auth::load().ok()),
|
session: RefCell::new(startup_session),
|
||||||
|
unlock_offered: Cell::new(false),
|
||||||
|
not_empty_offered: Cell::new(0),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
|
narrow: Cell::new(false),
|
||||||
file_thumbs: FileThumbnailState::new(),
|
file_thumbs: FileThumbnailState::new(),
|
||||||
login: login_widgets,
|
login: login_widgets,
|
||||||
status: StatusState {
|
status: StatusState {
|
||||||
@@ -394,6 +440,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
theme_row: main_widgets.theme_row.clone(),
|
theme_row: main_widgets.theme_row.clone(),
|
||||||
online_map_row: main_widgets.online_map_row.clone(),
|
online_map_row: main_widgets.online_map_row.clone(),
|
||||||
tray_row: main_widgets.tray_row.clone(),
|
tray_row: main_widgets.tray_row.clone(),
|
||||||
|
update_row: main_widgets.update_row.clone(),
|
||||||
language_row: main_widgets.language_row.clone(),
|
language_row: main_widgets.language_row.clone(),
|
||||||
settings_suppress: Cell::new(false),
|
settings_suppress: Cell::new(false),
|
||||||
budget_source: RefCell::new(None),
|
budget_source: RefCell::new(None),
|
||||||
@@ -520,6 +567,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
reload_pending: Cell::new(false),
|
reload_pending: Cell::new(false),
|
||||||
loading: Cell::new(false),
|
loading: Cell::new(false),
|
||||||
has_more: Cell::new(false),
|
has_more: Cell::new(false),
|
||||||
|
library: Cell::new(true),
|
||||||
page_waiters: RefCell::new(Vec::new()),
|
page_waiters: RefCell::new(Vec::new()),
|
||||||
width: Cell::new(0),
|
width: Cell::new(0),
|
||||||
photo_tex: RefCell::new(HashMap::new()),
|
photo_tex: RefCell::new(HashMap::new()),
|
||||||
@@ -561,6 +609,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
detail: locations_widgets.card_detail.clone(),
|
detail: locations_widgets.card_detail.clone(),
|
||||||
pause: locations_widgets.pause.clone(),
|
pause: locations_widgets.pause.clone(),
|
||||||
paused: Cell::new(false),
|
paused: Cell::new(false),
|
||||||
|
sign_in: locations_widgets.card_sign_in.clone(),
|
||||||
|
restart: locations_widgets.card_restart.clone(),
|
||||||
},
|
},
|
||||||
queue: QueueState {
|
queue: QueueState {
|
||||||
group: locations_widgets.queue_group.clone(),
|
group: locations_widgets.queue_group.clone(),
|
||||||
@@ -613,8 +663,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
running: Cell::new(false),
|
running: Cell::new(false),
|
||||||
dry_run: Cell::new(false),
|
dry_run: Cell::new(false),
|
||||||
},
|
},
|
||||||
|
update,
|
||||||
});
|
});
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
|
if let Some(e) = keyring_error {
|
||||||
|
keyring_unreadable(&ui, &e);
|
||||||
|
}
|
||||||
let ui_status = ui.clone();
|
let ui_status = ui.clone();
|
||||||
main_widgets
|
main_widgets
|
||||||
.status_button
|
.status_button
|
||||||
@@ -656,6 +710,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_activity(&ui, &activity_widgets);
|
wire_activity(&ui, &activity_widgets);
|
||||||
wire_takeout(&ui, &takeout_widgets);
|
wire_takeout(&ui, &takeout_widgets);
|
||||||
wire_retry(&ui);
|
wire_retry(&ui);
|
||||||
|
wire_update(&ui);
|
||||||
|
|
||||||
// Lazily load the Files / Photos pages the first time they're shown, so the
|
// 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.
|
// network round-trip only happens on demand rather than on every refresh.
|
||||||
@@ -692,13 +747,18 @@ fn build_window(app: &adw::Application) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let saved = ui.dirs.load_config().window.usable();
|
||||||
let window = adw::ApplicationWindow::builder()
|
let window = adw::ApplicationWindow::builder()
|
||||||
.application(app)
|
.application(app)
|
||||||
.title("Proton Drive")
|
.title("Proton Drive")
|
||||||
.default_width(980)
|
.default_width(saved.width)
|
||||||
.default_height(680)
|
.default_height(saved.height)
|
||||||
|
.maximized(saved.maximized)
|
||||||
|
.width_request(WindowState::MIN_WIDTH)
|
||||||
|
.height_request(WindowState::MIN_HEIGHT)
|
||||||
.content(&toasts)
|
.content(&toasts)
|
||||||
.build();
|
.build();
|
||||||
|
remember_window_state(&ui, app, &window);
|
||||||
// A wide window docks the details pane beside the files instead of laying it
|
// A wide window docks the details pane beside the files instead of laying it
|
||||||
// over them.
|
// over them.
|
||||||
let wide = adw::Breakpoint::new(
|
let wide = adw::Breakpoint::new(
|
||||||
@@ -706,6 +766,18 @@ fn build_window(app: &adw::Application) {
|
|||||||
);
|
);
|
||||||
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
wide.add_setter(&ui.browser.split, "collapsed", Some(&false.to_value()));
|
||||||
window.add_breakpoint(wide);
|
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_shortcuts(&ui, &window);
|
||||||
install_window_actions(&ui, &window);
|
install_window_actions(&ui, &window);
|
||||||
install_launch_actions(&ui, app, &window);
|
install_launch_actions(&ui, app, &window);
|
||||||
@@ -719,6 +791,51 @@ fn build_window(app: &adw::Application) {
|
|||||||
window.present();
|
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,
|
/// Whether `window` is minimised or otherwise out of sight (another workspace,
|
||||||
/// fully covered), as far as the compositor tells us.
|
/// fully covered), as far as the compositor tells us.
|
||||||
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
fn window_hidden(window: &adw::ApplicationWindow) -> bool {
|
||||||
@@ -765,10 +882,13 @@ const DESTINATIONS: [(&str, &str, &str); 7] = [
|
|||||||
/// Index of the first row in the sidebar's sync section.
|
/// Index of the first row in the sidebar's sync section.
|
||||||
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
const SIDEBAR_SYNC_SECTION: i32 = 6;
|
||||||
|
|
||||||
/// The navigation sidebar: the destinations over a footer carrying the sync
|
/// The navigation sidebar: the destinations under the update banner and over a
|
||||||
/// status, the account quota and the account menu. Returns the page (for the
|
/// 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).
|
/// 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();
|
let list = gtk4::ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::Single);
|
list.set_selection_mode(gtk4::SelectionMode::Single);
|
||||||
list.add_css_class("navigation-sidebar");
|
list.add_css_class("navigation-sidebar");
|
||||||
@@ -813,7 +933,6 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
header.set_title_widget(Some(&brand));
|
header.set_title_widget(Some(&brand));
|
||||||
header.pack_end(&build_primary_menu());
|
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
@@ -822,6 +941,7 @@ fn build_sidebar(footer: >k4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
.build();
|
.build();
|
||||||
let toolbar = adw::ToolbarView::new();
|
let toolbar = adw::ToolbarView::new();
|
||||||
toolbar.add_top_bar(&header);
|
toolbar.add_top_bar(&header);
|
||||||
|
toolbar.add_top_bar(update_banner);
|
||||||
toolbar.set_content(Some(&scroll));
|
toolbar.set_content(Some(&scroll));
|
||||||
toolbar.add_bottom_bar(footer);
|
toolbar.add_bottom_bar(footer);
|
||||||
|
|
||||||
@@ -847,6 +967,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
|
/// 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
|
/// 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
|
/// busy spinner beside it; the page packs its actions into the returned header
|
||||||
@@ -910,6 +1042,14 @@ fn wire_sidebar(ui: &Rc<Ui>) {
|
|||||||
ui_row.stack.set_visible_child_name(page);
|
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
|
/// Re-fetch the visible page from the server, bypassing every layer of cache
|
||||||
@@ -1045,8 +1185,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
|
/// 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
|
/// reported without stealing focus or forcing a click, so the user can keep
|
||||||
/// working while a slow upload lands.
|
/// 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) {
|
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
|
/// Show a toast that offers one follow-up action — an Undo, or a way to the page
|
||||||
@@ -1057,6 +1206,7 @@ fn toast(ui: &Rc<Ui>, message: &str) {
|
|||||||
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
fn toast_action(ui: &Rc<Ui>, message: &str, label: &str, action: impl Fn(&Rc<Ui>) + 'static) {
|
||||||
let toast = adw::Toast::builder()
|
let toast = adw::Toast::builder()
|
||||||
.title(message)
|
.title(message)
|
||||||
|
.use_markup(false)
|
||||||
.button_label(label)
|
.button_label(label)
|
||||||
.timeout(8)
|
.timeout(8)
|
||||||
.build();
|
.build();
|
||||||
@@ -1079,7 +1229,11 @@ fn toast_error(ui: &Rc<Ui>, what: &str, detail: &str) {
|
|||||||
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
gettext_f("{what}: {detail}", &[("what", what), ("detail", detail)])
|
||||||
};
|
};
|
||||||
tracing::warn!("{message}");
|
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);
|
ui.toasts.add_toast(toast);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,25 +1290,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
|
/// Back the main menu's entries with window actions.
|
||||||
/// 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.
|
|
||||||
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
let preferences = gio::SimpleAction::new("preferences", None);
|
let preferences = gio::SimpleAction::new("preferences", None);
|
||||||
let ui_prefs = ui.clone();
|
let ui_prefs = ui.clone();
|
||||||
@@ -1206,7 +1342,7 @@ fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
let dialog = adw::AboutDialog::builder()
|
let dialog = adw::AboutDialog::builder()
|
||||||
.application_name(gettext("Proton Drive for Linux"))
|
.application_name(gettext("Proton Drive for Linux"))
|
||||||
.application_icon("io.narl.proton-drive-linux")
|
.application_icon("io.narl.proton-drive-linux")
|
||||||
.version(pdfs_core::config::APP_VERSION)
|
.version(env!("CARGO_PKG_VERSION"))
|
||||||
.developer_name("Nils Pukropp")
|
.developer_name("Nils Pukropp")
|
||||||
.website("https://proton-drive.narl.io")
|
.website("https://proton-drive.narl.io")
|
||||||
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
.issue_url("https://github.com/narrrl/proton-drive-linux/issues")
|
||||||
@@ -1291,6 +1427,12 @@ fn install_launch_actions(ui: &Rc<Ui>, app: &adw::Application, window: &adw::App
|
|||||||
if ui_page.session.borrow().is_none() {
|
if ui_page.session.borrow().is_none() {
|
||||||
return;
|
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.
|
// Activity lives on as the Sync page's History view.
|
||||||
if page == "activity" {
|
if page == "activity" {
|
||||||
ui_page.locations.views.set_visible_child_name("history");
|
ui_page.locations.views.set_visible_child_name("history");
|
||||||
@@ -1346,8 +1488,8 @@ fn debug_info(ui: &Rc<Ui>) -> String {
|
|||||||
format!(
|
format!(
|
||||||
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
"App version: {}\nUser agent: {}\nlibadwaita: {}.{}.{}\nGTK: {}.{}.{}\n\
|
||||||
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
Mountpoint: {}\nControl socket: {}\nMount service running: {}\n",
|
||||||
pdfs_core::config::APP_VERSION,
|
config.resolved_app_version(),
|
||||||
pdfs_core::config::USER_AGENT,
|
config.resolved_user_agent(),
|
||||||
adw::major_version(),
|
adw::major_version(),
|
||||||
adw::minor_version(),
|
adw::minor_version(),
|
||||||
adw::micro_version(),
|
adw::micro_version(),
|
||||||
@@ -1659,13 +1801,52 @@ pub(crate) fn service_unreachable(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
status(
|
// Running but not answering may be a mount that fails on every try,
|
||||||
&ui,
|
// which never opens the socket. Its reason is in a file the daemon
|
||||||
"network-idle-symbolic",
|
// keeps for this (B263); "Connecting…" alone looked like a slow start
|
||||||
&gettext("Connecting…"),
|
// for as long as it lasted.
|
||||||
&gettext("Waiting for the Proton Drive service to start."),
|
let state_dir = ui.dirs.state_dir();
|
||||||
false,
|
let failure = gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||||
);
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
match failure {
|
||||||
|
Some(failure) if failure.not_empty.is_some() => {
|
||||||
|
let found = failure.not_empty.as_ref().expect("checked above");
|
||||||
|
status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
&gettext("Can't open your Drive"),
|
||||||
|
// Translators: {path} is the folder My files is mounted at.
|
||||||
|
&gettext_f(
|
||||||
|
"“{path}” already has files in it, and My files would hide them. Proton Drive tries again on its own once the folder is empty. You can also choose another folder for My files in Preferences.",
|
||||||
|
&[("path", &found.path.display().to_string())],
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if ui.not_empty_offered.replace(failure.since) != failure.since {
|
||||||
|
offer_not_empty(&ui, found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 || {
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||||
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
if ui.stack.visible_child_name().as_deref() == Some(page) {
|
||||||
reload(&ui);
|
reload(&ui);
|
||||||
@@ -1674,6 +1855,37 @@ pub(crate) fn service_unreachable(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the user the daemon will not mount My files over a folder with files
|
||||||
|
/// in it (B277), and offer the two ways out: open the folder to move them, or
|
||||||
|
/// choose another one.
|
||||||
|
fn offer_not_empty(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Your Proton Drive Folder Isn't Empty"))
|
||||||
|
// Translators: {path} is the folder My files is mounted at; {n} is how
|
||||||
|
// many files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it, so Proton Drive does not open there. Move the item to another folder, or choose another folder for My files.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them, so Proton Drive does not open there. Move the items to another folder, or choose another folder for My files.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Not Now"));
|
||||||
|
dialog.add_response("open", &gettext("Open Folder"));
|
||||||
|
dialog.add_response("choose", &gettext("Choose Another Folder…"));
|
||||||
|
dialog.set_response_appearance("choose", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("choose"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
let ui_response = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| match response {
|
||||||
|
"open" => open_path(&path),
|
||||||
|
"choose" => prompt_mountpoint(&ui_response),
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a copy button shows its check after copying.
|
/// How long a copy button shows its check after copying.
|
||||||
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
const COPIED_FEEDBACK: Duration = Duration::from_millis(1500);
|
||||||
|
|
||||||
@@ -1691,9 +1903,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 a local path with the handler the user configured — `xdg-open` unless
|
||||||
/// `open_with` in `config.json` overrides it for this kind of file.
|
/// `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) {
|
fn open_path(path: &str) {
|
||||||
let path = Path::new(path);
|
let path = PathBuf::from(path);
|
||||||
pdfs_core::opener::open_default(path, path.is_dir());
|
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,
|
/// [`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_icon_name(Some(icon));
|
||||||
ui.activity.status.set_title(title);
|
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.retry.set_visible(retry);
|
||||||
ui.activity.content.set_visible_child_name("status");
|
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::Sync => "pdfs-sync-symbolic",
|
||||||
ActivityKind::Rename => "document-edit-symbolic",
|
ActivityKind::Rename => "document-edit-symbolic",
|
||||||
ActivityKind::Move => "go-jump-symbolic",
|
ActivityKind::Move => "go-jump-symbolic",
|
||||||
|
ActivityKind::Copy => "edit-copy-symbolic",
|
||||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||||
ActivityKind::Trash => "user-trash-symbolic",
|
ActivityKind::Trash => "user-trash-symbolic",
|
||||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-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),
|
(ActivityKind::Move, true) => gettext_f("Moved {name}", &args),
|
||||||
// Translators: {name} is a file or folder name.
|
// Translators: {name} is a file or folder name.
|
||||||
(ActivityKind::Move, false) => gettext_f("Couldn't move {name}", &args),
|
(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.
|
// Translators: {name} is a folder name.
|
||||||
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
(ActivityKind::CreateFolder, true) => gettext_f("Created folder {name}", &args),
|
||||||
// Translators: {name} is a folder name.
|
// 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) {
|
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_icon_name(Some(icon));
|
||||||
ui.gallery.albums_status.set_title(title);
|
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");
|
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::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct BrowserState {
|
pub(crate) struct BrowserState {
|
||||||
@@ -376,7 +376,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
bulk_box.append(&bulk_clear);
|
bulk_box.append(&bulk_clear);
|
||||||
let bulk = gtk4::Revealer::builder()
|
let bulk = gtk4::Revealer::builder()
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||||
.child(&bulk_box)
|
.child(&scroll_sideways(&bulk_box))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// The status bar: contextual text (stretch 1), then the icon-size slider.
|
// 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) {
|
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_icon_name(Some(icon));
|
||||||
ui.browser.status.set_title(title);
|
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);
|
ui.browser.retry.set_visible(retry);
|
||||||
// Only the empty-folder state offers a way to fill the folder; every other
|
// Only the empty-folder state offers a way to fill the folder; every other
|
||||||
// status (loading, offline, error) turns them back off.
|
// 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
|
/// 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
|
/// which bulk actions apply to that mix. An entry kept offline through a pinned
|
||||||
/// a folder anywhere in the selection disables the two pin buttons rather than
|
/// folder above it cannot be made online only on its own, so it does not count
|
||||||
/// half-failing once pressed.
|
/// towards Make online only.
|
||||||
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
||||||
let entries = selected_entries(ui);
|
let entries = selected_entries(ui);
|
||||||
// One selected entry is the details pane's job; the bar is for a batch.
|
// 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);
|
ui.browser.bulk.set_reveal_child(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
let any_pinned = entries.iter().any(|e| e.pinned && !e.pinned_by_folder);
|
||||||
let any_pinned = entries.iter().any(|e| e.pinned);
|
|
||||||
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
let any_unpinned = entries.iter().any(|e| !e.pinned);
|
||||||
let mounted = *ui.mounted.borrow();
|
let mounted = *ui.mounted.borrow();
|
||||||
ui.browser.bulk_label.set_label(&ngettext_f(
|
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_trash.set_sensitive(mounted);
|
||||||
ui.browser
|
ui.browser.bulk_pin.set_sensitive(mounted && any_unpinned);
|
||||||
.bulk_pin
|
ui.browser.bulk_unpin.set_sensitive(mounted && any_pinned);
|
||||||
.set_sensitive(mounted && files_only && any_unpinned);
|
|
||||||
ui.browser
|
|
||||||
.bulk_unpin
|
|
||||||
.set_sensitive(mounted && files_only && any_pinned);
|
|
||||||
ui.browser.bulk.set_reveal_child(true);
|
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) {
|
pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
||||||
let socket = ui.dirs.control_socket();
|
let socket = ui.dirs.control_socket();
|
||||||
// Only the entries that need changing: re-pinning an already-pinned file is
|
// 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.
|
// a wasted round-trip, and it would inflate the count the toast reports. One
|
||||||
let paths: Vec<String> = entries
|
// kept through a pinned folder above it has no pin of its own to remove.
|
||||||
|
let changing: Vec<&DirEntry> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| !e.is_dir && e.pinned != pin)
|
.filter(|e| !e.pinned_by_folder && e.pinned != pin)
|
||||||
.map(|e| entry_rel(ui, e))
|
|
||||||
.collect();
|
.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() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -698,13 +698,27 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
|||||||
(0, None) => {}
|
(0, None) => {}
|
||||||
(n, _) => toast(
|
(n, _) => toast(
|
||||||
&ui,
|
&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(
|
ngettext_f(
|
||||||
"{n} file is now available offline",
|
"{n} file is now available offline",
|
||||||
"{n} files are now available offline",
|
"{n} files are now available offline",
|
||||||
n as u64,
|
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 {
|
} else {
|
||||||
ngettext_f(
|
ngettext_f(
|
||||||
"{n} file is now online only",
|
"{n} file is now online only",
|
||||||
@@ -832,8 +846,8 @@ fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The menu for one entry, in the order of `docs/UI_UX_PLAN.md` §6: open,
|
/// The menu for one entry, in a fixed order: open, offline, sharing,
|
||||||
/// offline, sharing, organising, and the destructive item last on its own.
|
/// organising, and the destructive item last on its own.
|
||||||
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
@@ -860,13 +874,17 @@ pub(crate) fn entry_context_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
|
|||||||
}
|
}
|
||||||
menu.section();
|
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());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
menu.toggle(&gettext("Available offline"), entry.pinned, move |_| {
|
||||||
toggle_pin(&ui_c, &entry_c)
|
toggle_pin(&ui_c, &entry_c)
|
||||||
});
|
});
|
||||||
menu.section();
|
|
||||||
}
|
}
|
||||||
|
menu.section();
|
||||||
|
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Share…"), move || {
|
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 || {
|
menu.item(&gettext("Move To…"), move || {
|
||||||
prompt_move(&ui_c, vec![entry_c.clone()])
|
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 {
|
if !entry.is_dir {
|
||||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
menu.item(&gettext("Versions…"), move || {
|
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.
|
/// simply absent here rather than offered and then refused.
|
||||||
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
pub(crate) fn bulk_context_menu(ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
|
||||||
let mut menu = ActionMenu::new();
|
let mut menu = ActionMenu::new();
|
||||||
// Offline state is a file-only notion; the check shows whether the whole
|
// The check shows whether the whole batch already is offline.
|
||||||
// batch already is.
|
{
|
||||||
if entries.iter().all(|e| !e.is_dir) {
|
|
||||||
let (ui_c, batch) = (ui.clone(), entries.clone());
|
let (ui_c, batch) = (ui.clone(), entries.clone());
|
||||||
let all_pinned = entries.iter().all(|e| e.pinned);
|
let all_pinned = entries.iter().all(|e| e.pinned);
|
||||||
menu.toggle(&gettext("Available offline"), all_pinned, move |pin| {
|
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 || {
|
menu.item(&gettext("Move To…"), move || {
|
||||||
prompt_move(&ui_c, batch.clone())
|
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(
|
menu.labelled_section(&ngettext_f(
|
||||||
"{n} selected",
|
"{n} selected",
|
||||||
"{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) {
|
pub(crate) fn open_entry_with(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
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();
|
let ui = ui.clone();
|
||||||
launcher.launch(
|
glib::spawn_future_local(async move {
|
||||||
ui_window(&ui).as_ref(),
|
let Some(path) = present_within(mounted_path(&mountpoint, &rel)).await else {
|
||||||
gio::Cancellable::NONE,
|
toast_error(
|
||||||
move |result| {
|
&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
|
if let Err(e) = result
|
||||||
&& !e.matches(gtk4::DialogError::Dismissed)
|
&& !e.matches(gtk4::DialogError::Dismissed)
|
||||||
{
|
{
|
||||||
toast_error(&ui, &gettext("Couldn't open file"), &e.to_string());
|
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
|
/// 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).
|
/// [`mounted_target`](crate::activation::mounted_target).
|
||||||
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn download_and_open(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let rel = entry_rel(ui, entry);
|
let rel = entry_rel(ui, entry);
|
||||||
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
// Ignore a repeat activation of a file already opening, so an impatient
|
||||||
if let Some(path) = mounted_target_rel(&mountpoint, &rel) {
|
// double-click doesn't open it twice or kick off a second round-trip.
|
||||||
open_path(&path.to_string_lossy());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Ignore a repeat activation of a file already downloading, so an impatient
|
|
||||||
// double-click doesn't kick off a second round-trip.
|
|
||||||
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
if !ui.opening.borrow_mut().insert(rel.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.busy_begin();
|
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config());
|
||||||
let name = entry.name.clone();
|
let (name, uid) = (entry.name.clone(), entry.uid.clone());
|
||||||
let rx = spawn_request(
|
|
||||||
ui.dirs.control_socket(),
|
|
||||||
Request::OpenFile {
|
|
||||||
path: rel.clone(),
|
|
||||||
uid: Some(entry.uid.clone()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
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;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
ui.opening.borrow_mut().remove(&rel);
|
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 rx = spawn_request(ui.dirs.control_socket(), req);
|
||||||
let name = entry.name.clone();
|
let name = entry.name.clone();
|
||||||
let pinned = entry.pinned;
|
let pinned = entry.pinned;
|
||||||
|
let is_dir = entry.is_dir;
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
@@ -1166,6 +1195,9 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
&ui,
|
&ui,
|
||||||
&if pinned {
|
&if pinned {
|
||||||
gettext_f("“{name}” is now online only", &[("name", &name)])
|
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 {
|
} else {
|
||||||
gettext_f("“{name}” is now available offline", &[("name", &name)])
|
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();
|
let mut acc = String::new();
|
||||||
for (i, seg) in segments.iter().enumerate() {
|
for (i, seg) in segments.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
ui.browser.crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
ui.browser.crumb.append(&sep);
|
|
||||||
acc = if acc.is_empty() {
|
acc = if acc.is_empty() {
|
||||||
seg.to_string()
|
seg.to_string()
|
||||||
} else {
|
} 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).
|
/// 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 {
|
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
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
|
/// 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.
|
/// is sent with absolute paths, which is how the daemon tells locations apart.
|
||||||
pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
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() {
|
if entries.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
let sources: Vec<String> = entries.iter().map(|e| entry_rel(ui, e)).collect();
|
||||||
let heading = match entries.as_slice() {
|
let (heading, body, confirm) = match (kind, entries.as_slice()) {
|
||||||
[one] => gettext_f("Move “{name}”", &[("name", &one.name)]),
|
(PickerKind::Move, [one]) => (
|
||||||
many => ngettext_f("Move {n} item", "Move {n} items", many.len() as u64, &[]),
|
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()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading(heading)
|
.heading(heading)
|
||||||
.body(gettext("Choose the folder to move into."))
|
.body(body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let up = gtk4::Button::builder()
|
let up = gtk4::Button::builder()
|
||||||
@@ -1966,7 +2037,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
|||||||
body.append(&scroll);
|
body.append(&scroll);
|
||||||
dialog.set_extra_child(Some(&body));
|
dialog.set_extra_child(Some(&body));
|
||||||
dialog.add_response("cancel", &gettext("Cancel"));
|
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_response_appearance("move", adw::ResponseAppearance::Suggested);
|
||||||
dialog.set_default_response(Some("move"));
|
dialog.set_default_response(Some("move"));
|
||||||
dialog.set_close_response("cancel");
|
dialog.set_close_response("cancel");
|
||||||
@@ -1979,6 +2050,7 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
|||||||
list,
|
list,
|
||||||
up: up.clone(),
|
up: up.clone(),
|
||||||
sources: sources.clone(),
|
sources: sources.clone(),
|
||||||
|
kind,
|
||||||
folder: RefCell::new(MoveTarget::Locations),
|
folder: RefCell::new(MoveTarget::Locations),
|
||||||
generation: Cell::new(0),
|
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() {
|
match picker_done.folder.borrow().clone() {
|
||||||
MoveTarget::Locations => {}
|
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::Drive(folder) => run_bulk_move(&ui, sources.clone(), folder),
|
||||||
MoveTarget::Synced { path, .. } => {
|
MoveTarget::Synced { path, .. } => {
|
||||||
let absolute = sources
|
let absolute = sources
|
||||||
@@ -2073,8 +2149,9 @@ struct MovePicker {
|
|||||||
location: gtk4::Label,
|
location: gtk4::Label,
|
||||||
list: gtk4::ListBox,
|
list: gtk4::ListBox,
|
||||||
up: gtk4::Button,
|
up: gtk4::Button,
|
||||||
/// What is being moved, mountpoint-relative.
|
/// What is being moved or copied, mountpoint-relative.
|
||||||
sources: Vec<String>,
|
sources: Vec<String>,
|
||||||
|
kind: PickerKind,
|
||||||
/// The folder on show, and the destination if the user confirms now.
|
/// The folder on show, and the destination if the user confirms now.
|
||||||
folder: RefCell<MoveTarget>,
|
folder: RefCell<MoveTarget>,
|
||||||
/// Drops a listing that arrives after the user went elsewhere.
|
/// Drops a listing that arrives after the user went elsewhere.
|
||||||
@@ -2095,11 +2172,13 @@ impl MovePicker {
|
|||||||
self.location.set_tooltip_text(Some(&tooltip));
|
self.location.set_tooltip_text(Some(&tooltip));
|
||||||
self.up
|
self.up
|
||||||
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
.set_sensitive(!matches!(folder, MoveTarget::Locations));
|
||||||
let allowed = match &folder {
|
let allowed = match (&folder, self.kind) {
|
||||||
MoveTarget::Locations => false,
|
(MoveTarget::Locations, _) => false,
|
||||||
MoveTarget::Drive(rel) => move_target_allowed(&self.sources, rel),
|
(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.
|
// 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.dialog.set_response_enabled("move", allowed);
|
||||||
*self.folder.borrow_mut() = folder.clone();
|
*self.folder.borrow_mut() = folder.clone();
|
||||||
@@ -2165,6 +2244,10 @@ impl MovePicker {
|
|||||||
/// My files and every synced folder, as rows of the top level.
|
/// My files and every synced folder, as rows of the top level.
|
||||||
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
async fn locations(&self) -> Option<Vec<PickerRow>> {
|
||||||
let mut rows = vec![(gettext("My files"), None, MoveTarget::Drive(String::new()))];
|
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 rx = spawn_request(self.ui.dirs.control_socket(), Request::ListSyncFolders);
|
||||||
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
let Ok(Ok(Response::SyncFolders { mut items })) = rx.recv().await else {
|
||||||
// My files alone is still somewhere to move to.
|
// 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())
|
.map(|i| path[..i].to_string())
|
||||||
.unwrap_or_default()
|
.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);
|
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
|
/// 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);
|
clear_selection(ui);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let mut done = 0usize;
|
let (done, failure) = send_each(&socket, sources, |path| Request::Move {
|
||||||
let mut failure: Option<(String, ErrorKind)> = None;
|
path,
|
||||||
for path in sources {
|
new_parent: target.clone(),
|
||||||
let rx = spawn_request(
|
})
|
||||||
socket.clone(),
|
.await;
|
||||||
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,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
reload_listing(&ui);
|
reload_listing(&ui);
|
||||||
let place = match target.rsplit('/').next() {
|
let place = quoted_place(&target);
|
||||||
// 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(),
|
|
||||||
};
|
|
||||||
match (done, failure) {
|
match (done, failure) {
|
||||||
(0, Some((message, kind))) => {
|
(0, Some((message, kind))) => {
|
||||||
toast_failure(&ui, &gettext("Couldn't move"), &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.
|
/// 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) {
|
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
run_bulk_delete(ui, vec![entry.clone()]);
|
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
|
/// The sync-state badge for an entry: `(icon, css-class)`. A folder carries no
|
||||||
/// (which carry no per-file cache state). A sync issue ranks first, on folders
|
/// cache state of its own, so it has one only when kept offline. A sync issue
|
||||||
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
|
/// ranks first, on folders too: the change waits for the user. Pinned (kept
|
||||||
/// cached (downloaded, evictable); everything else is online-only.
|
/// offline) ranks above merely cached (downloaded, evictable); everything else
|
||||||
|
/// is online-only.
|
||||||
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
|
||||||
if entry.issue.is_some() {
|
if entry.issue.is_some() {
|
||||||
return Some(("dialog-warning-symbolic", "badge-issue"));
|
return Some(("dialog-warning-symbolic", "badge-issue"));
|
||||||
}
|
}
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
return None;
|
return entry
|
||||||
|
.pinned
|
||||||
|
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||||
}
|
}
|
||||||
Some(if entry.pinned {
|
Some(if entry.pinned {
|
||||||
("pdfs-offline-symbolic", "badge-pinned")
|
("pdfs-offline-symbolic", "badge-pinned")
|
||||||
@@ -2912,6 +3074,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
size: h.size,
|
size: h.size,
|
||||||
modified: h.modified,
|
modified: h.modified,
|
||||||
pinned: h.pinned,
|
pinned: h.pinned,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: h.cached,
|
cached: h.cached,
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
@@ -2959,8 +3122,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
|
MoveTarget, badge_for, copy_target_allowed, hit_location, listing_summary,
|
||||||
show_thumbnail_build_progress, sort_entries,
|
move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||||
};
|
};
|
||||||
use pdfs_core::config::{FileSort, FilesView};
|
use pdfs_core::config::{FileSort, FilesView};
|
||||||
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
|
||||||
@@ -2972,6 +3135,7 @@ mod tests {
|
|||||||
size,
|
size,
|
||||||
modified,
|
modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: String::new(),
|
uid: String::new(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
@@ -2999,6 +3163,8 @@ mod tests {
|
|||||||
|
|
||||||
let mut folder = entry("d", true, 0, 0);
|
let mut folder = entry("d", true, 0, 0);
|
||||||
assert_eq!(badge_for(&folder), None);
|
assert_eq!(badge_for(&folder), None);
|
||||||
|
folder.pinned = true;
|
||||||
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-pinned");
|
||||||
folder.issue = Some(SyncIssue::Missing);
|
folder.issue = Some(SyncIssue::Missing);
|
||||||
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
|
||||||
}
|
}
|
||||||
@@ -3045,6 +3211,16 @@ mod tests {
|
|||||||
assert!(move_target_allowed(&mixed, ""));
|
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]
|
#[test]
|
||||||
fn listing_summary_matches_dolphin_order_and_wording() {
|
fn listing_summary_matches_dolphin_order_and_wording() {
|
||||||
assert_eq!(listing_summary(0, 0, 0), "0 folders, 0 files");
|
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) {
|
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_icon_name(Some(icon));
|
||||||
ui.devices.status.set_title(title);
|
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.retry.set_visible(retry);
|
||||||
ui.devices.content.set_visible_child_name("status");
|
ui.devices.content.set_visible_child_name("status");
|
||||||
}
|
}
|
||||||
@@ -390,6 +392,7 @@ fn this_computer_row(
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
|
||||||
.subtitle(this_computer_subtitle(folders))
|
.subtitle(this_computer_subtitle(folders))
|
||||||
|
.use_markup(false)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
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()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&dev.name)
|
.title(&dev.name)
|
||||||
.subtitle(device_subtitle(dev))
|
.subtitle(device_subtitle(dev))
|
||||||
|
.use_markup(false)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
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 || {
|
menu.item(&gettext("Restore to This Computer…"), move || {
|
||||||
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
|
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();
|
menu.section();
|
||||||
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
|
||||||
menu.item(&gettext("Rename…"), move || {
|
menu.item(&gettext("Rename…"), move || {
|
||||||
@@ -483,9 +495,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
|
|||||||
nav.is_empty(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
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.
|
/// 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 {
|
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
@@ -592,6 +597,19 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
|||||||
folders.len() as u64,
|
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
|
let attention = folders
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
.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.
|
/// Whether a synced folder is a mirror whose first pass has not finished.
|
||||||
pub(crate) fn sync_state_label(state: &str) -> String {
|
/// 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 {
|
match state {
|
||||||
"syncing" => gettext("syncing…"),
|
"syncing" => gettext("syncing…"),
|
||||||
"error" => gettext("sync error"),
|
"error" => gettext("sync error"),
|
||||||
"conflict" => gettext("needs attention"),
|
"conflict" => gettext("needs attention"),
|
||||||
|
_ if never_synced => gettext("waiting for first sync"),
|
||||||
_ => gettext("up to date"),
|
_ => 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.
|
/// — so both read "12 of 40" rather than a percentage that could go backwards.
|
||||||
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||||
match p.phase {
|
match p.phase {
|
||||||
// Before the first pass finishes there is no estimate, so the count would
|
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
|
||||||
// be "checked 12 of 12" — worse than saying nothing.
|
// Before the first pass finishes there is no estimate, so "12 of 12" would
|
||||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
// 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.
|
// Translators: {done} and {total} count folders and files checked so far.
|
||||||
SyncPhase::Scanning => gettext_f(
|
SyncPhase::Scanning => gettext_f(
|
||||||
"checking for changes — {done} of {total}",
|
"checking for changes — {done} of {total}",
|
||||||
@@ -766,7 +801,10 @@ fn show_restore_picker(
|
|||||||
.active(true)
|
.active(true)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.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.set_text(&f.local_path);
|
||||||
row.add_prefix(&check);
|
row.add_prefix(&check);
|
||||||
group.add(&row);
|
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());
|
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.
|
/// 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) {
|
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
@@ -1134,7 +1296,7 @@ mod tests {
|
|||||||
remote_uid: "vol~link".into(),
|
remote_uid: "vol~link".into(),
|
||||||
mode: "mirror".into(),
|
mode: "mirror".into(),
|
||||||
state: state.into(),
|
state: state.into(),
|
||||||
last_sync: 0,
|
last_sync: 1_700_000_000,
|
||||||
pending_mode: None,
|
pending_mode: None,
|
||||||
progress: None,
|
progress: None,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -1146,6 +1308,33 @@ mod tests {
|
|||||||
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
|
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]
|
#[test]
|
||||||
fn folders_needing_attention_are_counted_not_hidden() {
|
fn folders_needing_attention_are_counted_not_hidden() {
|
||||||
// The per-folder rows live on the Sync page, so this row is the only
|
// 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]
|
#[test]
|
||||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
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, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
view.status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||||
view.status.set_title(&failed);
|
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);
|
view.progress.set_visible(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||||
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||||
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||||
//! with no FUSE session behind it (mount-architecture.md §4).
|
//! with no FUSE session behind it.
|
||||||
//!
|
//!
|
||||||
//! The device rows drive the same control requests the Computers page used to:
|
//! The device rows drive the same control requests the Computers page used to:
|
||||||
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||||
@@ -46,6 +46,10 @@ pub(crate) struct SyncCard {
|
|||||||
pub(crate) pause: adw::SplitButton,
|
pub(crate) pause: adw::SplitButton,
|
||||||
/// Whether the last status said paused, so the button knows which way to go.
|
/// Whether the last status said paused, so the button knows which way to go.
|
||||||
pub(crate) paused: Cell<bool>,
|
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
|
/// 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_icon: gtk4::Image,
|
||||||
pub(crate) card_title: gtk4::Label,
|
pub(crate) card_title: gtk4::Label,
|
||||||
pub(crate) card_detail: 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) pause: adw::SplitButton,
|
||||||
pub(crate) queue_group: adw::PreferencesGroup,
|
pub(crate) queue_group: adw::PreferencesGroup,
|
||||||
pub(crate) retry_all: gtk4::Button,
|
pub(crate) retry_all: gtk4::Button,
|
||||||
@@ -134,7 +140,7 @@ pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, Loc
|
|||||||
.build();
|
.build();
|
||||||
add_folder.add_css_class("flat");
|
add_folder.add_css_class("flat");
|
||||||
let transfers_group = build_transfers_group();
|
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 pause = build_pause_button();
|
||||||
let (queue_group, retry_all) = build_queue_group();
|
let (queue_group, retry_all) = build_queue_group();
|
||||||
let conflicts_group = adw::PreferencesGroup::builder()
|
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_icon,
|
||||||
card_title,
|
card_title,
|
||||||
card_detail,
|
card_detail,
|
||||||
|
card_sign_in,
|
||||||
|
card_restart,
|
||||||
pause,
|
pause,
|
||||||
queue_group,
|
queue_group,
|
||||||
retry_all,
|
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();
|
let ui_add = ui.clone();
|
||||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||||
wire_pause(ui);
|
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();
|
let ui_retry = ui.clone();
|
||||||
ui.locations
|
ui.locations
|
||||||
.queue
|
.queue
|
||||||
@@ -379,9 +397,16 @@ pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
|||||||
state.banner.set_revealed(away && title.is_some());
|
state.banner.set_revealed(away && title.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The status hero: a large state icon, the state in words, and what it
|
/// The status hero: a large state icon, the state in words, what it means,
|
||||||
/// 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) {
|
fn build_sync_card() -> (
|
||||||
|
gtk4::Box,
|
||||||
|
gtk4::Image,
|
||||||
|
gtk4::Label,
|
||||||
|
gtk4::Label,
|
||||||
|
gtk4::Button,
|
||||||
|
gtk4::Button,
|
||||||
|
) {
|
||||||
let icon = gtk4::Image::builder()
|
let icon = gtk4::Image::builder()
|
||||||
.icon_name("pdfs-sync-symbolic")
|
.icon_name("pdfs-sync-symbolic")
|
||||||
.pixel_size(48)
|
.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);
|
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
||||||
card.add_css_class("card");
|
card.add_css_class("card");
|
||||||
card.add_css_class("sync-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(&icon);
|
||||||
card.append(&text);
|
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.
|
/// 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_icon_name(Some(icon));
|
||||||
ui.locations.status.set_title(title);
|
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.retry.set_visible(retry);
|
||||||
ui.locations.content.set_visible_child_name("status");
|
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 {
|
} else {
|
||||||
gettext("Pause Syncing")
|
gettext("Pause Syncing")
|
||||||
};
|
};
|
||||||
|
let ui_pause = ui.clone();
|
||||||
|
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
|
||||||
let ui = ui.clone();
|
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();
|
menu.section();
|
||||||
}
|
}
|
||||||
let ui = ui.clone();
|
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
|
/// 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.
|
/// by removing the local copies, which is not something to do by accident.
|
||||||
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
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 {
|
parts.push(match &spec.progress {
|
||||||
Some(p) => sync_progress_label(p),
|
Some(p) => sync_progress_label(p),
|
||||||
None if spec.paused => pgettext("state", "paused"),
|
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:
|
// Only the locations that are supposed to have a session report its absence:
|
||||||
@@ -1682,7 +1778,25 @@ mod tests {
|
|||||||
MountMode::Mirror,
|
MountMode::Mirror,
|
||||||
false,
|
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");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
|
|
||||||
pub(crate) struct LoginState {
|
pub(crate) struct LoginState {
|
||||||
// Login page.
|
// Login page.
|
||||||
@@ -9,6 +9,9 @@ pub(crate) struct LoginState {
|
|||||||
/// Spins inside the Sign in button while a sign-in is running.
|
/// Spins inside the Sign in button while a sign-in is running.
|
||||||
pub(crate) login_spinner: Spinner,
|
pub(crate) login_spinner: Spinner,
|
||||||
pub(crate) login_status: gtk4::Label,
|
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.
|
/// Where the login page's account links go.
|
||||||
@@ -87,6 +90,12 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
.child(&inner)
|
.child(&inner)
|
||||||
.build();
|
.build();
|
||||||
let scroll = gtk4::ScrolledWindow::builder().child(&clamp).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(),
|
scroll.upcast(),
|
||||||
@@ -96,10 +105,80 @@ pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
|||||||
login_button,
|
login_button,
|
||||||
login_spinner,
|
login_spinner,
|
||||||
login_status,
|
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
|
/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker
|
||||||
/// thread, and report the outcome back on the main loop.
|
/// thread, and report the outcome back on the main loop.
|
||||||
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||||
@@ -128,7 +207,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
set_signing_in(&ui, true);
|
set_signing_in(&ui, true);
|
||||||
ui.login.login_status.set_text(&gettext("Signing in…"));
|
ui.login.login_status.set_text(&gettext("Signing in…"));
|
||||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
let LoginChannels {
|
||||||
|
result: rx,
|
||||||
|
totp: totp_req_rx,
|
||||||
|
hv: hv_req_rx,
|
||||||
|
mailbox: mailbox_req_rx,
|
||||||
|
} = spawn_login(username, password);
|
||||||
|
|
||||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||||
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
// the account has 2FA enabled). The worker blocks until the dialog feeds
|
||||||
@@ -145,15 +229,40 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Same shape for human verification: only fires when the API gates the
|
// Same shape for human verification: only fires when the API gates the
|
||||||
// sign-in, and the worker is blocked on the token until it does.
|
// sign-in, and the worker is blocked on the credential until it does.
|
||||||
let ui_hv = ui.clone();
|
let ui_hv = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
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
|
ui_hv
|
||||||
.login
|
.login
|
||||||
.login_status
|
.login_status
|
||||||
.set_text(&gettext("Complete the verification to continue…"));
|
.set_text(&gettext("Complete the verification to continue…"));
|
||||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
// Anything but a credential drops the sender, which the worker
|
||||||
|
// reads as a cancelled sign-in.
|
||||||
|
match verify_human(hv.verification_url()).await {
|
||||||
|
Verification::Solved(token) => {
|
||||||
|
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||||
|
}
|
||||||
|
Verification::Cancelled => {}
|
||||||
|
// The web view could not start, most likely its sandbox
|
||||||
|
// (B195): the user's browser can show the page instead.
|
||||||
|
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// And for the mailbox password, which only a two-password account has.
|
||||||
|
// The worker asks again after a wrong answer, so this loops too.
|
||||||
|
let ui_mailbox = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await {
|
||||||
|
ask_mailbox(&ui_mailbox, attempt > 0, move |answer| {
|
||||||
|
// A cancelled dialog drops the sender, which the worker
|
||||||
|
// reads as a cancelled login.
|
||||||
|
if let Some(answer) = answer {
|
||||||
|
let _ = answer_tx.send(answer);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,7 +278,14 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
ui.login.login_status.set_text("");
|
ui.login.login_status.set_text("");
|
||||||
ui.login.password.set_text("");
|
ui.login.password.set_text("");
|
||||||
// Cache the new identity so `refresh` never hits the keyring.
|
// 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.
|
// Enable+start the mount service now that we have a session.
|
||||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
@@ -197,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 => {
|
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.")
|
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() => {
|
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||||
api.message.clone()
|
api.message.clone()
|
||||||
}
|
}
|
||||||
@@ -209,6 +330,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
|||||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||||
gettext("Sign-in cancelled: no two-factor code was entered.")
|
gettext("Sign-in cancelled: no two-factor code was entered.")
|
||||||
}
|
}
|
||||||
|
pdfs_core::Error::Other(message) if message.contains("mailbox") => {
|
||||||
|
gettext("Sign-in cancelled: no mailbox password was entered.")
|
||||||
|
}
|
||||||
|
pdfs_core::Error::WrongMailboxPassword => {
|
||||||
|
gettext("That mailbox password didn't unlock your account.")
|
||||||
|
}
|
||||||
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
||||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
||||||
),
|
),
|
||||||
@@ -220,26 +347,34 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the login worker asks the UI for, and how it reports back.
|
||||||
|
pub(crate) struct LoginChannels {
|
||||||
|
/// The final outcome, once.
|
||||||
|
pub(crate) result: async_channel::Receiver<Result<(), String>>,
|
||||||
|
/// Fires only if the account needs a 2FA code; carries the sender for it.
|
||||||
|
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
||||||
|
/// Fires only if the sign-in is gated: the challenge, and the sender for the
|
||||||
|
/// credential the user earns by solving it.
|
||||||
|
pub(crate) hv: async_channel::Receiver<(
|
||||||
|
HumanVerification,
|
||||||
|
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||||
|
)>,
|
||||||
|
/// Fires only for a two-password account: how many answers were already
|
||||||
|
/// wrong, and the sender for the password and the "remember" choice.
|
||||||
|
pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||||
/// runtime. Returns two channels: the first yields the final login result once;
|
/// runtime. The returned [`LoginChannels`] carry the final result and the lazy
|
||||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
/// prompts. Each login closure blocks the worker on a sender the UI answers, so
|
||||||
/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login
|
/// a code is requested lazily and can't expire before the password proof.
|
||||||
/// closure blocks the worker on that sender until the dialog answers, so the
|
pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||||
/// code is requested lazily and can't expire before the password proof.
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) fn spawn_login(
|
|
||||||
username: String,
|
|
||||||
password: String,
|
|
||||||
) -> (
|
|
||||||
async_channel::Receiver<Result<(), String>>,
|
|
||||||
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
|
|
||||||
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
|
|
||||||
) {
|
|
||||||
let (tx, rx) = async_channel::bounded(1);
|
let (tx, rx) = async_channel::bounded(1);
|
||||||
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
// Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked
|
||||||
// for a code on each attempt and a single slot would deadlock the worker.
|
// for a code on each attempt and a single slot would deadlock the worker.
|
||||||
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
let (totp_req_tx, totp_req_rx) = async_channel::bounded(2);
|
||||||
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
let (hv_req_tx, hv_req_rx) = async_channel::bounded(1);
|
||||||
|
let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match tokio::runtime::Builder::new_current_thread()
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -267,19 +402,30 @@ pub(crate) fn spawn_login(
|
|||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
.map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into()))
|
||||||
},
|
},
|
||||||
|hv| {
|
|attempt| {
|
||||||
// Gated: hand the UI the page to show, block on the token the
|
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
|
||||||
// user earns by solving it.
|
mailbox_req_tx
|
||||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
.send_blocking((attempt, answer_tx))
|
||||||
hv_req_tx
|
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
|
||||||
.send_blocking((hv.verification_url(), token_tx))
|
let (password, remember) = answer_rx
|
||||||
.map_err(|_| {
|
|
||||||
pdfs_core::Error::Other("verification prompt closed".into())
|
|
||||||
})?;
|
|
||||||
let token = token_rx
|
|
||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
|
||||||
Ok(HumanVerificationCredential::captcha(token))
|
Ok(auth::Mailbox {
|
||||||
|
password: password.into(),
|
||||||
|
remember,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|hv| {
|
||||||
|
// Gated: hand the UI the challenge, block on the credential
|
||||||
|
// the user earns by solving it.
|
||||||
|
let (credential_tx, credential_rx) =
|
||||||
|
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||||
|
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||||
|
pdfs_core::Error::Other("verification prompt closed".into())
|
||||||
|
})?;
|
||||||
|
credential_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -287,7 +433,143 @@ pub(crate) fn spawn_login(
|
|||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
(rx, totp_req_rx, hv_req_rx)
|
LoginChannels {
|
||||||
|
result: rx,
|
||||||
|
totp: totp_req_rx,
|
||||||
|
hv: hv_req_rx,
|
||||||
|
mailbox: mailbox_req_rx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password of a two-password account.
|
||||||
|
///
|
||||||
|
/// `done` gets the password and the "keep unlocked" choice, or `None` when the
|
||||||
|
/// dialog was cancelled. `retry` says the last answer was wrong. The choice
|
||||||
|
/// defaults to off: storing the mailbox unlock in the keyring trades away what
|
||||||
|
/// a separate mailbox password is for, so it is something to opt into.
|
||||||
|
pub(crate) fn ask_mailbox(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
retry: bool,
|
||||||
|
done: impl FnOnce(Option<(String, bool)>) + 'static,
|
||||||
|
) {
|
||||||
|
let body = if retry {
|
||||||
|
gettext("That mailbox password didn't unlock your account. Try again.")
|
||||||
|
} else {
|
||||||
|
gettext("This account has a separate mailbox password. Enter it to unlock your files.")
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Mailbox Password"))
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let entry = adw::PasswordEntryRow::builder()
|
||||||
|
.title(gettext("Mailbox password"))
|
||||||
|
.activates_default(true)
|
||||||
|
.build();
|
||||||
|
let remember = adw::SwitchRow::builder()
|
||||||
|
.title(gettext("Keep unlocked on this computer"))
|
||||||
|
.subtitle(gettext(
|
||||||
|
"Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
group.add(&entry);
|
||||||
|
group.add(&remember);
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
|
||||||
|
dialog.add_response("cancel", &gettext("Cancel"));
|
||||||
|
dialog.add_response("unlock", &gettext("Unlock"));
|
||||||
|
dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("unlock"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
|
||||||
|
let done = RefCell::new(Some(done));
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
let Some(done) = done.borrow_mut().take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let password = entry.text().to_string();
|
||||||
|
if resp == "unlock" && !password.is_empty() {
|
||||||
|
done(Some((password, remember.is_active())));
|
||||||
|
} else {
|
||||||
|
done(None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let parent = ui_window(ui).map(|w| w.upcast::<gtk4::Window>());
|
||||||
|
dialog.present(parent.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the signed-in account is locked, ask for its mailbox password — once per
|
||||||
|
/// run; after a cancel, a toast keeps an Unlock button within reach.
|
||||||
|
///
|
||||||
|
/// The key state is a keyring and kernel-keyring read, so it runs on a worker.
|
||||||
|
pub(crate) fn offer_unlock(ui: &Rc<Ui>) {
|
||||||
|
if ui.unlock_offered.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let state = gio::spawn_blocking(|| auth::key_state().ok()).await;
|
||||||
|
if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) {
|
||||||
|
prompt_unlock(&ui, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Unlocked,
|
||||||
|
Wrong,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password and unlock the account with it.
|
||||||
|
fn prompt_unlock(ui: &Rc<Ui>, retry: bool) {
|
||||||
|
let ui_done = ui.clone();
|
||||||
|
ask_mailbox(ui, retry, move |answer| {
|
||||||
|
let Some((password, remember)) = answer else {
|
||||||
|
toast_action(
|
||||||
|
&ui_done,
|
||||||
|
&gettext("Proton Drive is locked. Enter your mailbox password to unlock it."),
|
||||||
|
&gettext("Unlock"),
|
||||||
|
|ui| prompt_unlock(ui, false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
// Verifying talks to Proton and the restart talks to systemd, so
|
||||||
|
// both run on a worker, never on the main loop.
|
||||||
|
let outcome = gio::spawn_blocking(move || {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => return UnlockOutcome::Failed(e.to_string()),
|
||||||
|
};
|
||||||
|
match rt.block_on(auth::unlock(&password, remember)) {
|
||||||
|
Ok(()) => {
|
||||||
|
service::restart();
|
||||||
|
UnlockOutcome::Unlocked
|
||||||
|
}
|
||||||
|
Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong,
|
||||||
|
Err(e) => UnlockOutcome::Failed(login_error_message(&e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match outcome {
|
||||||
|
Ok(UnlockOutcome::Unlocked) => {
|
||||||
|
toast(&ui_done, &gettext("Unlocked."));
|
||||||
|
refresh(&ui_done);
|
||||||
|
}
|
||||||
|
Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true),
|
||||||
|
Ok(UnlockOutcome::Failed(message)) => {
|
||||||
|
toast_error(&ui_done, &gettext("Couldn't unlock"), &message)
|
||||||
|
}
|
||||||
|
Err(_) => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
/// Show the lazy two-factor dialog and feed the entered code back to the waiting
|
||||||
@@ -330,6 +612,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
|||||||
dialog.present(parent.as_ref());
|
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
|
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||||
/// daemon isn't left running without credentials), forget the stored session,
|
/// daemon isn't left running without credentials), forget the stored session,
|
||||||
/// and drop back to the login page.
|
/// and drop back to the login page.
|
||||||
@@ -349,7 +688,7 @@ pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
|||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = gio::spawn_blocking(|| {
|
let result = gio::spawn_blocking(|| {
|
||||||
service::disable_stop();
|
service::disable_stop();
|
||||||
auth::logout().map_err(|e| e.to_string())
|
auth::logout().map(|_| ()).map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if let Ok(Err(e)) = result {
|
if let Ok(Err(e)) = result {
|
||||||
@@ -374,6 +713,27 @@ mod tests {
|
|||||||
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_mailbox_prompt_is_reported_as_such() {
|
||||||
|
let error = pdfs_core::Error::Other("mailbox entry cancelled".into());
|
||||||
|
assert!(login_error_message(&error).contains("no mailbox password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn 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]
|
#[test]
|
||||||
fn an_unmapped_error_keeps_its_text() {
|
fn an_unmapped_error_keeps_its_text() {
|
||||||
let error = pdfs_core::Error::Other("something odd".into());
|
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_label(message);
|
||||||
viewer.status.set_visible(true);
|
viewer.status.set_visible(true);
|
||||||
};
|
};
|
||||||
match result {
|
let path = match result {
|
||||||
Ok(Ok(Response::FilePath { path })) if is_video => {
|
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.video.set_filename(Some(&path));
|
||||||
viewer.media.set_visible_child_name("video");
|
viewer.media.set_visible_child_name("video");
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*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) => {
|
Ok(texture) => {
|
||||||
// Zoomed in on the thumbnail: keep the photo the size it is
|
// Zoomed in on the thumbnail: keep the photo the size it is
|
||||||
// on screen, now drawn from the full-size pixels.
|
// 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);
|
zoom_to(&viewer, Some(zoom), None);
|
||||||
}
|
}
|
||||||
*viewer.path.borrow_mut() = Some(path.clone());
|
*viewer.path.borrow_mut() = Some(path.clone());
|
||||||
show_info(&viewer, &path, read_exif(&path));
|
show_info(&viewer, size, info);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to load texture for {path}: {e}");
|
tracing::error!("Failed to load texture for {path}: {e}");
|
||||||
fail(&gettext("Couldn't render this photo."));
|
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
|
/// Fill the details drawer for the photo now on screen: its own file facts
|
||||||
/// (size, dimensions) plus whatever EXIF it carries.
|
/// (`size`, dimensions) plus whatever EXIF it carries.
|
||||||
pub(crate) fn show_info(viewer: &Rc<Viewer>, path: &str, info: ExifInfo) {
|
pub(crate) fn show_info(viewer: &Rc<Viewer>, size: Option<u64>, info: ExifInfo) {
|
||||||
while let Some(row) = viewer.info_rows.first_child() {
|
while let Some(row) = viewer.info_rows.first_child() {
|
||||||
viewer.info_rows.remove(&row);
|
viewer.info_rows.remove(&row);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut fields: Vec<(String, String)> = Vec::new();
|
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.
|
// 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);
|
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()
|
let row = adw::ActionRow::builder()
|
||||||
.title(label.as_str())
|
.title(label.as_str())
|
||||||
.subtitle(value)
|
.subtitle(value)
|
||||||
|
.use_markup(false)
|
||||||
.subtitle_selectable(true)
|
.subtitle_selectable(true)
|
||||||
.build();
|
.build();
|
||||||
row.add_css_class("property");
|
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
|
// Any real pointer movement brings the controls back; resting over one of
|
||||||
// them keeps them up.
|
// them keeps them up.
|
||||||
let motion = gtk4::EventControllerMotion::new();
|
let motion = gtk4::EventControllerMotion::new();
|
||||||
let viewer_motion = viewer.clone();
|
let viewer_motion = Rc::downgrade(&viewer);
|
||||||
motion.connect_motion(move |_, x, y| {
|
motion.connect_motion(move |_, x, y| {
|
||||||
|
let Some(viewer_motion) = viewer_motion.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let (last_x, last_y) = viewer_motion.pointer.get();
|
let (last_x, last_y) = viewer_motion.pointer.get();
|
||||||
viewer_motion.pointer.set((x, y));
|
viewer_motion.pointer.set((x, y));
|
||||||
if (x - last_x).abs() > 1.0 || (y - last_y).abs() > 1.0 {
|
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);
|
body.add_controller(motion);
|
||||||
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
for revealer in [&top_reveal, &prev_reveal, &next_reveal] {
|
||||||
let hover = gtk4::EventControllerMotion::new();
|
let hover = gtk4::EventControllerMotion::new();
|
||||||
let viewer_enter = viewer.clone();
|
let viewer_enter = Rc::downgrade(&viewer);
|
||||||
hover.connect_enter(move |_, _, _| viewer_enter.chrome_hover.set(true));
|
hover.connect_enter(move |_, _, _| {
|
||||||
let viewer_leave = viewer.clone();
|
if let Some(viewer) = viewer_enter.upgrade() {
|
||||||
|
viewer.chrome_hover.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let viewer_leave = Rc::downgrade(&viewer);
|
||||||
hover.connect_leave(move |_| {
|
hover.connect_leave(move |_| {
|
||||||
|
let Some(viewer_leave) = viewer_leave.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_leave.chrome_hover.set(false);
|
viewer_leave.chrome_hover.set(false);
|
||||||
show_chrome(&viewer_leave);
|
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.
|
// Ctrl+scroll zooms around the pointer; a plain scroll pans a zoomed photo.
|
||||||
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
let wheel = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||||
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
wheel.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
||||||
let viewer_wheel = viewer.clone();
|
let viewer_wheel = Rc::downgrade(&viewer);
|
||||||
wheel.connect_scroll(move |controller, _, dy| {
|
wheel.connect_scroll(move |controller, _, dy| {
|
||||||
|
let Some(viewer_wheel) = viewer_wheel.upgrade() else {
|
||||||
|
return glib::Propagation::Proceed;
|
||||||
|
};
|
||||||
if !controller
|
if !controller
|
||||||
.current_event_state()
|
.current_event_state()
|
||||||
.contains(gtk4::gdk::ModifierType::CONTROL_MASK)
|
.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);
|
scroller.add_controller(wheel);
|
||||||
|
|
||||||
let pinch = gtk4::GestureZoom::new();
|
let pinch = gtk4::GestureZoom::new();
|
||||||
let viewer_pinch = viewer.clone();
|
let viewer_pinch = Rc::downgrade(&viewer);
|
||||||
pinch.connect_begin(move |_, _| {
|
pinch.connect_begin(move |_, _| {
|
||||||
|
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_pinch
|
viewer_pinch
|
||||||
.pinch_start
|
.pinch_start
|
||||||
.set(current_scale(&viewer_pinch).unwrap_or(1.0));
|
.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| {
|
pinch.connect_scale_changed(move |gesture, delta| {
|
||||||
|
let Some(viewer_pinch) = viewer_pinch.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let start = viewer_pinch.pinch_start.get();
|
let start = viewer_pinch.pinch_start.get();
|
||||||
zoom_to(
|
zoom_to(
|
||||||
&viewer_pinch,
|
&viewer_pinch,
|
||||||
@@ -1323,8 +1368,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
|
|
||||||
// Dragging a zoomed photo pans it.
|
// Dragging a zoomed photo pans it.
|
||||||
let drag = gtk4::GestureDrag::new();
|
let drag = gtk4::GestureDrag::new();
|
||||||
let viewer_drag = viewer.clone();
|
let viewer_drag = Rc::downgrade(&viewer);
|
||||||
drag.connect_drag_begin(move |_, _, _| {
|
drag.connect_drag_begin(move |_, _, _| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let scroller = &viewer_drag.scroller;
|
let scroller = &viewer_drag.scroller;
|
||||||
viewer_drag.drag_start.set((
|
viewer_drag.drag_start.set((
|
||||||
scroller.hadjustment().value(),
|
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"));
|
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| {
|
drag.connect_drag_update(move |_, dx, dy| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_drag.zoom.get().is_none() {
|
if viewer_drag.zoom.get().is_none() {
|
||||||
return;
|
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.hadjustment().set_value(x - dx);
|
||||||
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
viewer_drag.scroller.vadjustment().set_value(y - dy);
|
||||||
});
|
});
|
||||||
let viewer_drag = viewer.clone();
|
let viewer_drag = Rc::downgrade(&viewer);
|
||||||
drag.connect_drag_end(move |_, _, _| {
|
drag.connect_drag_end(move |_, _, _| {
|
||||||
|
let Some(viewer_drag) = viewer_drag.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_drag.zoom.get().is_some() {
|
if viewer_drag.zoom.get().is_some() {
|
||||||
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
viewer_drag.picture.set_cursor_from_name(Some("grab"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
scroller.add_controller(drag);
|
scroller.add_controller(drag);
|
||||||
|
|
||||||
let w_close = window.clone();
|
let w_close = window.downgrade();
|
||||||
close_btn.connect_clicked(move |_| {
|
close_btn.connect_clicked(move |_| {
|
||||||
|
let Some(w_close) = w_close.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
w_close.close();
|
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 |_| {
|
window.connect_close_request(move |_| {
|
||||||
stop_video(&viewer_closing);
|
if let Some(viewer) = keep.borrow_mut().take() {
|
||||||
disarm_slide(&viewer_closing);
|
stop_video(&viewer);
|
||||||
if let Some(source) = viewer_closing.chrome_source.borrow_mut().take() {
|
disarm_slide(&viewer);
|
||||||
source.remove();
|
if let Some(source) = viewer.chrome_source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
}
|
}
|
||||||
glib::Propagation::Proceed
|
glib::Propagation::Proceed
|
||||||
});
|
});
|
||||||
|
|
||||||
let viewer_info = viewer.clone();
|
let viewer_info = Rc::downgrade(&viewer);
|
||||||
info_toggle.connect_toggled(move |toggle| {
|
info_toggle.connect_toggled(move |toggle| {
|
||||||
|
let Some(viewer_info) = viewer_info.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
viewer_info
|
viewer_info
|
||||||
.info_revealer
|
.info_revealer
|
||||||
.set_reveal_child(toggle.is_active());
|
.set_reveal_child(toggle.is_active());
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_slides = ui.clone();
|
let ui_slides = ui.clone();
|
||||||
let viewer_slides = viewer.clone();
|
let viewer_slides = Rc::downgrade(&viewer);
|
||||||
slideshow_btn.connect_toggled(move |toggle| {
|
slideshow_btn.connect_toggled(move |toggle| {
|
||||||
|
let Some(viewer_slides) = viewer_slides.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if toggle.is_active() {
|
if toggle.is_active() {
|
||||||
start_slideshow(&ui_slides, &viewer_slides);
|
start_slideshow(&ui_slides, &viewer_slides);
|
||||||
} else {
|
} 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));
|
info_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||||
|
|
||||||
let ui_map = ui.clone();
|
let ui_map = ui.clone();
|
||||||
let viewer_map = viewer.clone();
|
let viewer_map = Rc::downgrade(&viewer);
|
||||||
let w_map = window.clone();
|
let w_map = window.downgrade();
|
||||||
info_map.connect_clicked(move |_| {
|
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();
|
let coords = *viewer_map.coords.borrow();
|
||||||
if let Some((lat, lon)) = coords {
|
if let Some((lat, lon)) = coords {
|
||||||
w_map.close();
|
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 ui_fav = ui.clone();
|
||||||
let viewer_fav = viewer.clone();
|
let viewer_fav = Rc::downgrade(&viewer);
|
||||||
favorite_btn.connect_toggled(move |btn| {
|
favorite_btn.connect_toggled(move |btn| {
|
||||||
|
let Some(viewer_fav) = viewer_fav.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if viewer_fav.favorite_suppress.get() {
|
if viewer_fav.favorite_suppress.get() {
|
||||||
return;
|
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
|
// gone, and leaving it open on a trashed photo invites a second Delete on
|
||||||
// something the user cannot see.
|
// something the user cannot see.
|
||||||
let ui_delete = ui.clone();
|
let ui_delete = ui.clone();
|
||||||
let viewer_delete = viewer.clone();
|
let viewer_delete = Rc::downgrade(&viewer);
|
||||||
let w_delete = window.clone();
|
let w_delete = window.downgrade();
|
||||||
delete_btn.connect_clicked(move |_| {
|
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();
|
let uid = viewer_delete.uid.borrow().clone();
|
||||||
w_delete.close();
|
w_delete.close();
|
||||||
trash_photos(&ui_delete, vec![uid], false);
|
trash_photos(&ui_delete, vec![uid], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_group = ui.clone();
|
let ui_group = ui.clone();
|
||||||
let viewer_group = viewer.clone();
|
let viewer_group = Rc::downgrade(&viewer);
|
||||||
group_btn.connect_clicked(move |_| {
|
group_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_group) = viewer_group.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let uid = viewer_group.uid.borrow().clone();
|
let uid = viewer_group.uid.borrow().clone();
|
||||||
let next =
|
let next =
|
||||||
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
|
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 ui_download = ui.clone();
|
||||||
let w_download = window.clone();
|
let w_download = window.downgrade();
|
||||||
let viewer_download = viewer.clone();
|
let viewer_download = Rc::downgrade(&viewer);
|
||||||
download_btn.connect_clicked(move |_| {
|
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() {
|
if let Some(path) = viewer_download.path.borrow().as_deref() {
|
||||||
let name = viewer_download.name.borrow();
|
let name = viewer_download.name.borrow();
|
||||||
save_photo_to_disk(
|
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 w_ext = window.downgrade();
|
||||||
let viewer_ext = viewer.clone();
|
let viewer_ext = Rc::downgrade(&viewer);
|
||||||
open_ext_btn.connect_clicked(move |_| {
|
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() {
|
if let Some(path) = viewer_ext.path.borrow().as_deref() {
|
||||||
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
open_with(&w_ext, path, viewer_ext.name.borrow().as_deref());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_prev = ui.clone();
|
let ui_prev = ui.clone();
|
||||||
let viewer_prev = viewer.clone();
|
let viewer_prev = Rc::downgrade(&viewer);
|
||||||
prev_btn.connect_clicked(move |_| {
|
prev_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_prev) = viewer_prev.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
navigate_photo(&ui_prev, &viewer_prev, -1);
|
navigate_photo(&ui_prev, &viewer_prev, -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
let ui_next = ui.clone();
|
let ui_next = ui.clone();
|
||||||
let viewer_next = viewer.clone();
|
let viewer_next = Rc::downgrade(&viewer);
|
||||||
next_btn.connect_clicked(move |_| {
|
next_btn.connect_clicked(move |_| {
|
||||||
|
let Some(viewer_next) = viewer_next.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
navigate_photo(&ui_next, &viewer_next, 1);
|
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
|
// 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 %.
|
// no backdrop to speak of. Double-click toggles between fitted and 100 %.
|
||||||
let backdrop = gtk4::GestureClick::new();
|
let backdrop = gtk4::GestureClick::new();
|
||||||
let viewer_click = viewer.clone();
|
let viewer_click = Rc::downgrade(&viewer);
|
||||||
backdrop.connect_pressed(move |_, n_press, x, y| {
|
backdrop.connect_pressed(move |_, n_press, x, y| {
|
||||||
|
let Some(viewer_click) = viewer_click.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
if n_press != 2 {
|
if n_press != 2 {
|
||||||
return;
|
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));
|
zoom_to(&viewer_click, Some((fit * 2.0).max(1.0)), Some(anchor));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let viewer_click = viewer.clone();
|
let viewer_click = Rc::downgrade(&viewer);
|
||||||
let w_click = window.clone();
|
let w_click = window.downgrade();
|
||||||
backdrop.connect_released(move |_, n_press, x, y| {
|
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
|
if n_press == 1
|
||||||
&& viewer_click.zoom.get().is_none()
|
&& viewer_click.zoom.get().is_none()
|
||||||
&& !over_photo(&viewer_click.picture, x, y)
|
&& !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 key_controller = gtk4::EventControllerKey::new();
|
||||||
let ui_key = ui.clone();
|
let ui_key = ui.clone();
|
||||||
let viewer_key = viewer.clone();
|
let viewer_key = Rc::downgrade(&viewer);
|
||||||
let w_key = window.clone();
|
let w_key = window.downgrade();
|
||||||
key_controller.connect_key_pressed(move |_, key, _keycode, state| {
|
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 ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK);
|
||||||
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
let playing_video = viewer_key.media.visible_child_name().as_deref() == Some("video");
|
||||||
match key.name().as_deref() {
|
match key.name().as_deref() {
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) loading: Cell<bool>,
|
pub(crate) loading: Cell<bool>,
|
||||||
/// Whether the last page came back full, so there may be more to load.
|
/// Whether the last page came back full, so there may be more to load.
|
||||||
pub(crate) has_more: Cell<bool>,
|
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
|
/// Run once the page in flight has landed in [`Self::model`] — how the
|
||||||
/// lightbox steps past the last loaded photo.
|
/// lightbox steps past the last loaded photo.
|
||||||
pub(crate) page_waiters: RefCell<Vec<Box<dyn FnOnce()>>>,
|
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);
|
select_box.append(&select_done);
|
||||||
let select_bar = gtk4::Revealer::builder()
|
let select_bar = gtk4::Revealer::builder()
|
||||||
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
.transition_type(gtk4::RevealerTransitionType::SlideDown)
|
||||||
.child(&select_box)
|
.child(&scroll_sideways(&select_box))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
// All / Photos / Videos / Raw filter. Linked toggles acting as one segmented
|
||||||
@@ -1376,11 +1379,9 @@ pub(crate) fn wire_gallery(
|
|||||||
"tiff" | "tif" => "image/tiff",
|
"tiff" | "tif" => "image/tiff",
|
||||||
_ => "image/jpeg",
|
_ => "image/jpeg",
|
||||||
};
|
};
|
||||||
let capture_time = std::fs::metadata(&path)
|
// The daemon dates the photo: by its EXIF, or by the file's
|
||||||
.and_then(|m| m.modified())
|
// modification time when it has none.
|
||||||
.ok()
|
let capture_time = None;
|
||||||
.and_then(|t| t.duration_since(std::time::SystemTime::UNIX_EPOCH).ok())
|
|
||||||
.map(|d| d.as_secs() as i64);
|
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
@@ -3341,6 +3342,12 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
items,
|
items,
|
||||||
counts,
|
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 {
|
if !available {
|
||||||
gallery_status(
|
gallery_status(
|
||||||
&ui,
|
&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) {
|
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_icon_name(Some(icon));
|
||||||
ui.gallery.status.set_title(title);
|
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);
|
ui.gallery.retry.set_visible(retry);
|
||||||
// Only the empty timeline offers a way to fill it; every other status turns
|
// Only the empty timeline offers a way to fill it; every other status turns
|
||||||
// those buttons back off.
|
// 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
|
/// 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.
|
/// user's default handler when mpv isn't installed.
|
||||||
pub(crate) fn play_external(path: &str) {
|
pub(crate) fn play_external(path: &str) {
|
||||||
if Command::new("mpv").arg(path).spawn().is_ok() {
|
match Command::new("mpv").arg(path).spawn() {
|
||||||
return;
|
Ok(child) => pdfs_core::opener::reap(child),
|
||||||
|
Err(_) => open_path(path),
|
||||||
}
|
}
|
||||||
open_path(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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) {
|
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_icon_name(Some(icon));
|
||||||
ui.gallery.places_status.set_title(title);
|
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");
|
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) {
|
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_icon_name(Some(icon));
|
||||||
ui.shared.status.set_title(title);
|
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.retry.set_visible(retry);
|
||||||
ui.shared.content.set_visible_child_name("status");
|
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(),
|
nav.is_empty(),
|
||||||
));
|
));
|
||||||
for (i, (_, name)) in nav.iter().enumerate() {
|
for (i, (_, name)) in nav.iter().enumerate() {
|
||||||
let sep = gtk4::Label::new(Some("›"));
|
crumb.append(&crumb_separator());
|
||||||
sep.add_css_class("dim-label");
|
|
||||||
crumb.append(&sep);
|
|
||||||
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
|
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.
|
/// 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 {
|
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
|
||||||
if current {
|
if current {
|
||||||
let l = gtk4::Label::builder()
|
return crumb_current(label);
|
||||||
.label(label)
|
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
||||||
.build();
|
|
||||||
l.add_css_class("heading");
|
|
||||||
return l.upcast();
|
|
||||||
}
|
}
|
||||||
let button = gtk4::Button::builder().label(label).build();
|
let button = gtk4::Button::builder().label(label).build();
|
||||||
button.add_css_class("flat");
|
button.add_css_class("flat");
|
||||||
@@ -825,6 +820,7 @@ fn invitation_row(inv: &InvitationInfo) -> adw::ActionRow {
|
|||||||
.title(&item)
|
.title(&item)
|
||||||
// Translators: {email} is the address of the person who sent the invitation.
|
// Translators: {email} is the address of the person who sent the invitation.
|
||||||
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||||
"folder-symbolic"
|
"folder-symbolic"
|
||||||
@@ -869,6 +865,7 @@ fn repaint_links(ui: &Rc<Ui>, bookmarks: &[BookmarkInfo]) {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&title)
|
.title(&title)
|
||||||
.subtitle(&bm.url)
|
.subtitle(&bm.url)
|
||||||
|
.use_markup(false)
|
||||||
.subtitle_lines(1)
|
.subtitle_lines(1)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.tooltip_text(gettext("Open in browser"))
|
.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_icon_name(Some(icon));
|
||||||
ui.shared_by_me.status.set_title(title);
|
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.retry.set_visible(retry);
|
||||||
ui.shared_by_me.content.set_visible_child_name("status");
|
ui.shared_by_me.content.set_visible_child_name("status");
|
||||||
ui.shared_by_me.subtitle.set_subtitle("");
|
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();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
dialog.connect_response(Some("stop"), move |_, _| {
|
dialog.connect_response(Some("stop"), move |_, _| {
|
||||||
let done = done.clone();
|
let done = done.clone();
|
||||||
|
let stopped = uids.clone();
|
||||||
run_requests(
|
run_requests(
|
||||||
&ui_c,
|
&ui_c,
|
||||||
uids.iter()
|
uids.iter()
|
||||||
@@ -572,6 +575,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
.collect(),
|
.collect(),
|
||||||
gettext_noop("Couldn't stop sharing"),
|
gettext_noop("Couldn't stop sharing"),
|
||||||
move |ui| {
|
move |ui| {
|
||||||
|
forget_shared_by_me(ui, &stopped);
|
||||||
reload_listing(ui);
|
reload_listing(ui);
|
||||||
toast(ui, &done);
|
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());
|
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.
|
/// 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) {
|
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
@@ -608,6 +624,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: item.modified,
|
modified: item.modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ pub(crate) struct StatusState {
|
|||||||
pub(crate) online_map_row: adw::SwitchRow,
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
/// "Show tray icon" toggle. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
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
|
/// Language picker: "System Default", then [`i18n::LANGUAGES`]. Guarded by
|
||||||
/// [`Self::settings_suppress`].
|
/// [`Self::settings_suppress`].
|
||||||
pub(crate) language_row: adw::ComboRow,
|
pub(crate) language_row: adw::ComboRow,
|
||||||
@@ -164,6 +167,7 @@ pub(crate) struct MainWidgets {
|
|||||||
pub(crate) theme_row: adw::SwitchRow,
|
pub(crate) theme_row: adw::SwitchRow,
|
||||||
pub(crate) online_map_row: adw::SwitchRow,
|
pub(crate) online_map_row: adw::SwitchRow,
|
||||||
pub(crate) tray_row: adw::SwitchRow,
|
pub(crate) tray_row: adw::SwitchRow,
|
||||||
|
pub(crate) update_row: adw::SwitchRow,
|
||||||
pub(crate) language_row: adw::ComboRow,
|
pub(crate) language_row: adw::ComboRow,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +254,17 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
let language_row = build_language_row();
|
let language_row = build_language_row();
|
||||||
appearance_group.add(&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()
|
let general = adw::PreferencesPage::builder()
|
||||||
.title(gettext("General"))
|
.title(gettext("General"))
|
||||||
.icon_name("preferences-system-symbolic")
|
.icon_name("preferences-system-symbolic")
|
||||||
@@ -258,6 +273,10 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
general.add(&location_group);
|
general.add(&location_group);
|
||||||
general.add(&network_group);
|
general.add(&network_group);
|
||||||
general.add(&appearance_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
|
// ---- Preferences: Storage
|
||||||
let storage_group = adw::PreferencesGroup::builder()
|
let storage_group = adw::PreferencesGroup::builder()
|
||||||
@@ -381,23 +400,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
.build();
|
.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();
|
let account_menu = gio::Menu::new();
|
||||||
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
|
account_menu.append_section(None, &app_section);
|
||||||
let sign_out = gio::Menu::new();
|
account_menu.append_section(None, &account_section);
|
||||||
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
|
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
||||||
account_menu.append_section(None, &sign_out);
|
row.append(&avatar);
|
||||||
let account_button = gtk4::MenuButton::builder()
|
row.append(&account_name);
|
||||||
.icon_name("view-more-symbolic")
|
row.append(>k4::Image::from_icon_name("view-more-symbolic"));
|
||||||
.tooltip_text(gettext("Account"))
|
let account = gtk4::MenuButton::builder()
|
||||||
|
.child(&row)
|
||||||
|
.tooltip_text(gettext("Main menu"))
|
||||||
.menu_model(&account_menu)
|
.menu_model(&account_menu)
|
||||||
.valign(gtk4::Align::Center)
|
.direction(gtk4::ArrowType::Up)
|
||||||
|
.primary(true)
|
||||||
.build();
|
.build();
|
||||||
account_button.add_css_class("flat");
|
account.add_css_class("flat");
|
||||||
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
||||||
account.add_css_class("sidebar-account");
|
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);
|
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||||
footer.add_css_class("sidebar-footer");
|
footer.add_css_class("sidebar-footer");
|
||||||
@@ -431,6 +461,7 @@ pub(crate) fn build_main_page() -> MainWidgets {
|
|||||||
theme_row,
|
theme_row,
|
||||||
online_map_row,
|
online_map_row,
|
||||||
tray_row,
|
tray_row,
|
||||||
|
update_row,
|
||||||
language_row,
|
language_row,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,6 +561,7 @@ pub(crate) fn wire_settings(
|
|||||||
.set_active(config.proton_theme.unwrap_or(false));
|
.set_active(config.proton_theme.unwrap_or(false));
|
||||||
ui.status.online_map_row.set_active(config.online_map);
|
ui.status.online_map_row.set_active(config.online_map);
|
||||||
ui.status.tray_row.set_active(!config.tray_hidden);
|
ui.status.tray_row.set_active(!config.tray_hidden);
|
||||||
|
ui.status.update_row.set_active(config.update_check);
|
||||||
ui.status
|
ui.status
|
||||||
.language_row
|
.language_row
|
||||||
.set_selected(language_index(config.language.as_deref()));
|
.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
|
// Tray icon: the tray reads the same flag at start, so hiding it here also
|
||||||
// keeps the login autostart from bringing it back.
|
// keeps the login autostart from bringing it back.
|
||||||
let ui_tray = ui.clone();
|
let ui_tray = ui.clone();
|
||||||
@@ -754,7 +806,8 @@ pub(crate) fn settings_request(ui: &Rc<Ui>, req: Request, done: String, failed:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
/// Prompt for a new mountpoint folder, persist it to config, and offer to restart
|
||||||
/// the mount service so the daemon picks it up.
|
/// the mount service so the daemon picks it up. A folder with files in it is
|
||||||
|
/// refused: the mount would hide them (B277).
|
||||||
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = gtk4::FileDialog::builder()
|
let dialog = gtk4::FileDialog::builder()
|
||||||
@@ -764,46 +817,89 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||||
let Ok(folder) = res else { return };
|
let Ok(folder) = res else { return };
|
||||||
let Some(path) = folder.path() else { return };
|
let Some(path) = folder.path() else { return };
|
||||||
let path_str = path.display().to_string();
|
// The folder My files is mounted at lists Drive's files, not its own,
|
||||||
|
// so picking it again is not checked.
|
||||||
// Persist the choice to config so the next mount uses it.
|
if path == ui.dirs.resolved_mountpoint(&ui.dirs.load_config()) {
|
||||||
let mut config = ui.dirs.load_config();
|
use_mountpoint(&ui, &path);
|
||||||
config.mountpoint = Some(path_str.clone());
|
|
||||||
if let Err(e) = ui.dirs.save_config(&config) {
|
|
||||||
toast_error(&ui, &gettext("Couldn't save the folder"), &e.to_string());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
let ui = ui.clone();
|
||||||
// The Locations page shows the same path as a row title. Any other page
|
glib::spawn_future_local(async move {
|
||||||
// reads it again on the next visit.
|
let checked = path.clone();
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
let found = gio::spawn_blocking(move || pdfs_core::mountpoint::not_empty(&checked))
|
||||||
load_locations(&ui);
|
.await
|
||||||
}
|
.unwrap_or_else(|_| Err(std::io::Error::other("the check did not finish")));
|
||||||
|
match found {
|
||||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
Ok(None) => use_mountpoint(&ui, &path),
|
||||||
let confirm = adw::AlertDialog::builder()
|
Ok(Some(found)) => refuse_mountpoint(&ui, &found),
|
||||||
.heading(gettext("Restart Proton Drive?"))
|
Err(e) => toast_error(&ui, &gettext("Couldn't read the folder"), &e.to_string()),
|
||||||
// Translators: {path} is the folder the drive is mounted at.
|
|
||||||
.body(gettext_f(
|
|
||||||
"My files now appears in “{path}” after Proton Drive restarts.",
|
|
||||||
&[("path", &path_str)],
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
confirm.add_response("later", &gettext("Later"));
|
|
||||||
confirm.add_response("restart", &gettext("Restart Now"));
|
|
||||||
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
|
||||||
confirm.set_default_response(Some("restart"));
|
|
||||||
confirm.set_close_response("later");
|
|
||||||
let ui_restart = ui.clone();
|
|
||||||
confirm.connect_response(None, move |_, resp| {
|
|
||||||
if resp == "restart" {
|
|
||||||
restart_service_then(&ui_restart, |_| {});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
confirm.present(ui_window(&ui).as_ref());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explain why the folder `found` cannot hold My files.
|
||||||
|
fn refuse_mountpoint(ui: &Rc<Ui>, found: &pdfs_core::mountpoint::NotEmpty) {
|
||||||
|
let path = found.path.display().to_string();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Choose an Empty Folder"))
|
||||||
|
// Translators: {path} is the folder the user picked; {n} is how many
|
||||||
|
// files and folders are already in it.
|
||||||
|
.body(ngettext_f(
|
||||||
|
"“{path}” already has {n} item in it. My files would hide it while Proton Drive runs, and it would not be uploaded.",
|
||||||
|
"“{path}” already has {n} items in it. My files would hide them while Proton Drive runs, and they would not be uploaded.",
|
||||||
|
found.count as u64,
|
||||||
|
&[("path", &path)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("close", &gettext("Close"));
|
||||||
|
dialog.set_default_response(Some("close"));
|
||||||
|
dialog.set_close_response("close");
|
||||||
|
dialog.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `path` the folder My files is mounted at, and offer the restart that
|
||||||
|
/// moves it there.
|
||||||
|
fn use_mountpoint(ui: &Rc<Ui>, path: &std::path::Path) {
|
||||||
|
let path_str = path.display().to_string();
|
||||||
|
|
||||||
|
// Persist the choice to config so the next mount uses it.
|
||||||
|
let mut config = ui.dirs.load_config();
|
||||||
|
config.mountpoint = Some(path_str.clone());
|
||||||
|
if let Err(e) = ui.dirs.save_config(&config) {
|
||||||
|
toast_error(ui, &gettext("Couldn't save the folder"), &e.to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||||
|
// The Locations page shows the same path as a row title. Any other page
|
||||||
|
// reads it again on the next visit.
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
|
load_locations(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
|
let confirm = adw::AlertDialog::builder()
|
||||||
|
.heading(gettext("Restart Proton Drive?"))
|
||||||
|
// Translators: {path} is the folder the drive is mounted at.
|
||||||
|
.body(gettext_f(
|
||||||
|
"My files now appears in “{path}” after Proton Drive restarts.",
|
||||||
|
&[("path", &path_str)],
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
confirm.add_response("later", &gettext("Later"));
|
||||||
|
confirm.add_response("restart", &gettext("Restart Now"));
|
||||||
|
confirm.set_response_appearance("restart", adw::ResponseAppearance::Suggested);
|
||||||
|
confirm.set_default_response(Some("restart"));
|
||||||
|
confirm.set_close_response("later");
|
||||||
|
let ui_restart = ui.clone();
|
||||||
|
confirm.connect_response(None, move |_, resp| {
|
||||||
|
if resp == "restart" {
|
||||||
|
restart_service_then(&ui_restart, |_| {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
confirm.present(ui_window(ui).as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
/// Connect the Files/Photos "Retry" buttons (shown by [`browser_unreachable`] /
|
||||||
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
|
/// [`gallery_unreachable`] when the mount is down): restart the systemd unit and
|
||||||
/// reload the page.
|
/// reload the page.
|
||||||
@@ -825,6 +921,13 @@ pub(crate) fn wire_retry(ui: &Rc<Ui>) {
|
|||||||
.connect_clicked(move |_| restart_service_then(&ui_trash, load_trash));
|
.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
|
/// 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
|
/// 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
|
/// restart; the daemon's event feed keeps both current in between. The identity
|
||||||
@@ -842,7 +945,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
if ui.stack.visible_child_name().as_deref() == Some("login") {
|
||||||
ui.stack.set_visible_child_name("browser");
|
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 {
|
if ui.status.account_name.label() != s.username {
|
||||||
ui.status.account_name.set_label(&s.username);
|
ui.status.account_name.set_label(&s.username);
|
||||||
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
ui.status.account_name.set_tooltip_text(Some(&s.username));
|
||||||
@@ -860,6 +963,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offer_unlock(ui);
|
||||||
refresh_status(ui);
|
refresh_status(ui);
|
||||||
refresh_transfers(ui);
|
refresh_transfers(ui);
|
||||||
}
|
}
|
||||||
@@ -939,7 +1043,9 @@ fn quota_display(max_space: i64, used_space: i64) -> (f64, String) {
|
|||||||
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
||||||
*ui.mounted.borrow_mut() = mounted;
|
*ui.mounted.borrow_mut() = mounted;
|
||||||
sync_mounted_actions(ui, 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.pin_row.set_sensitive(mounted);
|
||||||
ui.details.details.rename_button.set_sensitive(mounted);
|
ui.details.details.rename_button.set_sensitive(mounted);
|
||||||
ui.details.details.trash_button.set_sensitive(mounted);
|
ui.details.details.trash_button.set_sensitive(mounted);
|
||||||
@@ -1209,6 +1315,9 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
failing_error,
|
failing_error,
|
||||||
paused,
|
paused,
|
||||||
paused_until,
|
paused_until,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
@@ -1219,7 +1328,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
|
// 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.
|
// yet, and offline is usually the reason it is still queued.
|
||||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
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 {
|
SyncState::Paused {
|
||||||
until: paused_until,
|
until: paused_until,
|
||||||
queued,
|
queued,
|
||||||
@@ -1272,16 +1387,57 @@ pub(crate) fn paint_status(ui: &Rc<Ui>, status: Response) {
|
|||||||
/// read-out so the page doesn't flicker on a blip.
|
/// read-out so the page doesn't flicker on a blip.
|
||||||
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
||||||
set_mounted(ui, false);
|
set_mounted(ui, false);
|
||||||
paint_sync_status(ui, SyncState::Disconnected);
|
|
||||||
for r in ui.status.pin_rows.borrow().iter() {
|
for r in ui.status.pin_rows.borrow().iter() {
|
||||||
if let Some(b) = &r.unpin {
|
if let Some(b) = &r.unpin {
|
||||||
b.set_sensitive(false);
|
b.set_sensitive(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A running daemon whose mount fails on every try never answers either,
|
||||||
|
// and "isn't running" would be wrong for it (B263, B277). The pages read
|
||||||
|
// the same failure record.
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failure = if service_down().await {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let state_dir = ui.dirs.state_dir();
|
||||||
|
gio::spawn_blocking(move || pdfs_core::mountfail::read(&state_dir))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
};
|
||||||
|
// The daemon may have answered while this looked.
|
||||||
|
if *ui.mounted.borrow() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paint_sync_status(
|
||||||
|
&ui,
|
||||||
|
match failure {
|
||||||
|
Some(failure) => SyncState::CannotOpen {
|
||||||
|
not_empty: failure.not_empty.is_some(),
|
||||||
|
},
|
||||||
|
None => SyncState::Disconnected,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the sidebar's status strip reports, most urgent first.
|
/// What the sidebar's status strip reports, most urgent first.
|
||||||
pub(crate) enum SyncState {
|
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
|
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||||
/// everything else, because it is the reason for everything else.
|
/// everything else, because it is the reason for everything else.
|
||||||
Paused {
|
Paused {
|
||||||
@@ -1307,6 +1463,11 @@ pub(crate) enum SyncState {
|
|||||||
},
|
},
|
||||||
/// No mount daemon answered: still starting, or the service is down.
|
/// No mount daemon answered: still starting, or the service is down.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
/// The service runs, but its mount fails on every try. `not_empty` when
|
||||||
|
/// it refuses a folder that already has files in it.
|
||||||
|
CannotOpen {
|
||||||
|
not_empty: bool,
|
||||||
|
},
|
||||||
/// The daemon is running but didn't answer in time.
|
/// The daemon is running but didn't answer in time.
|
||||||
NotResponding,
|
NotResponding,
|
||||||
}
|
}
|
||||||
@@ -1315,8 +1476,48 @@ pub(crate) enum SyncState {
|
|||||||
/// one-line state, and a detail line.
|
/// one-line state, and a detail line.
|
||||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||||
let paused = matches!(state, SyncState::Paused { .. });
|
let paused = matches!(state, SyncState::Paused { .. });
|
||||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
let connected = !matches!(
|
||||||
|
state,
|
||||||
|
SyncState::Disconnected | SyncState::CannotOpen { .. } | SyncState::NotResponding
|
||||||
|
);
|
||||||
|
let expired = matches!(state, SyncState::SessionExpired { .. });
|
||||||
|
let outdated = matches!(state, SyncState::Outdated { .. });
|
||||||
let (icon, class, title, detail) = match state {
|
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 } => (
|
SyncState::Paused { until, queued } => (
|
||||||
"media-playback-pause-symbolic",
|
"media-playback-pause-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
@@ -1370,6 +1571,16 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
gettext("Not connected"),
|
gettext("Not connected"),
|
||||||
Some(gettext("Proton Drive isn't running")),
|
Some(gettext("Proton Drive isn't running")),
|
||||||
),
|
),
|
||||||
|
SyncState::CannotOpen { not_empty } => (
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
Some("warning"),
|
||||||
|
gettext("Can't open your Drive"),
|
||||||
|
Some(if not_empty {
|
||||||
|
gettext("Your Proton Drive folder isn't empty")
|
||||||
|
} else {
|
||||||
|
gettext("Proton Drive keeps trying to start")
|
||||||
|
}),
|
||||||
|
),
|
||||||
SyncState::NotResponding => (
|
SyncState::NotResponding => (
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
Some("warning"),
|
Some("warning"),
|
||||||
@@ -1387,6 +1598,8 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
|||||||
if let Some(class) = class {
|
if let Some(class) = class {
|
||||||
image.add_css_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_title.set_label(&title);
|
||||||
ui.status.status_detail.set_visible(detail.is_some());
|
ui.status.status_detail.set_visible(detail.is_some());
|
||||||
ui.status
|
ui.status
|
||||||
@@ -1455,7 +1668,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(gettext("No files kept offline"))
|
.title(gettext("No files kept offline"))
|
||||||
.subtitle(gettext(
|
.subtitle(gettext(
|
||||||
"Right-click a file and choose “Make available offline”.",
|
"Right-click a file or folder and choose “Available offline”.",
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
ui.status.pins_group.add(&row);
|
ui.status.pins_group.add(&row);
|
||||||
@@ -1483,6 +1696,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&name)
|
.title(&name)
|
||||||
.subtitle(&pin.path)
|
.subtitle(&pin.path)
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
let icon = gtk4::Image::from_icon_name("pdfs-offline-symbolic");
|
||||||
row.add_prefix(&icon);
|
row.add_prefix(&icon);
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
.unwrap_or_else(|| path.display().to_string()),
|
.unwrap_or_else(|| path.display().to_string()),
|
||||||
)
|
)
|
||||||
.subtitle(human_bytes(size))
|
.subtitle(human_bytes(size))
|
||||||
|
.use_markup(false)
|
||||||
.build();
|
.build();
|
||||||
let remove = gtk4::Button::builder()
|
let remove = gtk4::Button::builder()
|
||||||
.icon_name("edit-delete-symbolic")
|
.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
|
// An import runs for hours, so its ending is announced rather than
|
||||||
// left to be discovered: a toast for whoever is looking at the
|
// left to be discovered: a toast for whoever is looking at the
|
||||||
// window, and a desktop notification for whoever is not.
|
// 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 dry = ui.takeout.dry_run.get();
|
||||||
let headline = if dry {
|
let headline = if dry {
|
||||||
gettext("Scan finished")
|
gettext("Scan finished")
|
||||||
@@ -701,7 +707,18 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
|||||||
} else {
|
} else {
|
||||||
gettext("Last import")
|
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.")
|
gettext("Nothing was uploaded — this is what an import would do.")
|
||||||
} else if summary.cancelled {
|
} else if summary.cancelled {
|
||||||
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||||
|
|||||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user