mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -64,7 +64,7 @@ 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.
|
||||||
simulation:
|
simulation:
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -153,6 +153,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)
|
||||||
|
|
||||||
@@ -230,21 +233,67 @@ jobs:
|
|||||||
*.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
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -3,3 +3,5 @@
|
|||||||
*.pdb
|
*.pdb
|
||||||
.env
|
.env
|
||||||
/rpmbuild
|
/rpmbuild
|
||||||
|
/result
|
||||||
|
/result-*
|
||||||
+3
-3
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+14
-10
@@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "3.0.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -3485,13 +3485,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "3.0.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64 0.23.1",
|
||||||
"dbus-secret-service-keyring-store",
|
"dbus-secret-service-keyring-store",
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3508,12 +3510,13 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "3.0.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3535,7 +3538,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "3.0.0"
|
version = "3.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pgp"
|
name = "pgp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1"
|
checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead 0.5.2",
|
"aead 0.5.2",
|
||||||
"aes 0.8.4",
|
"aes 0.8.4",
|
||||||
@@ -3775,9 +3778,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.7.2"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be"
|
checksum = "9e6b87f78711e8fc72737c8eb24ebda156079308d79ab37ca2b1b32de6812880"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3802,9 +3805,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.7.2"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d"
|
checksum = "19f1936755c603b98de1c82a5b343c6232e9fea8adaa77139f7cb8fcddcda697"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3831,6 +3834,7 @@ dependencies = [
|
|||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+4
-3
@@ -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.4.1"
|
||||||
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.9.1" }
|
||||||
proton-drive-rs = { version = "0.7.2" }
|
proton-drive-rs = { version = "0.9.1" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs",
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { 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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ scriptable CLI.
|
|||||||
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
| Debian, Ubuntu 24.04 or newer | `.deb` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
||||||
| Fedora 44 or newer | `.rpm` from the [latest release](https://github.com/narrrl/proton-drive-linux/releases/latest) |
|
| Fedora 44 or newer | `.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 |
|
||||||
|
| NixOS | The flake in this repository: see [Install → NixOS](docs/INSTALL.md#nixos-flake) |
|
||||||
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
| Anything else | [Build from source](docs/INSTALL.md#build-from-source) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,6 +109,7 @@ video thumbnails), headless use and uninstalling.
|
|||||||
| [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 +151,7 @@ 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).
|
[threat model](docs/ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -156,6 +159,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
|
||||||
+306
-27
@@ -15,7 +15,7 @@ use pdfs_core::config::AppDirs;
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncIssue, SyncPhase,
|
||||||
pending_summary,
|
daemon_outdated, pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
@@ -135,6 +135,24 @@ enum Command {
|
|||||||
/// Account email; prompted if omitted.
|
/// Account email; prompted if omitted.
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
/// For a two-password account, keep the mailbox password's unlock in the
|
||||||
|
/// system keyring so the daemon starts without asking after every login.
|
||||||
|
/// Without it the unlock lives in kernel memory for this session only.
|
||||||
|
#[arg(long)]
|
||||||
|
remember: bool,
|
||||||
|
},
|
||||||
|
/// Enter the mailbox password of a two-password account so the daemon can
|
||||||
|
/// unlock your files. Needed once per login session.
|
||||||
|
Unlock {
|
||||||
|
/// Keep the unlock in the system keyring instead of kernel memory only.
|
||||||
|
#[arg(long)]
|
||||||
|
remember: bool,
|
||||||
|
},
|
||||||
|
/// Forget the unlock held in kernel memory; the account is locked again.
|
||||||
|
Lock {
|
||||||
|
/// Also remove an unlock kept in the keyring with `--remember`.
|
||||||
|
#[arg(long)]
|
||||||
|
forget: bool,
|
||||||
},
|
},
|
||||||
/// Forget the stored session.
|
/// Forget the stored session.
|
||||||
Logout,
|
Logout,
|
||||||
@@ -154,17 +172,19 @@ enum Command {
|
|||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
mountpoint: Option<PathBuf>,
|
mountpoint: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
/// Keep a file on this device (download + cache it). Needs a running mount.
|
/// Keep a file or folder on this device (download + cache it). A folder's
|
||||||
|
/// files download in the background; `pdfs transfers` shows the progress.
|
||||||
|
/// Needs a running mount.
|
||||||
Pin {
|
Pin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// Stop keeping a file on this device, evicting its cached content.
|
/// Stop keeping a file or folder on this device, evicting its cached content.
|
||||||
Unpin {
|
Unpin {
|
||||||
/// File path, inside the mountpoint or relative to it.
|
/// File or folder path, inside the mountpoint or relative to it.
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
},
|
},
|
||||||
/// List files pinned to this device.
|
/// List files and folders pinned to this device.
|
||||||
Pins,
|
Pins,
|
||||||
/// List a directory via the running daemon (in-app browser backend).
|
/// List a directory via the running daemon (in-app browser backend).
|
||||||
Ls {
|
Ls {
|
||||||
@@ -610,6 +630,15 @@ enum DeviceCmd {
|
|||||||
#[arg(long, conflicts_with = "uid")]
|
#[arg(long, conflicts_with = "uid")]
|
||||||
clear: bool,
|
clear: bool,
|
||||||
},
|
},
|
||||||
|
/// Move another computer's folders into this computer's backup and sync
|
||||||
|
/// them here. The other computer stays registered, without those folders.
|
||||||
|
Migrate {
|
||||||
|
/// Device uid (from `devices list`).
|
||||||
|
uid: String,
|
||||||
|
/// Skip the confirmation and accept every proposed local path.
|
||||||
|
#[arg(long)]
|
||||||
|
yes: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -803,7 +832,9 @@ fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed);
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Login { username } => cmd_login(username),
|
Command::Login { username, remember } => cmd_login(username, remember),
|
||||||
|
Command::Unlock { remember } => cmd_unlock(remember),
|
||||||
|
Command::Lock { forget } => cmd_lock(forget),
|
||||||
Command::Logout => cmd_logout(),
|
Command::Logout => cmd_logout(),
|
||||||
Command::Status => cmd_status(),
|
Command::Status => cmd_status(),
|
||||||
Command::Locations => cmd_locations(),
|
Command::Locations => cmd_locations(),
|
||||||
@@ -947,6 +978,24 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> {
|
|||||||
let uid = if clear { None } else { uid };
|
let uid = if clear { None } else { uid };
|
||||||
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
ok_or_bail(control_request(CtlRequest::AdoptDevice { uid })?)?
|
||||||
}
|
}
|
||||||
|
DeviceCmd::Migrate { uid, yes } => {
|
||||||
|
if !yes {
|
||||||
|
println!(
|
||||||
|
"Every folder that device backs up moves into this computer's backup.\n\
|
||||||
|
The device stays registered, without those folders. Stop Proton Drive\n\
|
||||||
|
on that computer first, or it sees its folders disappear."
|
||||||
|
);
|
||||||
|
confirm("Move them here?")?;
|
||||||
|
}
|
||||||
|
let response = control_request(CtlRequest::MigrateDevice { uid })?;
|
||||||
|
// A script gets the one reply and runs `sync restore` itself.
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ok_or_bail(response)?;
|
||||||
|
// The folders are this device's now, so the plain restore offers them.
|
||||||
|
cmd_sync_restore(yes, None)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1781,7 +1830,7 @@ fn prompt_line(label: &str) -> Result<String> {
|
|||||||
Ok(line.trim().to_owned())
|
Ok(line.trim().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_login(username: Option<String>) -> Result<()> {
|
fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
||||||
let username = match username {
|
let username = match username {
|
||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => prompt_line("Proton email")?,
|
None => prompt_line("Proton email")?,
|
||||||
@@ -1793,19 +1842,46 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}")))
|
||||||
};
|
};
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
// Only a two-password account is ever asked for this.
|
||||||
// Solving a CAPTCHA needs a browser engine, which the CLI has no business
|
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
||||||
// carrying. Point at the app that does rather than failing with the raw API
|
|
||||||
// message, which reads as "your login is broken".
|
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
|
||||||
rt.block_on(auth::login(&username, &password, get_totp))
|
// browser engine, so the user solves the page in their own browser and says
|
||||||
.map_err(|e| match e {
|
// when they are done; the retry then carries the challenge token.
|
||||||
pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!(
|
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
|
||||||
|
println!(
|
||||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||||
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
Open this page in your browser and complete it:\n\n {}\n",
|
||||||
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
auth::browser_verification_url(&hv)
|
||||||
your usual network often avoids it.)"
|
);
|
||||||
),
|
prompt_line("Press Enter once the page says you are verified")
|
||||||
other => anyhow::Error::new(other).context("login failed"),
|
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
|
||||||
|
Ok(auth::browser_verified(&hv))
|
||||||
|
};
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
rt.block_on(auth::login_interactive(
|
||||||
|
&username,
|
||||||
|
&password,
|
||||||
|
get_totp,
|
||||||
|
get_mailbox,
|
||||||
|
get_hv,
|
||||||
|
))
|
||||||
|
.map_err(|e| {
|
||||||
|
// Proton words this block as "update the app", which the user can't
|
||||||
|
// act on: the release is current and only waiting helps.
|
||||||
|
let blocked =
|
||||||
|
matches!(&e, pdfs_core::Error::Proton(p) if pdfs_core::error::sign_in_blocked(p));
|
||||||
|
let e = anyhow::Error::new(e).context("login failed");
|
||||||
|
if blocked {
|
||||||
|
e.context(
|
||||||
|
"Proton is temporarily refusing sign-in from this device (code 2028). \
|
||||||
|
Your version is not the problem. Wait several hours, up to a day, \
|
||||||
|
without trying again, then sign in.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||||
@@ -1818,9 +1894,88 @@ fn cmd_login(username: Option<String>) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("Logged in as {username}. Session stored in the system keyring.");
|
println!("Logged in as {username}. Session stored in the system keyring.");
|
||||||
|
report_key_state();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask for the mailbox password of a two-password account, without echo.
|
||||||
|
fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result<auth::Mailbox> {
|
||||||
|
if attempt == 0 {
|
||||||
|
eprintln!("This account has a separate mailbox password.");
|
||||||
|
} else {
|
||||||
|
eprintln!("That mailbox password did not unlock the account. Try again.");
|
||||||
|
}
|
||||||
|
let password = rpassword::prompt_password("Mailbox password: ")
|
||||||
|
.map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?;
|
||||||
|
Ok(auth::Mailbox {
|
||||||
|
password: zeroize::Zeroizing::new(password),
|
||||||
|
remember,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Say where the account's keys are held, and what that means for the next login.
|
||||||
|
fn report_key_state() {
|
||||||
|
match auth::key_state() {
|
||||||
|
Ok(auth::KeyState::Session) => println!(
|
||||||
|
"Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`."
|
||||||
|
),
|
||||||
|
Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_unlock(remember: bool) -> Result<()> {
|
||||||
|
auth::load().context("not logged in (run `pdfs login` first)")?;
|
||||||
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
|
// Bounded like the login prompt: three wrong answers end the command.
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
let mailbox = prompt_mailbox(attempt, remember)?;
|
||||||
|
match rt.block_on(auth::unlock(&mailbox.password, remember)) {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1,
|
||||||
|
Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A daemon parked on the locked account would otherwise notice on its next
|
||||||
|
// poll; restarting it makes the mount appear now, as after `pdfs login`.
|
||||||
|
if service::is_active() {
|
||||||
|
service::restart();
|
||||||
|
}
|
||||||
|
println!("Unlocked.");
|
||||||
|
report_key_state();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_lock(forget: bool) -> Result<()> {
|
||||||
|
auth::lock(forget)?;
|
||||||
|
println!(
|
||||||
|
"Locked. {}",
|
||||||
|
if forget {
|
||||||
|
"Unlock removed from memory and keyring."
|
||||||
|
} else {
|
||||||
|
"Run `pdfs unlock` to unlock again."
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// The running daemon keeps its keys until it restarts; say so, since
|
||||||
|
// "locked" would otherwise suggest the mount stopped.
|
||||||
|
if service::is_active() {
|
||||||
|
println!(
|
||||||
|
"The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stored`, `session` or `locked`: where the account's keys are held.
|
||||||
|
fn key_state_name() -> &'static str {
|
||||||
|
match auth::key_state() {
|
||||||
|
Ok(auth::KeyState::Stored) => "stored",
|
||||||
|
Ok(auth::KeyState::Session) => "session",
|
||||||
|
_ => "locked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_logout() -> Result<()> {
|
fn cmd_logout() -> Result<()> {
|
||||||
auth::logout()?;
|
auth::logout()?;
|
||||||
println!("Stored session cleared.");
|
println!("Stored session cleared.");
|
||||||
@@ -1848,6 +2003,7 @@ fn cmd_status() -> Result<()> {
|
|||||||
"username": session.as_ref().map(|s| s.username.clone()),
|
"username": session.as_ref().map(|s| s.username.clone()),
|
||||||
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
"user_id": session.as_ref().map(|s| s.user_id.clone()),
|
||||||
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
"scopes": session.as_ref().map(|s| s.scopes.clone()),
|
||||||
|
"keys": session.as_ref().map(|_| key_state_name()),
|
||||||
"mount": mount,
|
"mount": mount,
|
||||||
});
|
});
|
||||||
println!("{}", serde_json::to_string_pretty(&out)?);
|
println!("{}", serde_json::to_string_pretty(&out)?);
|
||||||
@@ -1858,6 +2014,11 @@ fn cmd_status() -> Result<()> {
|
|||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
println!("Logged in as {} (user {})", s.username, s.user_id);
|
println!("Logged in as {} (user {})", s.username, s.user_id);
|
||||||
println!("Scopes: {}", s.scopes.join(", "));
|
println!("Scopes: {}", s.scopes.join(", "));
|
||||||
|
match key_state_name() {
|
||||||
|
"locked" => println!("Keys: locked — run `pdfs unlock`"),
|
||||||
|
"session" => println!("Keys: unlocked for this login session"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(pdfs_core::Error::NotLoggedIn) => {
|
Err(pdfs_core::Error::NotLoggedIn) => {
|
||||||
println!("Not logged in. Run `pdfs login`.");
|
println!("Not logged in. Run `pdfs login`.");
|
||||||
@@ -1880,9 +2041,13 @@ fn cmd_status() -> Result<()> {
|
|||||||
staged_oldest_secs,
|
staged_oldest_secs,
|
||||||
paused,
|
paused,
|
||||||
paused_until,
|
paused_until,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
let state = match (online, paused) {
|
let state = match (online, paused) {
|
||||||
|
_ if session_expired => ", session expired: run `pdfs login`",
|
||||||
(_, true) => ", sync paused",
|
(_, true) => ", sync paused",
|
||||||
(false, false) => ", offline",
|
(false, false) => ", offline",
|
||||||
(true, false) => "",
|
(true, false) => "",
|
||||||
@@ -1915,6 +2080,14 @@ fn cmd_status() -> Result<()> {
|
|||||||
failing_error.as_deref().unwrap_or("no error recorded"),
|
failing_error.as_deref().unwrap_or("no error recorded"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if tokens_unsaved {
|
||||||
|
println!(
|
||||||
|
"Keyring refuses the refreshed sign-in; unlock it, or a reboot needs `pdfs login`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if daemon_outdated(&version) {
|
||||||
|
println!("Daemon {}", outdated_daemon_note(&version));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||||
Err(_) => println!("Mount: not running."),
|
Err(_) => println!("Mount: not running."),
|
||||||
@@ -1922,6 +2095,20 @@ fn cmd_status() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `pdfs status` and `pdfs diagnose` say about a daemon still running
|
||||||
|
/// an older release than this CLI.
|
||||||
|
fn outdated_daemon_note(version: &str) -> String {
|
||||||
|
let running = if version.is_empty() {
|
||||||
|
"an older release".to_string()
|
||||||
|
} else {
|
||||||
|
version.to_string()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"still runs {running}, this is {}; run `systemctl --user restart proton-drive` to finish the update",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_locations() -> Result<()> {
|
fn cmd_locations() -> Result<()> {
|
||||||
let response = control_request(CtlRequest::ListLocations)?;
|
let response = control_request(CtlRequest::ListLocations)?;
|
||||||
if emit_json(&response)? {
|
if emit_json(&response)? {
|
||||||
@@ -1989,7 +2176,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration {
|
|||||||
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Block until a session is stored, backing off between checks.
|
/// Block until a session is stored and unlocked, backing off between checks.
|
||||||
///
|
///
|
||||||
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
/// `load` and `sleep` are parameters rather than direct calls so the backoff
|
||||||
/// schedule can be tested without a keyring and without real time passing.
|
/// schedule can be tested without a keyring and without real time passing.
|
||||||
@@ -2014,6 +2201,16 @@ where
|
|||||||
sleep(delay);
|
sleep(delay);
|
||||||
attempts = attempts.saturating_add(1);
|
attempts = attempts.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
Err(pdfs_core::Error::Locked) => {
|
||||||
|
let delay = login_poll_delay(attempts);
|
||||||
|
if attempts == 0 {
|
||||||
|
tracing::info!("account is locked; waiting for `pdfs unlock`…");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(delay_secs = delay.as_secs(), "account still locked");
|
||||||
|
}
|
||||||
|
sleep(delay);
|
||||||
|
attempts = attempts.saturating_add(1);
|
||||||
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2024,25 +2221,31 @@ where
|
|||||||
/// an external unmount triggers a remount; errors back off and retry. This is
|
/// an external unmount triggers a remount; errors back off and retry. This is
|
||||||
/// the entry point for the systemd user service.
|
/// the entry point for the systemd user service.
|
||||||
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_daemon(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
|
// The daemon holds the account's key passphrases in memory; keep them out
|
||||||
|
// of core dumps and away from same-user ptrace.
|
||||||
|
pdfs_core::kernelkey::forbid_dumps();
|
||||||
loop {
|
loop {
|
||||||
// Wait until a session is stored. The GUI enables this service on login,
|
// Wait until a session is stored. The GUI enables this service on login,
|
||||||
// but the service may also start at boot before the user has logged in.
|
// but the service may also start at boot before the user has logged in.
|
||||||
wait_for_session(|| auth::load().map(|_| ()), std::thread::sleep)?;
|
wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?;
|
||||||
|
|
||||||
match mount_once(mountpoint.clone()) {
|
match mount_once(mountpoint.clone()) {
|
||||||
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
Ok(pdfs_fuse::MountOutcome::Shutdown) => {
|
||||||
tracing::info!("daemon stopping");
|
tracing::info!("daemon stopping");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Ok(pdfs_fuse::MountOutcome::ResumeStored) => {
|
||||||
|
tracing::info!("resuming the session stored in the keyring");
|
||||||
|
}
|
||||||
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
Ok(pdfs_fuse::MountOutcome::Unmounted) => {
|
||||||
tracing::warn!("mount ended externally; remounting in 2s");
|
tracing::warn!("mount ended externally; remounting in 2s");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// `{:#}` so the whole anyhow chain lands in the log — the top
|
// `{:#}` so the whole anyhow chain lands in the log — the top
|
||||||
// context alone ("mount failed") never says *why*.
|
// context alone ("mount failed") never says *why*.
|
||||||
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s");
|
||||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2104,9 +2307,14 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean unmount check (as a best-effort final backup).
|
// Last chance for a rotation the keyring refused when it happened. Only
|
||||||
if let Err(e) = rt.block_on(auth::persist(&session)) {
|
// that: by now the keyring may hold a newer login, the one this stop was
|
||||||
tracing::debug!(error = %e, "no new tokens to persist on shutdown");
|
// restarted for, and the tokens of this session would overwrite it.
|
||||||
|
if let Err(e) = auth::persist(&session) {
|
||||||
|
tracing::error!(
|
||||||
|
error = %e,
|
||||||
|
"could not save the refreshed session tokens; the next start may need `pdfs login`"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Dropping the runtime waits for every blocking task, however long; a call
|
// Dropping the runtime waits for every blocking task, however long; a call
|
||||||
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
// still stuck on Drive would hold the stop until systemd kills it (B165).
|
||||||
@@ -3203,12 +3411,25 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let db_path = dirs.db_path();
|
let db_path = dirs.db_path();
|
||||||
|
if db_path.exists() {
|
||||||
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
|
||||||
report.check(
|
report.check(
|
||||||
" database",
|
" database",
|
||||||
db_path.is_file(),
|
db_path.is_file(),
|
||||||
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
format!("{} ({})", db_path.display(), human_bytes(db_size)),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// The daemon creates it on its first start, which waits for a sign-in,
|
||||||
|
// so a fresh install has none yet (issue #39 read this as the fault).
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" database",
|
||||||
|
format!(
|
||||||
|
"{} not created yet (the daemon creates it on first start)",
|
||||||
|
db_path.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
println!("\nFUSE");
|
println!("\nFUSE");
|
||||||
let fuse = Path::new("/dev/fuse");
|
let fuse = Path::new("/dev/fuse");
|
||||||
@@ -3224,6 +3445,19 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
match pdfs_core::auth::load() {
|
match pdfs_core::auth::load() {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
report.finding(DiagnoseLevel::Ok, " keyring session", session.username);
|
||||||
|
match key_state_name() {
|
||||||
|
"locked" => report.finding(
|
||||||
|
DiagnoseLevel::Fail,
|
||||||
|
" account keys",
|
||||||
|
"locked — run `pdfs unlock` (the daemon waits for it)",
|
||||||
|
),
|
||||||
|
"session" => report.finding(
|
||||||
|
DiagnoseLevel::Ok,
|
||||||
|
" account keys",
|
||||||
|
"unlocked for this login session",
|
||||||
|
),
|
||||||
|
_ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// A locked keyring and an absent login look the same from here, so
|
// A locked keyring and an absent login look the same from here, so
|
||||||
@@ -3254,11 +3488,21 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
Ok(CtlResponse::Status {
|
Ok(CtlResponse::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
online,
|
online,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
pending_changes,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
report.finding(DiagnoseLevel::Ok, " daemon responding", "");
|
||||||
|
if daemon_outdated(&version) {
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" daemon version",
|
||||||
|
outdated_daemon_note(&version),
|
||||||
|
);
|
||||||
|
}
|
||||||
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
let live_type = mounted_fs_type(Path::new(&mountpoint));
|
||||||
let is_fuse = live_type
|
let is_fuse = live_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -3275,6 +3519,24 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
|kind| format!("{mountpoint} ({kind})"),
|
|kind| format!("{mountpoint} ({kind})"),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// The keyring check above read the blob, which still holds the
|
||||||
|
// tokens the daemon could not replace.
|
||||||
|
if tokens_unsaved {
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Warn,
|
||||||
|
" session tokens",
|
||||||
|
"the keyring refuses the refreshed ones; unlock it, or a reboot needs `pdfs login`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A refused session reads as offline too, but no connection will
|
||||||
|
// bring it back, and the keyring check above cannot tell.
|
||||||
|
if session_expired {
|
||||||
|
report.finding(
|
||||||
|
DiagnoseLevel::Fail,
|
||||||
|
" session",
|
||||||
|
"Proton refused it (expired or revoked) — run `pdfs login`",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
report.finding(
|
report.finding(
|
||||||
if online {
|
if online {
|
||||||
DiagnoseLevel::Ok
|
DiagnoseLevel::Ok
|
||||||
@@ -3288,6 +3550,7 @@ fn cmd_diagnose() -> Result<()> {
|
|||||||
"offline; cached data remains available"
|
"offline; cached data remains available"
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
let pending = pending_uploads + pending_changes;
|
let pending = pending_uploads + pending_changes;
|
||||||
report.finding(
|
report.finding(
|
||||||
if pending == 0 {
|
if pending == 0 {
|
||||||
@@ -3522,6 +3785,21 @@ mod login_wait_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_account_is_waited_on_until_it_is_unlocked() {
|
||||||
|
let mut checks = vec![
|
||||||
|
Ok(()),
|
||||||
|
Err(pdfs_core::Error::Locked),
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn),
|
||||||
|
Err(pdfs_core::Error::Locked),
|
||||||
|
];
|
||||||
|
let mut slept = 0;
|
||||||
|
|
||||||
|
wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(slept, 3, "locked and signed-out are both waited out");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stored_session_is_not_waited_for_at_all() {
|
fn a_stored_session_is_not_waited_for_at_all() {
|
||||||
let mut slept = 0;
|
let mut slept = 0;
|
||||||
@@ -3545,6 +3823,7 @@ mod login_wait_tests {
|
|||||||
size: 1,
|
size: 1,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
|||||||
@@ -28,3 +28,5 @@ 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
|
||||||
+1018
-120
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.
|
||||||
///
|
///
|
||||||
@@ -989,7 +988,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 +1490,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
|
||||||
@@ -2311,42 +2309,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 +2689,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 +2769,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 +2836,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 +2901,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]
|
||||||
|
|||||||
+110
-38
@@ -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.
|
||||||
@@ -157,7 +182,7 @@ 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>,
|
||||||
@@ -216,31 +241,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 {
|
||||||
@@ -432,7 +454,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
|
||||||
@@ -483,7 +505,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 +526,7 @@ 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
|
/// `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 +593,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,
|
||||||
@@ -596,8 +618,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
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);
|
||||||
@@ -628,8 +653,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]
|
||||||
|
|||||||
@@ -443,11 +443,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 },
|
||||||
@@ -496,7 +503,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.
|
||||||
@@ -1218,6 +1225,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 +1260,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.
|
||||||
@@ -1837,6 +1857,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 {
|
||||||
@@ -1903,17 +1944,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 +1987,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 },
|
||||||
@@ -2589,6 +2645,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]
|
||||||
@@ -3082,6 +3159,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -577,7 +577,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 +638,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 +666,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 +1056,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 +1168,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -311,7 +329,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
|
||||||
@@ -887,7 +905,7 @@ 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
|
||||||
@@ -1128,15 +1146,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 +1221,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),
|
||||||
|
|||||||
@@ -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,7 +39,7 @@ 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>,
|
||||||
@@ -139,6 +139,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<()> {
|
||||||
@@ -345,7 +356,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
|
||||||
@@ -1689,9 +1689,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();
|
||||||
|
|
||||||
|
// The fastest of a few runs: the rest of the suite runs alongside, and
|
||||||
|
// one run that lost the CPU to it read as the regression.
|
||||||
|
(0..5)
|
||||||
|
.map(|_| {
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
db.upsert_nodes(&batch).unwrap();
|
db.upsert_nodes(&batch).unwrap();
|
||||||
t.elapsed()
|
t.elapsed()
|
||||||
|
})
|
||||||
|
.min()
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
let small = cost_against_index_of(200);
|
let small = cost_against_index_of(200);
|
||||||
@@ -4940,6 +4947,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();
|
||||||
@@ -5735,7 +5764,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.
|
||||||
@@ -6291,7 +6320,7 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
|||||||
/// 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!(
|
||||||
@@ -6525,6 +6554,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]
|
||||||
|
|||||||
@@ -102,5 +102,5 @@ impl Db {
|
|||||||
Ok(seen)
|
Ok(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync -----------------------------------------
|
||||||
}
|
}
|
||||||
+102
-15
@@ -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,17 +154,17 @@ 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`]).
|
||||||
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>() {
|
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
|
||||||
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
Some(proton) => proton_kind(proton),
|
||||||
// A `ProtonError` that is not an `Api` variant never reached the
|
// Not the SDK's at all: a timeout around the call, or the socket.
|
||||||
// API; so does anything that is not a `ProtonError` at all.
|
None => ErrorKind::Offline,
|
||||||
_ => ErrorKind::Offline,
|
|
||||||
};
|
};
|
||||||
Self::new(kind, format!("{what}: {e}"))
|
Self::new(kind, format!("{what}: {e}"))
|
||||||
}
|
}
|
||||||
@@ -199,18 +209,38 @@ 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 {
|
let kind = match &e {
|
||||||
Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => {
|
Error::NotLoggedIn
|
||||||
ErrorKind::Denied
|
| Error::ReloginRequired
|
||||||
}
|
| Error::Locked
|
||||||
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
|
| Error::WrongMailboxPassword
|
||||||
// A `ProtonError` that never reached the API is a transport failure.
|
| Error::HumanVerificationRequired(_) => ErrorKind::Denied,
|
||||||
Error::Proton(_) => ErrorKind::Offline,
|
Error::Proton(proton) => proton_kind(proton),
|
||||||
_ => ErrorKind::Internal,
|
_ => ErrorKind::Internal,
|
||||||
};
|
};
|
||||||
Self::new(kind, e.to_string())
|
Self::new(kind, e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What an API response code means to a front-end.
|
/// What an API response code means to a front-end.
|
||||||
///
|
///
|
||||||
/// The grouping is by *what the user can do about it*, which is what
|
/// The grouping is by *what the user can do about it*, which is what
|
||||||
@@ -266,6 +296,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 +353,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 +402,18 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// `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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod config;
|
|||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod kernelkey;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +721,7 @@ 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 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) {
|
||||||
|
|||||||
@@ -334,6 +334,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,7 +372,7 @@ 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 { .. }
|
||||||
@@ -938,6 +941,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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1297,6 +1303,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
|
||||||
@@ -1816,7 +1826,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;
|
||||||
|
|||||||
+192
-23
@@ -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};
|
||||||
@@ -114,6 +115,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 +157,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 +201,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 +214,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 +257,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 +277,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 +388,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 +454,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 +478,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
|
||||||
@@ -536,12 +657,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 +742,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 +968,23 @@ 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:?}")))?;
|
||||||
|
if let Some(reason) =
|
||||||
|
sync::mirror_tree_unsynced(&local, &baseline, &self.ignore_rules(&local))
|
||||||
|
{
|
||||||
|
return Err(SwitchBlocked::Failed(format!(
|
||||||
|
"going online-only would delete what is only on this computer: {reason}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
let root_uid = parse_uid(&folder.remote_uid).ok_or_else(|| {
|
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))
|
||||||
})?;
|
})?;
|
||||||
@@ -986,8 +1128,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 +1250,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -733,14 +722,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,7 +1141,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,
|
||||||
@@ -1850,7 +1841,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
|
||||||
@@ -2009,7 +2000,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);
|
||||||
@@ -2679,7 +2670,9 @@ fn proton_error<'e>(e: &'e (dyn std::error::Error + 'static)) -> Option<&'e Prot
|
|||||||
/// is, or `Some(None)` for a failure that is not a refusal.
|
/// 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 {
|
||||||
@@ -2834,6 +2827,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 +2869,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,
|
||||||
|
|||||||
@@ -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,
|
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`].
|
||||||
@@ -154,6 +157,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
|
||||||
@@ -470,4 +479,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -346,7 +346,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,
|
||||||
@@ -517,7 +517,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
|
||||||
@@ -607,7 +607,7 @@ impl Filesystem for ProtonFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `close(2)` calls flush before release. The upload is queued in `release`
|
/// `close(2)` calls flush before release. The upload is queued in `release`
|
||||||
/// and performed in the background (offline.md Phase 3), so there is nothing
|
/// and performed in the background, so there is nothing
|
||||||
/// to push here — the written bytes are already in the scratch file.
|
/// to push here — the written bytes are already in the scratch file.
|
||||||
fn flush(
|
fn flush(
|
||||||
&self,
|
&self,
|
||||||
@@ -985,7 +985,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
|
||||||
@@ -1021,7 +1021,7 @@ 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).
|
||||||
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 +1094,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 +1267,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 +1280,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 +1290,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 +1306,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) => {
|
||||||
@@ -1325,6 +1325,25 @@ impl ProtonFs {
|
|||||||
"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
|
||||||
@@ -1541,7 +1560,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 +1671,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) {
|
||||||
@@ -1874,7 +1893,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 +1975,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 +2174,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,7 +2203,7 @@ 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) {
|
||||||
Ok(()) => reply.ok(),
|
Ok(()) => reply.ok(),
|
||||||
@@ -2234,7 +2252,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();
|
||||||
|
|||||||
+151
-70
@@ -123,6 +123,7 @@ use mount::{
|
|||||||
};
|
};
|
||||||
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN};
|
||||||
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle};
|
||||||
|
pub use systemd::idle_sleep;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry};
|
||||||
use r#virtual::{
|
use r#virtual::{
|
||||||
@@ -226,7 +227,7 @@ const DRAIN_WORKERS: usize = 16;
|
|||||||
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||||
|
|
||||||
/// First and longest delay between probes for the network coming back after an
|
/// First and longest delay between probes for the network coming back after an
|
||||||
/// offline mount (offline.md Phase 1). Doubles from min to max: a laptop shut in
|
/// offline mount. Doubles from min to max: a laptop shut in
|
||||||
/// a bag is the common case, so the steady state must be cheap, while a brief
|
/// a bag is the common case, so the steady state must be cheap, while a brief
|
||||||
/// blip should still recover in seconds.
|
/// blip should still recover in seconds.
|
||||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||||
@@ -334,7 +335,7 @@ const MAX_THUMBNAIL_MISSES: usize = 8192;
|
|||||||
///
|
///
|
||||||
/// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request:
|
/// Matches the SDK's own `MAX_BATCH_COUNT`, so a chunk is exactly one request:
|
||||||
/// chunking smaller would add round trips, larger would be split anyway and
|
/// chunking smaller would add round trips, larger would be split anyway and
|
||||||
/// delay the waiters this chunking exists to release (bugs.md B14).
|
/// delay the waiters this chunking exists to release (`docs/BUGS.md` B14).
|
||||||
const SIZE_UPGRADE_CHUNK: usize = 150;
|
const SIZE_UPGRADE_CHUNK: usize = 150;
|
||||||
|
|
||||||
/// How many folders may have a size upgrade in flight at once.
|
/// How many folders may have a size upgrade in flight at once.
|
||||||
@@ -344,7 +345,7 @@ const SIZE_UPGRADE_CHUNK: usize = 150;
|
|||||||
/// both with the number of folders it walked. Eight keeps an interactive `ls -l`
|
/// both with the number of folders it walked. Eight keeps an interactive `ls -l`
|
||||||
/// (one folder, sometimes a couple) entirely unaffected while putting a ceiling
|
/// (one folder, sometimes a couple) entirely unaffected while putting a ceiling
|
||||||
/// on the recursive case. A folder past the cap is queued, not dropped: one of
|
/// on the recursive case. A folder past the cap is queued, not dropped: one of
|
||||||
/// the running threads takes it over when its own batch ends (bugs.md B100).
|
/// the running threads takes it over when its own batch ends (`docs/BUGS.md` B100).
|
||||||
const MAX_SIZE_UPGRADES: usize = 8;
|
const MAX_SIZE_UPGRADES: usize = 8;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -535,7 +536,7 @@ struct Core {
|
|||||||
workers: Arc<Workers>,
|
workers: Arc<Workers>,
|
||||||
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
|
/// Unified SQLite metadata cache: the persistence layer behind the in-memory
|
||||||
/// `State` maps. Every mutation writes through here, and the maps rehydrate
|
/// `State` maps. Every mutation writes through here, and the maps rehydrate
|
||||||
/// from it on mount (plan.md P1).
|
/// from it on mount.
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
/// Serializes only shared-list publication and access-loss invalidation.
|
/// Serializes only shared-list publication and access-loss invalidation.
|
||||||
/// Network calls happen before taking it.
|
/// Network calls happen before taking it.
|
||||||
@@ -545,8 +546,8 @@ struct Core {
|
|||||||
/// Successful refreshes suppress retries even if persisting their timestamp
|
/// Successful refreshes suppress retries even if persisting their timestamp
|
||||||
/// fails. Shared across primary/fork clones and cleared by access events.
|
/// fails. Shared across primary/fork clones and cleared by access events.
|
||||||
shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>,
|
shared_refresh_deadlines: Arc<Mutex<SharedRefreshDeadlines>>,
|
||||||
/// False while the API is unreachable and we are serving the cached tree
|
/// False while the API is unreachable and we are serving the cached tree.
|
||||||
/// (offline.md Phase 1). Set by the probe thread; read by front-ends through
|
/// Set by the probe thread; read by front-ends through
|
||||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||||
/// infer it from a wall of EIO.
|
/// infer it from a wall of EIO.
|
||||||
online: Arc<AtomicBool>,
|
online: Arc<AtomicBool>,
|
||||||
@@ -557,8 +558,8 @@ struct Core {
|
|||||||
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||||
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||||
sync_paused_until: Arc<AtomicI64>,
|
sync_paused_until: Arc<AtomicI64>,
|
||||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
/// Writes accepted from the kernel but not yet uploaded, keyed by node.
|
||||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
/// The in-memory face of the `pending_op` table, from
|
||||||
/// which it is rebuilt on mount.
|
/// which it is rebuilt on mount.
|
||||||
///
|
///
|
||||||
/// Two things read it: [`Core::read_range`], because until the op drains the
|
/// Two things read it: [`Core::read_range`], because until the op drains the
|
||||||
@@ -586,7 +587,7 @@ struct Core {
|
|||||||
/// the queue instead of waiting out their backoff.
|
/// the queue instead of waiting out their backoff.
|
||||||
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
drain_wake: Arc<(Mutex<bool>, Condvar)>,
|
||||||
/// The daemon's stop signal, shared with every background loop this `Core`
|
/// The daemon's stop signal, shared with every background loop this `Core`
|
||||||
/// was cloned into so teardown can end and join them (bugs.md B44).
|
/// was cloned into so teardown can end and join them (`docs/BUGS.md` B44).
|
||||||
shutdown: Arc<shutdown::Shutdown>,
|
shutdown: Arc<shutdown::Shutdown>,
|
||||||
/// How long this node's last upload actually took, in milliseconds, with
|
/// How long this node's last upload actually took, in milliseconds, with
|
||||||
/// the time it was measured for eviction. Feeds
|
/// the time it was measured for eviction. Feeds
|
||||||
@@ -695,7 +696,7 @@ struct Core {
|
|||||||
/// Size upgrades currently running, per folder inode, across every mount. A `getattr` that
|
/// Size upgrades currently running, per folder inode, across every mount. A `getattr` that
|
||||||
/// needs a real size waits on the entry rather than issuing its own fetch —
|
/// needs a real size waits on the entry rather than issuing its own fetch —
|
||||||
/// `ls -l` of a folder is one `getattr` per file, and they must collapse
|
/// `ls -l` of a folder is one `getattr` per file, and they must collapse
|
||||||
/// onto a single batch (bugs.md B14).
|
/// onto a single batch (`docs/BUGS.md` B14).
|
||||||
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
size_upgrades: Arc<Mutex<HashMap<u64, Arc<SizeUpgrade>>>>,
|
||||||
/// Replies parked on those upgrades. Shared across every mount for the same
|
/// Replies parked on those upgrades. Shared across every mount for the same
|
||||||
/// reason the pool is: the bound that matters is per daemon.
|
/// reason the pool is: the bound that matters is per daemon.
|
||||||
@@ -726,11 +727,11 @@ struct Core {
|
|||||||
/// can say what a pass is doing rather than just "syncing". An entry exists
|
/// can say what a pass is doing rather than just "syncing". An entry exists
|
||||||
/// only while that folder's reconcile pass is running.
|
/// only while that folder's reconcile pass is running.
|
||||||
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
sync_progress: Arc<Mutex<HashMap<i64, SyncProgress>>>,
|
||||||
/// Channel to the folder-sync engine (devices.md Phase 2): nudges it to
|
/// Channel to the folder-sync engine: nudges it to
|
||||||
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
/// reconcile a folder, reconcile everything, or re-scan its watch set.
|
||||||
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
sync_tx: std::sync::mpsc::Sender<sync::SyncMsg>,
|
||||||
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
|
/// Secondary FUSE sessions for `ondemand` sync folders, keyed by sync-folder
|
||||||
/// id (devices.md Phase 3). Each is a `ProtonFs` rooted at the folder's remote
|
/// id. Each is a `ProtonFs` rooted at the folder's remote
|
||||||
/// node, mounted over its local path, sharing this Core's client/cache/db and
|
/// node, mounted over its local path, sharing this Core's client/cache/db and
|
||||||
/// tree but with its own root (`fork_state`). Held so we can unmount on
|
/// tree but with its own root (`fork_state`). Held so we can unmount on
|
||||||
/// toggle back to `mirror` and on daemon shutdown. Each entry retains the
|
/// toggle back to `mirror` and on daemon shutdown. Each entry retains the
|
||||||
@@ -743,6 +744,9 @@ struct Core {
|
|||||||
/// uploading that same tree — the engine would upload files as they vanish
|
/// uploading that same tree — the engine would upload files as they vanish
|
||||||
/// and then walk the FUSE mount as if it were local.
|
/// and then walk the FUSE mount as if it were local.
|
||||||
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
sync_locks: Arc<Mutex<HashMap<i64, Arc<Mutex<()>>>>>,
|
||||||
|
/// Per-sync-folder paths the last pass could not read, so the activity feed
|
||||||
|
/// says so when the set changes rather than on every poll (B196).
|
||||||
|
sync_unscanned: Arc<Mutex<HashMap<i64, Vec<String>>>>,
|
||||||
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
/// Nodes this daemon changed on the remote itself, and how many echoes of
|
||||||
/// those changes the event feed still owes us.
|
/// those changes the event feed still owes us.
|
||||||
///
|
///
|
||||||
@@ -1030,7 +1034,9 @@ impl Core {
|
|||||||
|
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let job = core.transfers.begin_job("Checking the cache database");
|
let job = core
|
||||||
|
.transfers
|
||||||
|
.begin_background_job("Checking the cache database");
|
||||||
job.detail("Verifying every page");
|
job.detail("Verifying every page");
|
||||||
let problems = match core.db.integrity_check() {
|
let problems = match core.db.integrity_check() {
|
||||||
Ok(problems) => problems,
|
Ok(problems) => problems,
|
||||||
@@ -1833,8 +1839,7 @@ impl Core {
|
|||||||
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
|
info!(nodes = st.entries.len(), "hydrated metadata cache from db");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the in-memory pending map from the `pending_op` table on mount
|
/// Rebuild the in-memory pending map from the `pending_op` table on mount.
|
||||||
/// (offline.md Phase 3).
|
|
||||||
///
|
///
|
||||||
/// A queued write survives a restart — that is the point of persisting it —
|
/// A queued write survives a restart — that is the point of persisting it —
|
||||||
/// so until the drain worker gets to it, reads of that file must still come
|
/// so until the drain worker gets to it, reads of that file must still come
|
||||||
@@ -2774,7 +2779,7 @@ impl Core {
|
|||||||
missing.push(uid.clone());
|
missing.push(uid.clone());
|
||||||
}
|
}
|
||||||
// Returned, not awaited here: the caller is a `getattr` that must not
|
// Returned, not awaited here: the caller is a `getattr` that must not
|
||||||
// answer with a provisional size (bugs.md B14), and it parks its reply
|
// answer with a provisional size (`docs/BUGS.md` B14), and it parks its reply
|
||||||
// on this batch rather than holding a thread until it lands.
|
// on this batch rather than holding a thread until it lands.
|
||||||
self.upgrade_sizes(key, missing)
|
self.upgrade_sizes(key, missing)
|
||||||
}
|
}
|
||||||
@@ -2791,7 +2796,7 @@ impl Core {
|
|||||||
/// `total_size_on_storage`, the *ciphertext* size, which is slightly larger
|
/// `total_size_on_storage`, the *ciphertext* size, which is slightly larger
|
||||||
/// than the real one. A read must not plan on it: since B84 a size the
|
/// than the real one. A read must not plan on it: since B84 a size the
|
||||||
/// revision disagrees with fails the read, so `read` settles the size first
|
/// revision disagrees with fails the read, so `read` settles the size first
|
||||||
/// ([`Core::resolve_provisional_size`], bugs.md B100). In `stat` it is a
|
/// ([`Core::resolve_provisional_size`], `docs/BUGS.md` B100). In `stat` it is a
|
||||||
/// discrepancy that closes within a round trip, not a repeat of B11 (which
|
/// discrepancy that closes within a round trip, not a repeat of B11 (which
|
||||||
/// reported **0** and so suppressed reads entirely).
|
/// reported **0** and so suppressed reads entirely).
|
||||||
///
|
///
|
||||||
@@ -2865,11 +2870,11 @@ impl Core {
|
|||||||
/// Chunked so a waiter is released as soon as *its* file is resolved. A
|
/// Chunked so a waiter is released as soon as *its* file is resolved. A
|
||||||
/// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and
|
/// single 793-node batch took ~80 s, which outran [`SizeUpgrade::WAIT`] and
|
||||||
/// put provisional sizes back in front of callers — the bug this was
|
/// put provisional sizes back in front of callers — the bug this was
|
||||||
/// supposed to fix (bugs.md B14).
|
/// supposed to fix (`docs/BUGS.md` B14).
|
||||||
///
|
///
|
||||||
/// When the batch ends, the thread takes over the oldest batch the cap held
|
/// When the batch ends, the thread takes over the oldest batch the cap held
|
||||||
/// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders
|
/// back, so at most [`MAX_SIZE_UPGRADES`] threads run however many folders
|
||||||
/// a recursive walk touches (bugs.md B100).
|
/// a recursive walk touches (`docs/BUGS.md` B100).
|
||||||
fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) {
|
fn run_size_upgrade(&self, key: u64, uids: Vec<NodeUid>, slot: Arc<SizeUpgrade>) {
|
||||||
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
let (mut key, mut uids, mut slot) = (key, uids, slot);
|
||||||
loop {
|
loop {
|
||||||
@@ -2897,7 +2902,7 @@ impl Core {
|
|||||||
/// size it is given against the revision's own block table and fails the
|
/// size it is given against the revision's own block table and fails the
|
||||||
/// read on a mismatch (B84), so reading on the provisional size is an `EIO`
|
/// read on a mismatch (B84), so reading on the provisional size is an `EIO`
|
||||||
/// every time. `getattr` usually settles the size first, but not when its
|
/// every time. `getattr` usually settles the size first, but not when its
|
||||||
/// wait times out, so the read cannot rely on it (bugs.md B100).
|
/// wait times out, so the read cannot rely on it (`docs/BUGS.md` B100).
|
||||||
fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> {
|
fn resolve_provisional_size(&self, ino: u64, uid: &NodeUid) -> Option<u64> {
|
||||||
let parent = self.state().entries.get(&ino)?.parent;
|
let parent = self.state().entries.get(&ino)?.parent;
|
||||||
// Without a network the answer is `None` either way; the bound keeps a
|
// Without a network the answer is `None` either way; the bound keeps a
|
||||||
@@ -3317,8 +3322,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a released write handle's bytes and queue their upload
|
/// Accept a released write handle's bytes and queue their upload.
|
||||||
/// (offline.md Phase 3).
|
|
||||||
///
|
///
|
||||||
/// This is what makes a copy into the mount run at disk speed: the caller's
|
/// This is what makes a copy into the mount run at disk speed: the caller's
|
||||||
/// `close` returns once the bytes are staged on local disk and the intent is
|
/// `close` returns once the bytes are staged on local disk and the intent is
|
||||||
@@ -3785,8 +3789,8 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invent a node under `parent_uid` and queue the op that will make it real
|
/// Invent a node under `parent_uid` and queue the op that will make it real.
|
||||||
/// (offline.md Phase 3b). Returns the node to intern, exactly as the online
|
/// Returns the node to intern, exactly as the online
|
||||||
/// path returns the one the server minted.
|
/// path returns the one the server minted.
|
||||||
///
|
///
|
||||||
/// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of
|
/// The parent may itself be a placeholder — `mkdir -p` offline, or `cp -r` of
|
||||||
@@ -3842,7 +3846,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Queue giving a node a new parent and/or name, and apply it to the tree
|
/// Queue giving a node a new parent and/or name, and apply it to the tree
|
||||||
/// now (offline.md Phase 3b).
|
/// now.
|
||||||
///
|
///
|
||||||
/// The op records the desired end state rather than the step, so it both
|
/// The op records the desired end state rather than the step, so it both
|
||||||
/// supersedes any earlier queued rename and lets the drain skip whichever
|
/// supersedes any earlier queued rename and lets the drain skip whichever
|
||||||
@@ -3950,7 +3954,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Queue trashing a node the server knows about, and drop it from the tree
|
/// Queue trashing a node the server knows about, and drop it from the tree
|
||||||
/// now (offline.md Phase 3b).
|
/// now.
|
||||||
///
|
///
|
||||||
/// Anything else queued for this node is discarded first: the user has said
|
/// Anything else queued for this node is discarded first: the user has said
|
||||||
/// the file should not exist, so uploading bytes to it or renaming it are
|
/// the file should not exist, so uploading bytes to it or renaming it are
|
||||||
@@ -4081,7 +4085,7 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// The path identifies the revision: publication stages a fresh file and
|
/// The path identifies the revision: publication stages a fresh file and
|
||||||
/// swaps the map entry to point at it, so an unchanged path means the base a
|
/// swaps the map entry to point at it, so an unchanged path means the base a
|
||||||
/// caller sampled earlier is still the base now (bugs.md B32).
|
/// caller sampled earlier is still the base now (`docs/BUGS.md` B32).
|
||||||
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
|
fn pending_blob(&self, uid: &NodeUid) -> Option<PathBuf> {
|
||||||
self.pending.lock().get(uid).map(|p| p.path.clone())
|
self.pending.lock().get(uid).map(|p| p.path.clone())
|
||||||
}
|
}
|
||||||
@@ -4223,8 +4227,10 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
/// Pin the node at mountpoint-relative `rel`. A file downloads its full
|
||||||
/// plaintext into the content cache; a folder records a recursive pin and
|
/// plaintext into the content cache before this returns. A folder records a
|
||||||
/// downloads every descendant file (selective sync). Returns a human message.
|
/// recursive pin and downloads every descendant file on a background thread,
|
||||||
|
/// reported as a job: a big folder far outlasts the socket read timeout.
|
||||||
|
/// Returns a human message.
|
||||||
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
fn pin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder, mtime, size) = {
|
let (name, is_folder, mtime, size) = {
|
||||||
@@ -4241,34 +4247,93 @@ impl Core {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
if is_folder {
|
if is_folder {
|
||||||
|
// Offline, every download would fail and the folder would only look
|
||||||
|
// kept. Refuse instead, as a file pin does.
|
||||||
|
if !self.is_online() {
|
||||||
|
return Err(CoreError::offline());
|
||||||
|
}
|
||||||
// Record the recursive pin first so every descendant is eviction-
|
// Record the recursive pin first so every descendant is eviction-
|
||||||
// exempt before we start filling the cache with the subtree.
|
// exempt before we start filling the cache with the subtree.
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, true)
|
.add_pin(&uid, rel, true)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
let n = self.pin_subtree(ino)?;
|
let core = self.clone();
|
||||||
return Ok(format!("{name} ({n} files)"));
|
let folder = name.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pin-folder".into())
|
||||||
|
.spawn(move || core.pin_subtree(ino, &uid, &folder))
|
||||||
|
.map_err(|e| CoreError::internal(format!("spawn pin: {e}")))?;
|
||||||
|
return Ok(format!("{name}: downloading its files in the background"));
|
||||||
}
|
}
|
||||||
let bytes = self
|
self.fetch_content(&uid, &name, mtime, size)?;
|
||||||
.download_file_tracked(&uid, &name, size)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "download"))?;
|
|
||||||
self.cache
|
|
||||||
.store(&uid, mtime, size, &bytes)
|
|
||||||
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?;
|
|
||||||
self.cache
|
self.cache
|
||||||
.add_pin(&uid, rel, false)
|
.add_pin(&uid, rel, false)
|
||||||
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
.map_err(|e| CoreError::from_api(&e, "pin"))?;
|
||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and cache every file in the subtree rooted at folder `ino`,
|
/// Download and cache every file in the subtree rooted at the pinned folder
|
||||||
/// returning the count cached (already-fresh blobs counted, not re-fetched).
|
/// `ino` (`folder`, named `name`), as a job the front ends show. Walks the
|
||||||
/// Walks the tree depth-first, enumerating each folder so a cold subtree is
|
/// tree depth-first, enumerating each folder so a cold subtree is fully
|
||||||
/// fully discovered; the lock is dropped before each network download.
|
/// discovered; the lock is dropped before each network download. Stops when
|
||||||
fn pin_subtree(&self, ino: u64) -> CoreResult<usize> {
|
/// the folder is unpinned or the daemon stops.
|
||||||
|
fn pin_subtree(&self, ino: u64, folder: &NodeUid, name: &str) {
|
||||||
|
let job = self
|
||||||
|
.transfers
|
||||||
|
.begin_job(format!("Making {name} available offline"));
|
||||||
|
match self.pin_subtree_files(ino) {
|
||||||
|
Ok(files) => {
|
||||||
|
job.set_total(files.len() as u64);
|
||||||
|
let mut failed = 0usize;
|
||||||
|
for (uid, file, mtime, size) in files {
|
||||||
|
if self.shutdown.is_stopping() || !self.cache.is_pinned(folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job.detail(file.clone());
|
||||||
|
if let Err(e) = self.fetch_pinned(&uid, &file, mtime, size) {
|
||||||
|
warn!(%uid, error = %e, "pin folder: download failed");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
info!(folder = %name, failed, "pin folder: done");
|
||||||
|
}
|
||||||
|
Err(e) => warn!(folder = %name, error = %e, "pin folder: enumerate failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache the file `uid` for a folder pin. Its `mtime` and `size` were read
|
||||||
|
/// before the download, and a create landing or a remote edit can change
|
||||||
|
/// them while it runs. The blob is then stored under the old ones and reads
|
||||||
|
/// as stale, so the file is listed as kept but not available offline. Fetch
|
||||||
|
/// it again under the new ones, a few times at most.
|
||||||
|
fn fetch_pinned(&self, uid: &NodeUid, name: &str, mtime: i64, size: u64) -> CoreResult<()> {
|
||||||
|
let mut fetched = (mtime, size);
|
||||||
|
for _ in 0..3 {
|
||||||
|
self.fetch_content(uid, name, fetched.0, fetched.1)?;
|
||||||
|
let current = {
|
||||||
|
let st = self.state();
|
||||||
|
st.by_uid
|
||||||
|
.get(uid)
|
||||||
|
.and_then(|ino| st.entries.get(ino))
|
||||||
|
.map(|e| (e.node.modification_time, node_size(&e.node)))
|
||||||
|
};
|
||||||
|
match current {
|
||||||
|
Some(now) if now != fetched => fetched = now,
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every file in the subtree rooted at folder `ino`.
|
||||||
|
fn pin_subtree_files(&self, ino: u64) -> CoreResult<Vec<(NodeUid, String, i64, u64)>> {
|
||||||
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new();
|
||||||
let mut stack = vec![ino];
|
let mut stack = vec![ino];
|
||||||
while let Some(dir) = stack.pop() {
|
while let Some(dir) = stack.pop() {
|
||||||
|
if self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
self.ensure_children(dir)
|
self.ensure_children(dir)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
let st = self.state();
|
let st = self.state();
|
||||||
@@ -4289,22 +4354,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut count = 0;
|
Ok(files)
|
||||||
for (uid, name, mtime, size) in files {
|
|
||||||
if self.cache.is_cached(&uid, mtime, size) {
|
|
||||||
count += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match self.download_file_tracked(&uid, &name, size) {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if self.cache.store(&uid, mtime, size, &bytes).is_ok() {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => warn!(%uid, error = %e, "pin subtree: download failed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
/// Fetch a thumbnail of `ttype` for the file at `ino`, served from the cache
|
||||||
@@ -4350,6 +4400,8 @@ impl Core {
|
|||||||
|
|
||||||
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
/// Unpin the node at `rel`, evicting its cached content. For a folder, also
|
||||||
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
/// evicts every descendant's cached blob (the subtree is no longer kept).
|
||||||
|
/// A node kept only because a folder above it is pinned is refused: there
|
||||||
|
/// is no pin of its own to remove, and it would stay kept all the same.
|
||||||
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
fn unpin(&self, rel: &Path) -> CoreResult<String> {
|
||||||
let (ino, uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
let (name, is_folder) = {
|
let (name, is_folder) = {
|
||||||
@@ -4359,14 +4411,24 @@ impl Core {
|
|||||||
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
.map(|e| (e.node.name.clone(), e.node.is_folder()))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
};
|
};
|
||||||
|
if self.pinned_by_folder(ino) {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{name} is kept offline because a folder above it is; unpin that folder instead"
|
||||||
|
)));
|
||||||
|
}
|
||||||
self.cache
|
self.cache
|
||||||
.remove_pin(&uid)
|
.remove_pin(&uid)
|
||||||
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
.map_err(|e| CoreError::from_api(&e, "unpin"))?;
|
||||||
// A recursively-pinned folder's descendants were eviction-exempt; now
|
// A recursively-pinned folder's descendants were eviction-exempt; now
|
||||||
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
// that the pin is gone, reclaim their blobs eagerly instead of waiting
|
||||||
// for budget pressure. Descendants come from the DB node tree.
|
// for budget pressure. Descendants come from the DB node tree. One
|
||||||
|
// pinned on its own, or under a pinned subfolder, is still kept.
|
||||||
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
if is_folder && let Ok(uids) = self.db.descendants(&uid.to_string()) {
|
||||||
|
let still_pinned = self.pinned_set();
|
||||||
for s in uids {
|
for s in uids {
|
||||||
|
if still_pinned.contains(&s) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(u) = parse_uid(&s) {
|
if let Some(u) = parse_uid(&s) {
|
||||||
self.cache.evict(&u);
|
self.cache.evict(&u);
|
||||||
self.evict_reader(&u);
|
self.evict_reader(&u);
|
||||||
@@ -4376,6 +4438,20 @@ impl Core {
|
|||||||
Ok(name)
|
Ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the node at `ino` is kept offline because a folder above it is
|
||||||
|
/// pinned, whether or not it is also pinned on its own.
|
||||||
|
fn pinned_by_folder(&self, ino: u64) -> bool {
|
||||||
|
let parent = {
|
||||||
|
let st = self.state();
|
||||||
|
st.entries
|
||||||
|
.get(&ino)
|
||||||
|
.filter(|e| e.parent != ino)
|
||||||
|
.and_then(|e| st.entries.get(&e.parent))
|
||||||
|
.map(|p| p.uid.clone())
|
||||||
|
};
|
||||||
|
parent.is_some_and(|uid| self.cache.is_pinned(&uid))
|
||||||
|
}
|
||||||
|
|
||||||
/// A Photos API handle sharing this Core's Drive client and session, so it
|
/// A Photos API handle sharing this Core's Drive client and session, so it
|
||||||
/// reuses the daemon's single authenticated session rather than logging in
|
/// reuses the daemon's single authenticated session rather than logging in
|
||||||
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
/// again (Proton refresh tokens are single-use). Cheap — the Drive client
|
||||||
@@ -4388,10 +4464,13 @@ impl Core {
|
|||||||
/// the same lazy remote enumeration `readdir` uses, projected into
|
/// the same lazy remote enumeration `readdir` uses, projected into
|
||||||
/// serializable [`DirEntry`]s (with per-file pin state).
|
/// serializable [`DirEntry`]s (with per-file pin state).
|
||||||
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
fn list_dir(&self, rel: &Path) -> CoreResult<Vec<DirEntry>> {
|
||||||
let (ino, _uid) = self.resolve(rel)?;
|
let (ino, uid) = self.resolve(rel)?;
|
||||||
self.ensure_children(ino)
|
self.ensure_children(ino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
self.settle_listing_sizes(ino);
|
self.settle_listing_sizes(ino);
|
||||||
|
// Every entry shares this folder as its parent, so one lookup says
|
||||||
|
// whether they are all kept through it.
|
||||||
|
let pinned_by_folder = self.cache.is_pinned(&uid);
|
||||||
// Snapshot the listing, then drop the lock before touching the on-disk
|
// Snapshot the listing, then drop the lock before touching the on-disk
|
||||||
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
// pin registry so a slow disk read doesn't block FUSE metadata ops.
|
||||||
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
|
||||||
@@ -4423,7 +4502,8 @@ impl Core {
|
|||||||
is_dir,
|
is_dir,
|
||||||
size,
|
size,
|
||||||
modified,
|
modified,
|
||||||
pinned: self.cache.is_pinned(&uid),
|
pinned: pinned_by_folder || self.cache.is_pinned(&uid),
|
||||||
|
pinned_by_folder,
|
||||||
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
cached: !is_dir && self.cache.is_cached(&uid, modified, size),
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
// Listing entries live in the requested dir; the caller derives
|
// Listing entries live in the requested dir; the caller derives
|
||||||
@@ -5124,7 +5204,7 @@ impl Core {
|
|||||||
/// `rename(2)` promises to replace an existing destination atomically. Proton
|
/// `rename(2)` promises to replace an existing destination atomically. Proton
|
||||||
/// offers no such primitive — `rename_node` refuses a name that is already
|
/// offers no such primitive — `rename_node` refuses a name that is already
|
||||||
/// taken — so this is the first half of an emulation that is *not* atomic:
|
/// taken — so this is the first half of an emulation that is *not* atomic:
|
||||||
/// see [`Core::restore_replaced`] for the other half (bugs.md B13).
|
/// see [`Core::restore_replaced`] for the other half (`docs/BUGS.md` B13).
|
||||||
///
|
///
|
||||||
/// A node whose own creation is still queued has never reached the server, so
|
/// A node whose own creation is still queued has never reached the server, so
|
||||||
/// dropping its queued ops is the whole removal; nothing goes to the wire and
|
/// dropping its queued ops is the whole removal; nothing goes to the wire and
|
||||||
@@ -5315,6 +5395,7 @@ impl Core {
|
|||||||
// A trashed node can't be pinned or served from the mount, so its
|
// A trashed node can't be pinned or served from the mount, so its
|
||||||
// content is never current cache: report neither.
|
// content is never current cache: report neither.
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
@@ -5950,8 +6031,8 @@ fn parse_node_uid(s: &str) -> Option<NodeUid> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The uid a node made on this machine goes by until it lands, so it can be
|
/// The uid a node made on this machine goes by until it lands, so it can be
|
||||||
/// interned, listed and written to before the server has ever heard of it
|
/// interned, listed and written to before the server has ever heard of it:
|
||||||
/// (offline.md Phase 3b): its row's local id, as [`pdfs_core::db::local_uid`]
|
/// its row's local id, as [`pdfs_core::db::local_uid`]
|
||||||
/// has it.
|
/// has it.
|
||||||
fn local_node_uid(lid: i64) -> NodeUid {
|
fn local_node_uid(lid: i64) -> NodeUid {
|
||||||
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
|
NodeUid::new(VolumeId::from(LOCAL_VOLUME), LinkId::from(lid.to_string()))
|
||||||
@@ -6114,7 +6195,7 @@ fn dir_is_empty(dir: &Path) -> std::io::Result<bool> {
|
|||||||
|
|
||||||
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
|
/// Delete everything inside `dir` but keep `dir` itself (it stays as the FUSE
|
||||||
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
|
/// mountpoint). Used when a `mirror` folder flips to `ondemand`: the local files
|
||||||
/// are the disk we're reclaiming (devices.md Phase 3).
|
/// are the disk we're reclaiming.
|
||||||
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
||||||
for entry in std::fs::read_dir(dir)? {
|
for entry in std::fs::read_dir(dir)? {
|
||||||
let path = entry?.path();
|
let path = entry?.path();
|
||||||
@@ -6196,7 +6277,7 @@ struct Progress {
|
|||||||
///
|
///
|
||||||
/// Waiters are released **per chunk**, not once at the end: a waiter only cares
|
/// Waiters are released **per chunk**, not once at the end: a waiter only cares
|
||||||
/// about its own file, and waiting for the other 792 is what let a large folder
|
/// about its own file, and waiting for the other 792 is what let a large folder
|
||||||
/// outrun the timeout (bugs.md B14).
|
/// outrun the timeout (`docs/BUGS.md` B14).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct SizeUpgrade {
|
struct SizeUpgrade {
|
||||||
inner: Mutex<Progress>,
|
inner: Mutex<Progress>,
|
||||||
@@ -6222,7 +6303,7 @@ enum SizeUpgradeClaim {
|
|||||||
/// Past the cap this used to return nothing, and the caller answered with the
|
/// Past the cap this used to return nothing, and the caller answered with the
|
||||||
/// provisional size at once. Nothing retried the folder until the next `stat`,
|
/// provisional size at once. Nothing retried the folder until the next `stat`,
|
||||||
/// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed
|
/// so a recursive walk over more than [`MAX_SIZE_UPGRADES`] fresh folders showed
|
||||||
/// ciphertext sizes for most of them (bugs.md B100). Queued, the batch is still
|
/// ciphertext sizes for most of them (`docs/BUGS.md` B100). Queued, the batch is still
|
||||||
/// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out.
|
/// fetched, and a waiter gets the real size unless [`SizeUpgrade::WAIT`] runs out.
|
||||||
fn claim_size_upgrade(
|
fn claim_size_upgrade(
|
||||||
in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>,
|
in_flight: &mut HashMap<u64, Arc<SizeUpgrade>>,
|
||||||
@@ -6425,7 +6506,7 @@ impl SizeWaitQueue {
|
|||||||
/// Split out from the handler because it is the part that is pure and the part
|
/// Split out from the handler because it is the part that is pure and the part
|
||||||
/// that is dangerous: every `Err` here is a refusal that happens *before*
|
/// that is dangerous: every `Err` here is a refusal that happens *before*
|
||||||
/// anything is trashed, and getting one wrong turns a refusal into the
|
/// anything is trashed, and getting one wrong turns a refusal into the
|
||||||
/// destruction of the destination (bugs.md B13).
|
/// destruction of the destination (`docs/BUGS.md` B13).
|
||||||
///
|
///
|
||||||
/// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise.
|
/// `dst_empty` is only meaningful when `dst_dir`; pass `true` otherwise.
|
||||||
fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> {
|
fn check_replaceable(src_dir: bool, dst_dir: bool, dst_empty: bool) -> Result<(), Errno> {
|
||||||
@@ -7155,7 +7236,7 @@ mod size_upgrade_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Past the cap a folder used to get no upgrade at all, so a recursive
|
/// Past the cap a folder used to get no upgrade at all, so a recursive
|
||||||
/// walk reported ciphertext sizes for most of what it listed (bugs.md B100).
|
/// walk reported ciphertext sizes for most of what it listed (`docs/BUGS.md` B100).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_folder_past_the_cap_is_queued_not_dropped() {
|
fn a_folder_past_the_cap_is_queued_not_dropped() {
|
||||||
let mut in_flight = HashMap::new();
|
let mut in_flight = HashMap::new();
|
||||||
@@ -7208,7 +7289,7 @@ mod replace_tests {
|
|||||||
|
|
||||||
/// The case that motivated all of this: rsync renaming its temp file over
|
/// The case that motivated all of this: rsync renaming its temp file over
|
||||||
/// the real one. Two plain files, and it has to be allowed — refusing is
|
/// the real one. Two plain files, and it has to be allowed — refusing is
|
||||||
/// what made every rsync transfer fail at the last step (bugs.md B13).
|
/// what made every rsync transfer fail at the last step (`docs/BUGS.md` B13).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_file_may_replace_a_file() {
|
fn a_file_may_replace_a_file() {
|
||||||
assert!(check_replaceable(false, false, true).is_ok());
|
assert!(check_replaceable(false, false, true).is_ok());
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -54,6 +55,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
|
||||||
@@ -161,10 +182,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 +203,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) {
|
||||||
@@ -248,7 +302,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 +330,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 +344,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 +369,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());
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -193,7 +197,7 @@ pub(super) fn clear_stale_mount(mountpoint: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `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 +214,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 +427,8 @@ 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)?;
|
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 +436,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 +464,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())),
|
||||||
@@ -475,6 +510,7 @@ pub(crate) fn mount_with(
|
|||||||
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,7 +537,7 @@ 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 core = core.clone();
|
||||||
@@ -718,6 +754,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 +786,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 +846,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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -246,7 +246,7 @@ impl Core {
|
|||||||
Profile::parse(&buf).map(Some).map_err(CoreError::invalid)
|
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
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,18 @@ 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()
|
||||||
|
.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.
|
||||||
|
|||||||
@@ -66,6 +66,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(),
|
||||||
@@ -660,6 +661,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 +685,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
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ impl Daemon {
|
|||||||
/// cache and the database the last run left.
|
/// cache and the database the last run left.
|
||||||
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
|
pub(crate) fn start(dir: &Path, client: FakeClient) -> io::Result<Self> {
|
||||||
log_to_test_output();
|
log_to_test_output();
|
||||||
|
isolate_from_the_developer();
|
||||||
let mountpoint = dir.join("mnt");
|
let mountpoint = dir.join("mnt");
|
||||||
let socket = dir.join("control.sock");
|
let socket = dir.join("control.sock");
|
||||||
std::fs::create_dir_all(&mountpoint)?;
|
std::fs::create_dir_all(&mountpoint)?;
|
||||||
@@ -138,6 +139,40 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write the new file `path` under the mount, and let the drain send its
|
||||||
|
/// create only once the create holds the bytes. Otherwise a drain worker
|
||||||
|
/// still busy with the op before can claim the create before the release
|
||||||
|
/// attaches them, which the kernel sends after `close` returns. The file
|
||||||
|
/// then goes out empty, and the bytes follow as a revision, past a hook on
|
||||||
|
/// the next create.
|
||||||
|
pub(crate) fn write_new(&self, path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(&self.mountpoint)
|
||||||
|
.map_err(io::Error::other)?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
self.request(&paused(true))?;
|
||||||
|
std::fs::write(path, bytes)?;
|
||||||
|
let attached = wait_until(Duration::from_secs(30), || {
|
||||||
|
self.pending().is_ok_and(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.kind == "create" && item.path == rel && item.exportable)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
self.request(&paused(false))?;
|
||||||
|
match attached {
|
||||||
|
true => Ok(()),
|
||||||
|
false => Err(io::Error::other(format!(
|
||||||
|
"{rel}: create never held the bytes"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the daemon and start it again on the same state and link.
|
/// Stop the daemon and start it again on the same state and link.
|
||||||
pub(crate) fn restart(mut self) -> io::Result<Self> {
|
pub(crate) fn restart(mut self) -> io::Result<Self> {
|
||||||
self.shut_down()?;
|
self.shut_down()?;
|
||||||
@@ -224,6 +259,33 @@ impl tracing::field::Visit for Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point the XDG directories of the whole test process at a scratch
|
||||||
|
/// directory, once, before the first daemon starts.
|
||||||
|
///
|
||||||
|
/// The daemon reads `config.json` through `AppDirs` in a few places (the
|
||||||
|
/// adopted device, the ignore rules, the profile), and those would otherwise
|
||||||
|
/// be the developer's own: an adopted device of their real account made every
|
||||||
|
/// sync folder fail to register against the fake Drive.
|
||||||
|
fn isolate_from_the_developer() {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
let home = scratch("xdg");
|
||||||
|
for (var, sub) in [
|
||||||
|
("XDG_CONFIG_HOME", "config"),
|
||||||
|
("XDG_STATE_HOME", "state"),
|
||||||
|
("XDG_CACHE_HOME", "cache"),
|
||||||
|
("XDG_DATA_HOME", "data"),
|
||||||
|
] {
|
||||||
|
let path = home.join(sub);
|
||||||
|
std::fs::create_dir_all(&path).expect("create a simulation XDG directory");
|
||||||
|
// SAFETY: the simulations run one at a time (`--test-threads=1`,
|
||||||
|
// docs/TESTING.md), and this runs before the first of them starts a
|
||||||
|
// daemon, so no other thread reads the environment meanwhile.
|
||||||
|
unsafe { std::env::set_var(var, &path) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
|
/// A client for what `DriveApi` does not cover (sharing, Photos, devices),
|
||||||
/// pointed at a closed local port so none of it leaves the machine.
|
/// pointed at a closed local port so none of it leaves the machine.
|
||||||
fn offline_client() -> ProtonDriveClient {
|
fn offline_client() -> ProtonDriveClient {
|
||||||
@@ -451,6 +513,36 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_partial_write_over_bytes_never_read_takes_another_write_at_once() {
|
||||||
|
// Nothing here ever read the file, so the release of a partial write
|
||||||
|
// had no cached base to fill its gaps from and queued an incomplete
|
||||||
|
// revision. The next write open on it failed with EIO (B204).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
drive.device().write("f.txt", b"0123456789");
|
||||||
|
let dir = scratch("partial-write-unread-base");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let path = daemon.mountpoint.join("f.txt");
|
||||||
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
|
write_at(&path, 2, b"ab").unwrap();
|
||||||
|
write_at(&path, 6, b"cd").unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.tree().get("f.txt").cloned() == file(b"01ab45cd89")
|
||||||
|
}),
|
||||||
|
"{:?} {:?}",
|
||||||
|
daemon.pending(),
|
||||||
|
drive.tree()
|
||||||
|
);
|
||||||
|
assert_eq!(drive.tree().len(), 1, "{:?}", drive.tree());
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
|
fn a_file_deleted_offline_while_open_still_reads_and_takes_writes() {
|
||||||
@@ -729,7 +821,7 @@ mod tests {
|
|||||||
let path = daemon.mountpoint.join("c.bin");
|
let path = daemon.mountpoint.join("c.bin");
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(&path, b"abc").unwrap();
|
daemon.write_new(&path, b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("c.bin").unwrap();
|
let uid = drive.lookup("c.bin").unwrap();
|
||||||
let landing = daemon.client.hold_next_landing(&uid);
|
let landing = daemon.client.hold_next_landing(&uid);
|
||||||
@@ -775,7 +867,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("a.txt").unwrap();
|
let uid = drive.lookup("a.txt").unwrap();
|
||||||
let landing = daemon.client.hold_next_landing(&uid);
|
let landing = daemon.client.hold_next_landing(&uid);
|
||||||
@@ -813,7 +905,7 @@ mod tests {
|
|||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
let mnt = daemon.mountpoint.clone();
|
let mnt = daemon.mountpoint.clone();
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("a.txt"), b"old").unwrap();
|
daemon.write_new(&mnt.join("a.txt"), b"old").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
|
std::fs::write(mnt.join("a.txt"), b"newer").unwrap();
|
||||||
let uid = drive.lookup("a.txt").unwrap();
|
let uid = drive.lookup("a.txt").unwrap();
|
||||||
@@ -996,7 +1088,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
|
std::fs::rename(mnt.join("x/c.bin"), mnt.join("y/d")).unwrap();
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
@@ -1074,7 +1166,7 @@ mod tests {
|
|||||||
take_logged();
|
take_logged();
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
// The read-back after the create, the one adopting it, and the size
|
// The read-back after the create, the one adopting it, and the size
|
||||||
@@ -1122,7 +1214,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
let uid = drive.lookup("x/c.bin").unwrap();
|
let uid = drive.lookup("x/c.bin").unwrap();
|
||||||
let read_back = daemon.client.hold_next_read(&uid);
|
let read_back = daemon.client.hold_next_read(&uid);
|
||||||
@@ -1165,7 +1257,7 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let create = daemon.client.hold_reply_to_next_create();
|
let create = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mnt.join("x/c.bin"), b"abc").unwrap();
|
daemon.write_new(&mnt.join("x/c.bin"), b"abc").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
// Long enough for the next event poll to bring the file's event, which
|
// Long enough for the next event poll to bring the file's event, which
|
||||||
// drops the folder's listing.
|
// drops the folder's listing.
|
||||||
@@ -1290,7 +1382,9 @@ mod tests {
|
|||||||
let folder = drive.lookup("x").unwrap();
|
let folder = drive.lookup("x").unwrap();
|
||||||
|
|
||||||
let create = daemon.client.hold_next_create();
|
let create = daemon.client.hold_next_create();
|
||||||
std::fs::write(mnt.join("x/removed-while-uploading"), b"abc").unwrap();
|
daemon
|
||||||
|
.write_new(&mnt.join("x/removed-while-uploading"), b"abc")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
assert!(wait_until(Duration::from_secs(30), || create.reached()));
|
||||||
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
|
std::fs::remove_file(mnt.join("x/removed-while-uploading")).unwrap();
|
||||||
std::fs::remove_dir(mnt.join("x")).unwrap();
|
std::fs::remove_dir(mnt.join("x")).unwrap();
|
||||||
@@ -1535,7 +1629,9 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"once").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"once")
|
||||||
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
wait_until(Duration::from_secs(30), || {
|
wait_until(Duration::from_secs(30), || {
|
||||||
daemon.pending().is_ok_and(|items| items.is_empty())
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
@@ -1565,7 +1661,7 @@ mod tests {
|
|||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"first").unwrap();
|
daemon.write_new(&path, b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::write(&path, b"second").unwrap();
|
std::fs::write(&path, b"second").unwrap();
|
||||||
drop(held);
|
drop(held);
|
||||||
@@ -1597,7 +1693,7 @@ mod tests {
|
|||||||
let path = daemon.mountpoint.join("f.txt");
|
let path = daemon.mountpoint.join("f.txt");
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"gone").unwrap();
|
daemon.write_new(&path, b"gone").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
drive.tree().contains_key("f.txt")
|
drive.tree().contains_key("f.txt")
|
||||||
}));
|
}));
|
||||||
@@ -1630,7 +1726,7 @@ mod tests {
|
|||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(&path, b"first").unwrap();
|
daemon.write_new(&path, b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::write(&path, b"second").unwrap();
|
std::fs::write(&path, b"second").unwrap();
|
||||||
drop(held);
|
drop(held);
|
||||||
@@ -1670,7 +1766,9 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon
|
daemon
|
||||||
.pending()
|
.pending()
|
||||||
@@ -1709,7 +1807,9 @@ mod tests {
|
|||||||
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
|
||||||
daemon.client.lose_reply_to_next_create();
|
daemon.client.lose_reply_to_next_create();
|
||||||
std::fs::write(daemon.mountpoint.join("f.txt"), b"first").unwrap();
|
daemon
|
||||||
|
.write_new(&daemon.mountpoint.join("f.txt"), b"first")
|
||||||
|
.unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || {
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
daemon
|
daemon
|
||||||
.pending()
|
.pending()
|
||||||
@@ -1803,7 +1903,7 @@ mod tests {
|
|||||||
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
let file = |bytes: &[u8]| Some(Entry::File(Arc::new(bytes.to_vec())));
|
||||||
|
|
||||||
let held = daemon.client.hold_reply_to_next_create();
|
let held = daemon.client.hold_reply_to_next_create();
|
||||||
std::fs::write(mount.join("c.bin"), b"first").unwrap();
|
daemon.write_new(&mount.join("c.bin"), b"first").unwrap();
|
||||||
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
assert!(wait_until(Duration::from_secs(30), || held.reached()));
|
||||||
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
|
std::fs::rename(mount.join("c.bin"), mount.join("d")).unwrap();
|
||||||
std::fs::write(mount.join("c.bin"), b"second").unwrap();
|
std::fs::write(mount.join("c.bin"), b"second").unwrap();
|
||||||
@@ -2069,4 +2169,245 @@ mod tests {
|
|||||||
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_pinned_folder_keeps_every_file_under_it_until_it_is_unpinned() {
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-folder");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir_all(daemon.mountpoint.join("Docs/sub")).unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/sub/b.txt"), b"beta").unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
&& drive.lookup("Docs/sub/b.txt").is_some()
|
||||||
|
}));
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
let entry = |folder: &str, name: &str| match daemon.request(&Request::ListDir {
|
||||||
|
path: folder.into(),
|
||||||
|
}) {
|
||||||
|
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == name),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
assert!(entry("Docs", "a.txt").is_some_and(|e| !e.cached && !e.pinned));
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::Pin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
entry("Docs", "a.txt").is_some_and(|e| e.cached)
|
||||||
|
&& entry("Docs/sub", "b.txt").is_some_and(|e| e.cached)
|
||||||
|
}),
|
||||||
|
"the folder's files were downloaded in the background"
|
||||||
|
);
|
||||||
|
let a = entry("Docs", "a.txt").unwrap();
|
||||||
|
assert!(a.pinned && a.pinned_by_folder);
|
||||||
|
assert!(entry("", "Docs").is_some_and(|e| e.pinned && !e.pinned_by_folder));
|
||||||
|
assert!(
|
||||||
|
daemon
|
||||||
|
.request(&Request::Unpin {
|
||||||
|
path: "Docs/a.txt".into(),
|
||||||
|
})
|
||||||
|
.is_err(),
|
||||||
|
"a file kept through its folder cannot be unpinned on its own"
|
||||||
|
);
|
||||||
|
|
||||||
|
daemon
|
||||||
|
.request(&Request::Unpin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let a = entry("Docs", "a.txt").unwrap();
|
||||||
|
assert!(!a.pinned && !a.pinned_by_folder && !a.cached);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_folder_pinned_while_a_create_in_it_lands_keeps_that_file() {
|
||||||
|
// The pin's download of a file started before the file's landing set
|
||||||
|
// its modification time from Drive, and ended after it: the blob was
|
||||||
|
// stored under the old time, and the file never read as cached (B208).
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("pin-folder-landing");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
std::fs::create_dir(daemon.mountpoint.join("Docs")).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
let entry = || match daemon.request(&Request::ListDir {
|
||||||
|
path: "Docs".into(),
|
||||||
|
}) {
|
||||||
|
Ok(Response::Entries { entries }) => entries.into_iter().find(|e| e.name == "a.txt"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drive stamps the file with the time it landed, a second after it
|
||||||
|
// was written, so the landing moves its modification time.
|
||||||
|
let paused = |paused| Request::SetSyncPaused {
|
||||||
|
paused,
|
||||||
|
until: None,
|
||||||
|
};
|
||||||
|
daemon.request(&paused(true)).unwrap();
|
||||||
|
std::fs::write(daemon.mountpoint.join("Docs/a.txt"), b"alpha").unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(1100));
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
daemon.request(&paused(false)).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
let real = drive.lookup("Docs/a.txt").unwrap();
|
||||||
|
let read_back = daemon.client.hold_next_read(&real);
|
||||||
|
drop(landing);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || read_back.reached()));
|
||||||
|
let written = entry().unwrap().modified;
|
||||||
|
|
||||||
|
// Without the blob the landing kept, the pin downloads the file.
|
||||||
|
daemon.request(&Request::PurgeCache).unwrap();
|
||||||
|
|
||||||
|
let download = daemon.client.hold_next_download(&real);
|
||||||
|
take_logged();
|
||||||
|
daemon
|
||||||
|
.request(&Request::Pin {
|
||||||
|
path: "Docs".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || download.reached()));
|
||||||
|
drop(read_back);
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
entry().is_some_and(|e| e.modified != written)
|
||||||
|
}));
|
||||||
|
drop(download);
|
||||||
|
let mut logged = Vec::new();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || {
|
||||||
|
logged.extend(take_logged());
|
||||||
|
logged
|
||||||
|
.iter()
|
||||||
|
.any(|line| line.starts_with("pin folder: done"))
|
||||||
|
}),
|
||||||
|
"{logged:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
entry().is_some_and(|e| e.cached),
|
||||||
|
"the file kept through its folder is available offline: {:?}",
|
||||||
|
entry()
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register `local` as a synced folder in `mode` and wait until the daemon
|
||||||
|
/// lists it that way, returning its id.
|
||||||
|
fn sync_folder(daemon: &Daemon, local: &Path, mode: &str) -> i64 {
|
||||||
|
std::fs::create_dir_all(local).unwrap();
|
||||||
|
let path = local.canonicalize().unwrap().to_string_lossy().into_owned();
|
||||||
|
daemon
|
||||||
|
.request(&Request::AddSyncFolder {
|
||||||
|
local_path: path.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let listed = |mode: &str| match daemon.request(&Request::ListSyncFolders) {
|
||||||
|
Ok(Response::SyncFolders { items }) => items
|
||||||
|
.into_iter()
|
||||||
|
.find(|f| {
|
||||||
|
f.local_path == path
|
||||||
|
&& f.mode == mode
|
||||||
|
&& f.pending_mode.is_none()
|
||||||
|
&& f.last_sync > 0
|
||||||
|
})
|
||||||
|
.map(|f| f.id),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || listed("mirror").is_some()),
|
||||||
|
"{} was registered: {:?}",
|
||||||
|
local.display(),
|
||||||
|
daemon.request(&Request::ListSyncFolders)
|
||||||
|
);
|
||||||
|
let id = listed("mirror").unwrap();
|
||||||
|
// Reads lag behind creates, so the remote folder the first pass made
|
||||||
|
// is not readable at once; switching modes reads it.
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
if mode != "mirror" {
|
||||||
|
daemon
|
||||||
|
.request(&Request::SetSyncFolderMode {
|
||||||
|
id,
|
||||||
|
mode: mode.into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
wait_until(Duration::from_secs(30), || listed(mode).is_some()
|
||||||
|
&& is_mounted(local)),
|
||||||
|
"{} switched to {mode}: {:?}",
|
||||||
|
local.display(),
|
||||||
|
daemon.request(&Request::ListSyncFolders)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
|
||||||
|
fn a_move_out_of_an_on_demand_folder_refuses_a_file_still_open_for_writing() {
|
||||||
|
// The account run's "move refuses a file still being written" case let
|
||||||
|
// a move from an on-demand synced folder into a mirror folder go ahead
|
||||||
|
// while the file was open, so the write landed after the move.
|
||||||
|
let drive = FakeDrive::new();
|
||||||
|
let dir = scratch("move-open-out-of-ondemand");
|
||||||
|
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
|
||||||
|
let ondemand = dir.join("ondemand");
|
||||||
|
let mirror = dir.join("mirror");
|
||||||
|
sync_folder(&daemon, &ondemand, "ondemand");
|
||||||
|
sync_folder(&daemon, &mirror, "mirror");
|
||||||
|
|
||||||
|
let source = ondemand.join("open");
|
||||||
|
std::fs::create_dir(&source).unwrap();
|
||||||
|
daemon.request(&Request::SyncNow { id: None }).unwrap();
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || {
|
||||||
|
daemon.pending().is_ok_and(|items| items.is_empty())
|
||||||
|
}));
|
||||||
|
// The account run opened the file a moment after its create landed:
|
||||||
|
// the queue was already empty, and the tree still had the
|
||||||
|
// placeholder uid it gave the file.
|
||||||
|
let landing = daemon.client.hold_next_landing_of_any();
|
||||||
|
{
|
||||||
|
let mut file = std::fs::File::create(source.join("open.txt")).unwrap();
|
||||||
|
std::io::Write::write_all(&mut file, b"before").unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
}
|
||||||
|
assert!(wait_until(Duration::from_secs(30), || landing.reached()));
|
||||||
|
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(source.join("open.txt"))
|
||||||
|
.unwrap();
|
||||||
|
std::io::Write::write_all(&mut file, b" and after").unwrap();
|
||||||
|
let moved = daemon.request(&Request::Move {
|
||||||
|
path: source.to_string_lossy().into_owned(),
|
||||||
|
new_parent: mirror
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
|
});
|
||||||
|
drop(file);
|
||||||
|
drop(landing);
|
||||||
|
match moved {
|
||||||
|
Err(e) => assert!(
|
||||||
|
e.to_string().contains("not uploaded yet"),
|
||||||
|
"refused for another reason: {e}"
|
||||||
|
),
|
||||||
|
Ok(answer) => panic!("the move went ahead: {answer:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
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,20 @@ 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 +328,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 +362,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 +642,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 +786,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 +811,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 +1447,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1484,8 +1537,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 +1563,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -253,7 +253,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
|
||||||
@@ -1198,7 +1198,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 +1317,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
|
||||||
|
|||||||
@@ -68,7 +68,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+566
-97
@@ -1,4 +1,4 @@
|
|||||||
//! Two-way folder sync engine (devices.md Phase 2).
|
//! Two-way folder sync engine.
|
||||||
//!
|
//!
|
||||||
//! Each `mirror`-mode synced folder is kept mirrored between a local directory
|
//! Each `mirror`-mode synced folder is kept mirrored between a local directory
|
||||||
//! and a folder under this machine's Proton Drive device. A local filesystem
|
//! and a folder under this machine's Proton Drive device. A local filesystem
|
||||||
@@ -50,7 +50,7 @@ mod planner;
|
|||||||
use engine::SYNC_CONCURRENCY;
|
use engine::SYNC_CONCURRENCY;
|
||||||
pub(crate) use engine::{SyncMsg, spawn};
|
pub(crate) use engine::{SyncMsg, spawn};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use engine::{classify, settle_with};
|
use engine::{classify, event_target, settle_with};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
@@ -59,8 +59,9 @@ pub(crate) use planner::base_name;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use planner::conflict_path;
|
use planner::conflict_path;
|
||||||
use planner::{
|
use planner::{
|
||||||
FilePlan, classification_order, conflict_path_with_suffix, filter_baseline, guard_local_wipe,
|
FilePlan, Unscanned, classification_order, conflict_path_with_suffix, filter_baseline,
|
||||||
join_rel, parent_rel, plan_file, rel_to_path, remote_sig, unchanged_remote_size,
|
guard_local_wipe, join_rel, local_deletions, parent_rel, plan_file, rel_to_path, remote_sig,
|
||||||
|
tracked_baseline, unchanged_remote_size,
|
||||||
};
|
};
|
||||||
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
/// What a [`Pending`] op did, so the reconcile can update shared state on the
|
||||||
/// engine thread (never inside a task).
|
/// engine thread (never inside a task).
|
||||||
@@ -187,6 +188,7 @@ impl Core {
|
|||||||
"idle"
|
"idle"
|
||||||
};
|
};
|
||||||
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
let _ = self.db.sync_folder_set_state(folder.id, state, now_secs());
|
||||||
|
self.report_unscanned(folder.id, name, &outcome.unscanned);
|
||||||
// Summarise a pass that actually moved something. A poll over an
|
// Summarise a pass that actually moved something. A poll over an
|
||||||
// unchanged folder does nothing and says nothing — otherwise the
|
// unchanged folder does nothing and says nothing — otherwise the
|
||||||
// feed would fill with "synced, 0 changes" every two minutes.
|
// feed would fill with "synced, 0 changes" every two minutes.
|
||||||
@@ -243,6 +245,37 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tell the activity feed what a pass over this folder had to leave out
|
||||||
|
/// because it cannot be read, once each time that changes. Every other path
|
||||||
|
/// in the folder still synced, so this does not make the folder's state an
|
||||||
|
/// error; but a folder the user has no permission for is never going to
|
||||||
|
/// reach Drive, and they should hear that once rather than not at all
|
||||||
|
/// (B196).
|
||||||
|
fn report_unscanned(&self, folder_id: i64, name: &str, unscanned: &[String]) {
|
||||||
|
let previous = {
|
||||||
|
let mut last = self.sync_unscanned.lock();
|
||||||
|
if unscanned.is_empty() {
|
||||||
|
last.remove(&folder_id)
|
||||||
|
} else {
|
||||||
|
last.insert(folder_id, unscanned.to_vec())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if unscanned.is_empty() || previous.as_deref() == Some(unscanned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SHOWN: usize = 3;
|
||||||
|
let mut detail = unscanned[..unscanned.len().min(SHOWN)].join(", ");
|
||||||
|
if unscanned.len() > SHOWN {
|
||||||
|
detail.push_str(&format!(" and {} more", unscanned.len() - SHOWN));
|
||||||
|
}
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Sync,
|
||||||
|
format!("{} item(s) in {name} cannot be synced", unscanned.len()),
|
||||||
|
detail,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Compile the ignore rules for a synced folder: the global patterns from
|
/// Compile the ignore rules for a synced folder: the global patterns from
|
||||||
/// the config, plus any `.pdfsignore` at `local_root`.
|
/// the config, plus any `.pdfsignore` at `local_root`.
|
||||||
///
|
///
|
||||||
@@ -250,7 +283,7 @@ impl Core {
|
|||||||
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
/// than to "ignore nothing" — the defaults exclude build and VCS trees, and
|
||||||
/// silently uploading those because a config read failed is the outcome this
|
/// silently uploading those because a config read failed is the outcome this
|
||||||
/// feature exists to prevent.
|
/// feature exists to prevent.
|
||||||
fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
pub(crate) fn ignore_rules(&self, local_root: &Path) -> IgnoreRules {
|
||||||
let globals = match pdfs_core::config::AppDirs::new() {
|
let globals = match pdfs_core::config::AppDirs::new() {
|
||||||
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
Ok(dirs) => dirs.load_config().resolved_ignore_patterns(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -313,15 +346,23 @@ impl Core {
|
|||||||
self.progress_scan_total(folder_id, baseline.len());
|
self.progress_scan_total(folder_id, baseline.len());
|
||||||
|
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
let writing = open_for_write_set(local_root);
|
let writing = open_for_write_set(local_root);
|
||||||
self.walk_local(
|
self.walk_local(
|
||||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
folder_id,
|
||||||
|
local_root,
|
||||||
|
rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut unscanned,
|
||||||
)?;
|
)?;
|
||||||
// The guard compares against the paths this pass could still see: an
|
// The guard compares against the paths this pass could still see: an
|
||||||
// ignored path is absent from `local` by rule, not by loss, and counting
|
// ignored or unreadable path is absent from `local` by rule or by
|
||||||
// those as missing would trip the guard on every pass — wedging the
|
// failure, not by loss, and counting those as missing would trip the
|
||||||
// folder's sync permanently — the moment a rule covers the whole tree.
|
// guard on every pass — wedging the folder's sync permanently — the
|
||||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
// moment a rule covers the whole tree.
|
||||||
|
let tracked = tracked_baseline(&baseline, rules, &unscanned);
|
||||||
|
guard_local_wipe(&tracked, &local)?;
|
||||||
|
|
||||||
// The remote folder uids come from the baseline instead of a walk: every
|
// The remote folder uids come from the baseline instead of a walk: every
|
||||||
// directory this device has synced recorded its uid there when it was
|
// directory this device has synced recorded its uid there when it was
|
||||||
@@ -391,6 +432,7 @@ impl Core {
|
|||||||
|
|
||||||
let mut outcome = Outcome {
|
let mut outcome = Outcome {
|
||||||
deferred: local.values().filter(|item| item.open_for_write).count(),
|
deferred: local.values().filter(|item| item.open_for_write).count(),
|
||||||
|
unscanned: unscanned.report(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let mut order: Vec<&String> = local.keys().collect();
|
let mut order: Vec<&String> = local.keys().collect();
|
||||||
@@ -515,13 +557,8 @@ impl Core {
|
|||||||
// showing the folder's remote contents — otherwise everything they deleted
|
// showing the folder's remote contents — otherwise everything they deleted
|
||||||
// comes back the moment the switch lands. Shallowest first, skipping anything
|
// comes back the moment the switch lands. Shallowest first, skipping anything
|
||||||
// under a folder already trashed (its children went with it).
|
// under a folder already trashed (its children went with it).
|
||||||
let mut missing: Vec<&String> = baseline
|
|
||||||
.keys()
|
|
||||||
.filter(|rel| !local.contains_key(*rel))
|
|
||||||
.collect();
|
|
||||||
missing.sort_by_key(|p| p.matches('/').count());
|
|
||||||
let mut trashed: Vec<String> = Vec::new();
|
let mut trashed: Vec<String> = Vec::new();
|
||||||
for rel in missing {
|
for rel in &local_deletions(&tracked, &local) {
|
||||||
if trashed
|
if trashed
|
||||||
.iter()
|
.iter()
|
||||||
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
.any(|dir| rel.starts_with(&format!("{dir}/")))
|
||||||
@@ -586,12 +623,18 @@ impl Core {
|
|||||||
self.progress_scan_total(folder_id, baseline.len() * 2);
|
self.progress_scan_total(folder_id, baseline.len() * 2);
|
||||||
|
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
let writing = open_for_write_set(local_root);
|
let writing = open_for_write_set(local_root);
|
||||||
self.walk_local(
|
self.walk_local(
|
||||||
folder_id, local_root, local_root, rules, &writing, &mut local,
|
folder_id,
|
||||||
|
local_root,
|
||||||
|
rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut unscanned,
|
||||||
)?;
|
)?;
|
||||||
// See `push_pass`: the guard must not count rule-excluded paths as lost.
|
// See `push_pass`: the guard must not count excluded paths as lost.
|
||||||
guard_local_wipe(&filter_baseline(&baseline, rules), &local)?;
|
guard_local_wipe(&tracked_baseline(&baseline, rules, &unscanned), &local)?;
|
||||||
|
|
||||||
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
let mut remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
let mut remote_dirs: HashMap<String, NodeUid> = HashMap::new();
|
||||||
@@ -605,9 +648,15 @@ impl Core {
|
|||||||
&baseline,
|
&baseline,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let order = classification_order(&local, &remote, &baseline, rules);
|
let mut order = classification_order(&local, &remote, &baseline, rules);
|
||||||
|
// A path the scan could not read is no more a local deletion than an
|
||||||
|
// ignored one, and a remote path under it has nowhere to be written.
|
||||||
|
order.retain(|rel| !unscanned.covers(rel));
|
||||||
|
|
||||||
let mut outcome = Outcome::default();
|
let mut outcome = Outcome {
|
||||||
|
unscanned: unscanned.report(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
// Folders to delete, collected here and removed deepest-first at the end
|
// Folders to delete, collected here and removed deepest-first at the end
|
||||||
// so a parent is never removed before its children.
|
// so a parent is never removed before its children.
|
||||||
let mut delete_local_dirs: Vec<String> = Vec::new();
|
let mut delete_local_dirs: Vec<String> = Vec::new();
|
||||||
@@ -873,19 +922,19 @@ impl Core {
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively walk a local directory into `out`, keyed by `/`-joined relative
|
/// Recursively walk a local tree into `out`, keyed by `/`-joined relative
|
||||||
/// path. Symlinks and other special files are skipped. Reports each entry to the
|
/// path. Symlinks and other special files are skipped, and paths that cannot
|
||||||
/// pass's scan progress.
|
/// be read go to `unscanned`. Reports each entry to the pass's scan progress.
|
||||||
fn walk_local(
|
fn walk_local(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
dir: &Path,
|
|
||||||
rules: &IgnoreRules,
|
rules: &IgnoreRules,
|
||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
walk_local_tree(root, rules, writing, out, unscanned, &mut |rel| {
|
||||||
self.progress_scanned(folder_id, base_name(rel))
|
self.progress_scanned(folder_id, base_name(rel))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1342,7 +1391,7 @@ impl Core {
|
|||||||
/// rename below is the destructive step, and the download in between can run
|
/// rename below is the destructive step, and the download in between can run
|
||||||
/// for minutes, so the destination is re-examined right before it: a local
|
/// for minutes, so the destination is re-examined right before it: a local
|
||||||
/// file that no longer matches the plan is set aside as a conflict copy
|
/// file that no longer matches the plan is set aside as a conflict copy
|
||||||
/// instead of being replaced (bugs.md B39).
|
/// instead of being replaced (`docs/BUGS.md` B39).
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
&self,
|
&self,
|
||||||
@@ -1441,7 +1490,7 @@ impl Core {
|
|||||||
/// the pre-upload identity is what makes the next pass see the path as
|
/// the pre-upload identity is what makes the next pass see the path as
|
||||||
/// locally changed and upload a whole, clean revision over it. Recording the
|
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||||
/// post-upload stat instead declared the torn revision synchronized and left
|
/// post-upload stat instead declared the torn revision synchronized and left
|
||||||
/// it as the file's content on Drive (bugs.md B40).
|
/// it as the file's content on Drive (`docs/BUGS.md` B40).
|
||||||
async fn record_file_baseline(
|
async fn record_file_baseline(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1505,7 +1554,7 @@ fn open_to_upload(path: &Path, rel: &str) -> Result<Option<std::fs::File>, Strin
|
|||||||
/// records the identity the upload *read* rather than the one on disk now, so
|
/// records the identity the upload *read* rather than the one on disk now, so
|
||||||
/// the next pass classifies the path as locally changed and replaces the torn
|
/// the next pass classifies the path as locally changed and replaces the torn
|
||||||
/// revision. Without the log there is nothing in the journal to explain the
|
/// revision. Without the log there is nothing in the journal to explain the
|
||||||
/// extra revision (bugs.md B40).
|
/// extra revision (`docs/BUGS.md` B40).
|
||||||
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||||
let now = local_sig(path);
|
let now = local_sig(path);
|
||||||
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||||
@@ -1537,7 +1586,7 @@ fn local_sig(path: &Path) -> Option<LocalSig> {
|
|||||||
/// transfer started, and a download of a large file can run for minutes. Without
|
/// transfer started, and a download of a large file can run for minutes. Without
|
||||||
/// this, a local edit completed inside that window was replaced by the remote
|
/// this, a local edit completed inside that window was replaced by the remote
|
||||||
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||||
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
/// engine must not have (`docs/BUGS.md` B39). Keeping the copy makes it re-upload as a
|
||||||
/// new file on the next pass, which is the same resolution a both-sides-changed
|
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||||
/// conflict already gets.
|
/// conflict already gets.
|
||||||
///
|
///
|
||||||
@@ -1573,7 +1622,7 @@ fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::R
|
|||||||
/// exists, so the same removal was re-attempted and re-reported as an error on
|
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||||
/// every pass, forever. Every other error is still an error, and keeping the
|
/// every pass, forever. Every other error is still an error, and keeping the
|
||||||
/// baseline is exactly right there: the surviving file must not be mistaken for
|
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||||
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
/// a new local creation on the next pass and uploaded back (`docs/BUGS.md` B42).
|
||||||
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||||
match result {
|
match result {
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
@@ -1582,51 +1631,120 @@ fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||||
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
/// local tree under `root` into `out`, reporting each entry to `scanned`.
|
||||||
///
|
///
|
||||||
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
/// **A path the scan cannot read is left out and recorded in `unscanned`, never
|
||||||
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
/// silently dropped.** Every pass drops what `unscanned` covers from
|
||||||
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
/// classification, so a baseline row under it is neither a local deletion nor a
|
||||||
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
/// download target (`docs/BUGS.md` B55). That covers a subfolder the pass has no
|
||||||
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
/// permission for, an entry that vanished between the listing and its `stat`,
|
||||||
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
/// and a name that is not UTF-8. Failing the whole pass on any of them, as this
|
||||||
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
/// did before, left one root-owned folder stopping everything else in the tree
|
||||||
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
/// from syncing, on every pass, for good (B196).
|
||||||
/// pass retries once the transient condition clears.
|
///
|
||||||
|
/// The root is the exception, and so is any other I/O error: an absent or
|
||||||
|
/// unreadable root fails the pass, since leaving the root out would leave
|
||||||
|
/// nothing in, and an error this cannot name a cause for is not one to guess
|
||||||
|
/// about.
|
||||||
///
|
///
|
||||||
/// Split out from the method so exactly that can be tested against a real tree
|
/// Split out from the method so exactly that can be tested against a real tree
|
||||||
/// with a real unreadable directory in it, which needs no `Core` at all.
|
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||||
fn walk_local_tree(
|
fn walk_local_tree(
|
||||||
|
root: &Path,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
writing: &HashSet<PathBuf>,
|
||||||
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
|
scanned: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<(), String> {
|
||||||
|
walk_local_dir(root, root, rules, writing, out, unscanned, scanned).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why [`walk_local_dir`] could not finish a folder.
|
||||||
|
enum ScanError {
|
||||||
|
/// An I/O error on `what`, which the caller may leave out of the pass.
|
||||||
|
Io { what: String, error: std::io::Error },
|
||||||
|
/// Anything else, which fails the pass.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ScanError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ScanError::Io { what, error } => write!(f, "{what}: {error}"),
|
||||||
|
ScanError::Failed(message) => f.write_str(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave `rel` out of the pass for `failure`, or hand it back when it is one
|
||||||
|
/// that fails the pass. Permission denied is reported to the user; an entry
|
||||||
|
/// that vanished mid-scan is not, since the next pass sees it settled.
|
||||||
|
fn leave_out(rel: &str, failure: ScanError, unscanned: &mut Unscanned) -> Result<(), ScanError> {
|
||||||
|
let ScanError::Io { what, error } = &failure else {
|
||||||
|
return Err(failure);
|
||||||
|
};
|
||||||
|
match error.kind() {
|
||||||
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
|
warn!(%what, %error, "sync: cannot read; leaving it out of the pass");
|
||||||
|
unscanned.unreadable(rel.to_string(), &error.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
std::io::ErrorKind::NotFound => {
|
||||||
|
debug!(%what, "sync: gone mid-scan; leaving it out of the pass");
|
||||||
|
unscanned.vanished(rel.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(failure),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_local_dir(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
rules: &IgnoreRules,
|
rules: &IgnoreRules,
|
||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
unscanned: &mut Unscanned,
|
||||||
scanned: &mut dyn FnMut(&str),
|
scanned: &mut dyn FnMut(&str),
|
||||||
) -> Result<(), String> {
|
) -> Result<(), ScanError> {
|
||||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
let io = |what: String| move |error| ScanError::Io { what, error };
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(io(format!("read {}", dir.display())))?;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
let entry = entry.map_err(io(format!("read entry in {}", dir.display())))?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
||||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||||
format!(
|
ScanError::Failed(format!(
|
||||||
"local path {} escaped sync root {}: {e}",
|
"local path {} escaped sync root {}: {e}",
|
||||||
path.display(),
|
path.display(),
|
||||||
root.display()
|
root.display()
|
||||||
)
|
))
|
||||||
})?;
|
|
||||||
let rel = stripped.to_str().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
let Some(rel) = stripped.to_str() else {
|
||||||
|
// Nothing can be keyed by this name: not the baseline, which only
|
||||||
|
// ever held paths this scan produced, and not Drive, whose names are
|
||||||
|
// UTF-8. Leaving it out cannot read as a deletion of anything.
|
||||||
|
warn!(path = %path.display(), "sync: name is not valid UTF-8; leaving it out of the pass");
|
||||||
|
unscanned.unreadable(
|
||||||
|
stripped.to_string_lossy().into_owned(),
|
||||||
|
"name is not valid UTF-8",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
// Ignore our own in-flight download temp files.
|
// Ignore our own in-flight download temp files.
|
||||||
if rel.contains(".pdfs-tmp-") {
|
if rel.contains(".pdfs-tmp-") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
|
Ok(meta) => meta,
|
||||||
|
// Gone since the listing, or in a folder that lists but does not
|
||||||
|
// allow `stat`. Either way this pass cannot say what it is.
|
||||||
|
Err(error) => {
|
||||||
|
let what = format!("stat {}", path.display());
|
||||||
|
leave_out(rel, ScanError::Io { what, error }, unscanned)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
// Ignored paths are dropped here as well as from the classification
|
// Ignored paths are dropped here as well as from the classification
|
||||||
// union — not for correctness (the union filter is what makes this
|
// union — not for correctness (the union filter is what makes this
|
||||||
// safe) but so an ignored `node_modules/` is never descended into or
|
// safe) but so an ignored `node_modules/` is never descended into or
|
||||||
@@ -1646,7 +1764,15 @@ fn walk_local_tree(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
scanned(rel);
|
scanned(rel);
|
||||||
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
if let Err(failure) =
|
||||||
|
walk_local_dir(root, &path, rules, writing, out, unscanned, scanned)
|
||||||
|
{
|
||||||
|
leave_out(rel, failure, unscanned)?;
|
||||||
|
// What the folder did list before failing goes with it: a
|
||||||
|
// half-read folder is as unknown as an unread one.
|
||||||
|
let prefix = format!("{rel}/");
|
||||||
|
out.retain(|k, _| k != rel && !k.starts_with(&prefix));
|
||||||
|
}
|
||||||
} else if meta.is_file() {
|
} else if meta.is_file() {
|
||||||
// Check whether any process holds this file open for writing.
|
// Check whether any process holds this file open for writing.
|
||||||
// The canonical path is used because /proc/*/fd links resolve
|
// The canonical path is used because /proc/*/fd links resolve
|
||||||
@@ -1684,7 +1810,7 @@ pub(crate) fn mirror_subtree_unsynced(
|
|||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
if let Some(reason) = unsynced_local(root, rel, baseline, &mut seen) {
|
if let Some(reason) = unsynced_local(root, rel, baseline, None, &mut seen) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
let prefix = format!("{rel}/");
|
let prefix = format!("{rel}/");
|
||||||
@@ -1695,18 +1821,64 @@ pub(crate) fn mirror_subtree_unsynced(
|
|||||||
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`mirror_subtree_unsynced`] for a whole mirror folder: why its local copy
|
||||||
|
/// holds something Proton Drive does not, or `None` when evicting it loses
|
||||||
|
/// nothing. The switch to on-demand asks this before it empties the folder.
|
||||||
|
///
|
||||||
|
/// A push pass that ends `idle` is not enough on its own. It never uploads an
|
||||||
|
/// ignored path, a symlink or a folder it cannot read, it defers a file open
|
||||||
|
/// for writing, and a file can change after it finishes; the eviction deleted
|
||||||
|
/// all of those for good (B198).
|
||||||
|
///
|
||||||
|
/// A tracked path deleted here but still in the baseline counts, since the
|
||||||
|
/// mount would show it again. An ignored one does not: ignoring never carries
|
||||||
|
/// a deletion to Drive, so its row stays in the baseline for good, and holding
|
||||||
|
/// the switch on it would hold it forever.
|
||||||
|
pub(crate) fn mirror_tree_unsynced(
|
||||||
|
root: &Path,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
if let Some(reason) = unsynced_local(root, "", baseline, Some(rules), &mut seen) {
|
||||||
|
return Some(reason);
|
||||||
|
}
|
||||||
|
let mut missing: Vec<String> = filter_baseline(baseline, rules)
|
||||||
|
.into_keys()
|
||||||
|
.filter(|r| !seen.contains(r.as_str()))
|
||||||
|
.collect();
|
||||||
|
missing.sort_unstable();
|
||||||
|
missing
|
||||||
|
.first()
|
||||||
|
.map(|r| format!("{r} was deleted here but not on Proton Drive yet"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The walk behind both checks. `rules`, when given, only names an ignored
|
||||||
|
/// path as such in the reason; it is still something only this disk has.
|
||||||
fn unsynced_local(
|
fn unsynced_local(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
baseline: &HashMap<String, StoredSyncEntry>,
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
rules: Option<&IgnoreRules>,
|
||||||
seen: &mut HashSet<String>,
|
seen: &mut HashSet<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let path = root.join(rel_to_path(rel));
|
let path = if rel.is_empty() {
|
||||||
|
root.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(rel_to_path(rel))
|
||||||
|
};
|
||||||
|
// The folder itself has no baseline row; only what is in it does.
|
||||||
|
if !rel.is_empty() {
|
||||||
let meta = match std::fs::symlink_metadata(&path) {
|
let meta = match std::fs::symlink_metadata(&path) {
|
||||||
Ok(meta) => meta,
|
Ok(meta) => meta,
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
||||||
};
|
};
|
||||||
let Some(base) = baseline.get(rel) else {
|
let Some(base) = baseline.get(rel) else {
|
||||||
|
if rules.is_some_and(|rules| rules.is_ignored(rel, meta.is_dir())) {
|
||||||
|
return Some(format!(
|
||||||
|
"{rel} is excluded from sync, so it is only on this computer"
|
||||||
|
));
|
||||||
|
}
|
||||||
return Some(format!("{rel} is not on Proton Drive yet"));
|
return Some(format!("{rel} is not on Proton Drive yet"));
|
||||||
};
|
};
|
||||||
seen.insert(rel.to_string());
|
seen.insert(rel.to_string());
|
||||||
@@ -1717,23 +1889,25 @@ fn unsynced_local(
|
|||||||
if !meta.is_dir() {
|
if !meta.is_dir() {
|
||||||
return Some(format!("{rel} is not a regular file or folder"));
|
return Some(format!("{rel} is not a regular file or folder"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let shown = if rel.is_empty() { "the folder" } else { rel };
|
||||||
let entries = match std::fs::read_dir(&path) {
|
let entries = match std::fs::read_dir(&path) {
|
||||||
Ok(entries) => entries,
|
Ok(entries) => entries,
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||||
};
|
};
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let name = match entry {
|
let name = match entry {
|
||||||
Ok(entry) => entry.file_name(),
|
Ok(entry) => entry.file_name(),
|
||||||
Err(e) => return Some(format!("cannot read {rel}: {e}")),
|
Err(e) => return Some(format!("cannot read {shown}: {e}")),
|
||||||
};
|
};
|
||||||
let Some(name) = name.to_str() else {
|
let Some(name) = name.to_str() else {
|
||||||
return Some(format!("{rel} holds a name that is not valid UTF-8"));
|
return Some(format!("{shown} holds a name that is not valid UTF-8"));
|
||||||
};
|
};
|
||||||
// A download the engine left half done; it is the engine's to clean up.
|
// A download the engine left half done; it is the engine's to clean up.
|
||||||
if name.contains(".pdfs-tmp-") {
|
if name.contains(".pdfs-tmp-") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(reason) = unsynced_local(root, &format!("{rel}/{name}"), baseline, seen) {
|
if let Some(reason) = unsynced_local(root, &join_rel(rel, name), baseline, rules, seen) {
|
||||||
return Some(reason);
|
return Some(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1830,7 +2004,7 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
|||||||
///
|
///
|
||||||
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||||
/// sync from no edit at all, and if the size did not change either, that file
|
/// sync from no edit at all, and if the size did not change either, that file
|
||||||
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
/// was never uploaded (`docs/BUGS.md` B25). `None` for a clock or filesystem that
|
||||||
/// cannot answer, which falls the comparison back to seconds rather than
|
/// cannot answer, which falls the comparison back to seconds rather than
|
||||||
/// asserting an mtime of zero.
|
/// asserting an mtime of zero.
|
||||||
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||||
@@ -1845,7 +2019,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A removal of something already gone has produced the state the sync
|
/// A removal of something already gone has produced the state the sync
|
||||||
/// engine wanted, so its baseline is dropped rather than retried forever.
|
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||||
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
/// Anything else stays an error and keeps the baseline (`docs/BUGS.md` B42).
|
||||||
#[test]
|
#[test]
|
||||||
fn an_already_absent_path_counts_as_removed() {
|
fn an_already_absent_path_counts_as_removed() {
|
||||||
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||||
@@ -1862,7 +2036,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The download guard: a destination that still matches the plan is
|
/// The download guard: a destination that still matches the plan is
|
||||||
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
/// replaced silently, one that has moved on since is kept (`docs/BUGS.md` B39).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||||
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||||
@@ -1923,6 +2097,32 @@ mod tests {
|
|||||||
(ids, started.elapsed())
|
(ids, started.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_change_in_one_mirror_folder_reconciles_only_that_folder() {
|
||||||
|
let watched = vec![
|
||||||
|
(PathBuf::from("/home/me/Docs"), 1),
|
||||||
|
(PathBuf::from("/home/me/Photos"), 2),
|
||||||
|
(PathBuf::from("/home/me/Photos/Raw"), 3),
|
||||||
|
];
|
||||||
|
let target = |paths: &[&str]| {
|
||||||
|
let paths: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
|
||||||
|
match event_target(&watched, &paths) {
|
||||||
|
SyncMsg::Reconcile(id) => Some(id),
|
||||||
|
SyncMsg::ReconcileAll => None,
|
||||||
|
_ => panic!("unexpected message"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a.txt"]), Some(1));
|
||||||
|
// A rename within one folder.
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Docs/b"]), Some(1));
|
||||||
|
// The deepest root wins.
|
||||||
|
assert_eq!(target(&["/home/me/Photos/Raw/x.cr2"]), Some(3));
|
||||||
|
// Across folders, outside them all, or no path at all: walk everything.
|
||||||
|
assert_eq!(target(&["/home/me/Docs/a", "/home/me/Photos/a"]), None);
|
||||||
|
assert_eq!(target(&["/home/me/Docsx/a"]), None);
|
||||||
|
assert_eq!(target(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
/// The bug this replaced a fixed sleep for: a save that keeps writing past
|
||||||
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
/// the debounce used to be walked mid-write, uploading a torn snapshot as a
|
||||||
/// real revision. The window has to restart on every event, so settling
|
/// real revision. The window has to restart on every event, so settling
|
||||||
@@ -2154,7 +2354,15 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||||
|
|
||||||
for size in 1..=3 {
|
for size in 1..=3 {
|
||||||
@@ -2170,14 +2378,22 @@ mod tests {
|
|||||||
// remounted folder resumes rather than staying wedged.
|
// remounted folder resumes rather than staying wedged.
|
||||||
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||||
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut local,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||||
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||||
|
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
/// A restored folder starts with an empty baseline against
|
||||||
/// an empty local directory and a full remote. That must reconcile as
|
/// an empty local directory and a full remote. That must reconcile as
|
||||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||||
/// every restore would wedge its folder on the first pass.
|
/// every restore would wedge its folder on the first pass.
|
||||||
@@ -2475,33 +2691,176 @@ mod tests {
|
|||||||
|_: &str| {}
|
|_: &str| {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A directory the pass cannot read must fail the pass. Omitting it would
|
/// Scan `root` with no rules, returning what it found and left out.
|
||||||
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
fn scan(root: &Path) -> Result<(HashMap<String, LocalItem>, Unscanned), String> {
|
||||||
/// having deleted that subtree and propagates to Drive (B55).
|
let mut out = HashMap::new();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
walk_local_tree(
|
||||||
|
root,
|
||||||
|
&rules_for(&[]),
|
||||||
|
&HashSet::new(),
|
||||||
|
&mut out,
|
||||||
|
&mut unscanned,
|
||||||
|
&mut progress,
|
||||||
|
)?;
|
||||||
|
Ok((out, unscanned))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted_keys<V>(map: &HashMap<String, V>) -> Vec<&str> {
|
||||||
|
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B196, the report in #33: a folder the user may not read, such as a
|
||||||
|
/// container's root-owned cache, is left out of the pass and named, and
|
||||||
|
/// everything around it still scans. It used to fail the whole pass, so
|
||||||
|
/// nothing else in the tree ever synced again.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
fn an_unreadable_subdirectory_is_left_out_and_the_rest_scans() {
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
let root = sync_test_dir("scan-eacces");
|
let root = sync_test_dir("scan-eacces");
|
||||||
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
let locked = root.join("locked");
|
std::fs::create_dir_all(root.join("docker/cache")).unwrap();
|
||||||
|
std::fs::write(root.join("docker/compose.yml"), b"c").unwrap();
|
||||||
|
let locked = root.join("docker/cache/locked");
|
||||||
std::fs::create_dir(&locked).unwrap();
|
std::fs::create_dir(&locked).unwrap();
|
||||||
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
let rules = rules_for(&[]);
|
let result = scan(&root);
|
||||||
let writing = HashSet::new();
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
let mut progress = no_progress();
|
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
|
||||||
|
|
||||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
let error = result.expect_err("an unreadable directory must abort the pass");
|
let (out, unscanned) = result.expect("one unreadable folder must not fail the pass");
|
||||||
assert!(
|
assert_eq!(
|
||||||
error.contains("locked"),
|
sorted_keys(&out),
|
||||||
"the error should name the subtree it could not read: {error}"
|
vec!["docker", "docker/cache", "docker/compose.yml", "keep.txt"]
|
||||||
|
);
|
||||||
|
assert!(unscanned.covers("docker/cache/locked"));
|
||||||
|
assert!(unscanned.covers("docker/cache/locked/inner.txt"));
|
||||||
|
assert!(!unscanned.covers("docker/cache"));
|
||||||
|
let report = unscanned.report();
|
||||||
|
assert_eq!(report.len(), 1);
|
||||||
|
assert!(report[0].starts_with("docker/cache/locked ("), "{report:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder with read but no search permission lists its names but cannot
|
||||||
|
/// `stat` them. Whatever it did list must go with it: a half-read folder
|
||||||
|
/// is as unknown as an unread one.
|
||||||
|
#[test]
|
||||||
|
fn a_listable_but_unsearchable_folder_is_left_out_whole() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-no-search");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
let half = root.join("half");
|
||||||
|
std::fs::create_dir(&half).unwrap();
|
||||||
|
std::fs::write(half.join("one.txt"), b"1").unwrap();
|
||||||
|
std::fs::write(half.join("two.txt"), b"2").unwrap();
|
||||||
|
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o400)).unwrap();
|
||||||
|
|
||||||
|
let result = scan(&root);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&half, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let (out, unscanned) = result.unwrap();
|
||||||
|
// Each entry fails its own `stat` and is left out by name.
|
||||||
|
assert_eq!(sorted_keys(&out), vec!["half", "keep.txt"]);
|
||||||
|
assert!(unscanned.covers("half/one.txt"));
|
||||||
|
assert!(unscanned.covers("half/two.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of B196: what was left out is not a deletion, and not a
|
||||||
|
/// download target either. Its baseline rows leave the wipe guard's set
|
||||||
|
/// and a push pass's deletions, and its paths leave classification.
|
||||||
|
#[test]
|
||||||
|
fn a_path_the_scan_left_out_is_never_a_local_deletion() {
|
||||||
|
let mut baseline = baseline_with("keep.txt", 1, 1);
|
||||||
|
baseline.extend(baseline_with("locked", 0, 0));
|
||||||
|
baseline.extend(baseline_with("locked/inner.txt", 1, 1));
|
||||||
|
baseline.extend(baseline_with("gone.txt", 1, 1));
|
||||||
|
let local: HashMap<String, LocalItem> = [("keep.txt".to_string(), local_item(false))]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
unscanned.unreadable("locked".to_string(), "Permission denied");
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules, &unscanned);
|
||||||
|
assert_eq!(sorted_keys(&tracked), vec!["gone.txt", "keep.txt"]);
|
||||||
|
assert_eq!(local_deletions(&tracked, &local), vec!["gone.txt"]);
|
||||||
|
|
||||||
|
// Every tracked path but one is gone, and the guard still lets the
|
||||||
|
// pass run: one survivor proves the tree is there.
|
||||||
|
assert!(guard_local_wipe(&tracked, &local).is_ok());
|
||||||
|
// With everything readable left out, nothing is tracked, so nothing
|
||||||
|
// can be lost and the guard has nothing to object to.
|
||||||
|
let mut all = Unscanned::default();
|
||||||
|
all.unreadable("keep.txt".to_string(), "Permission denied");
|
||||||
|
all.unreadable("locked".to_string(), "Permission denied");
|
||||||
|
all.vanished("gone.txt".to_string());
|
||||||
|
assert!(tracked_baseline(&baseline, &rules, &all).is_empty());
|
||||||
|
|
||||||
|
let remote: HashMap<String, RemoteItem> = HashMap::new();
|
||||||
|
let mut order = classification_order(&local, &remote, &baseline, &rules);
|
||||||
|
order.retain(|rel| !unscanned.covers(rel));
|
||||||
|
order.sort_unstable();
|
||||||
|
assert_eq!(order, vec!["gone.txt", "keep.txt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unscanned_covers_a_path_and_what_is_under_it_but_not_its_siblings() {
|
||||||
|
let mut unscanned = Unscanned::default();
|
||||||
|
unscanned.unreadable("a/locked".to_string(), "Permission denied");
|
||||||
|
unscanned.vanished("b.txt".to_string());
|
||||||
|
|
||||||
|
assert!(unscanned.covers("a/locked"));
|
||||||
|
assert!(unscanned.covers("a/locked/x/y.txt"));
|
||||||
|
assert!(unscanned.covers("b.txt"));
|
||||||
|
assert!(!unscanned.covers("a"));
|
||||||
|
assert!(!unscanned.covers("a/locked2"));
|
||||||
|
assert!(!unscanned.covers("b.txt.bak"));
|
||||||
|
// A file that only vanished mid-scan is not worth telling anyone about.
|
||||||
|
assert_eq!(unscanned.report(), vec!["a/locked (Permission denied)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B197: a push pass read its deletions from the raw baseline, so a path
|
||||||
|
/// synced before it became ignored, which the walk skips, was trashed on
|
||||||
|
/// Drive, and the switch the push pass ran for then evicted the local copy.
|
||||||
|
#[test]
|
||||||
|
fn a_push_pass_never_reads_an_ignored_path_as_deleted() {
|
||||||
|
let mut baseline = baseline_with("notes.md", 1, 1);
|
||||||
|
baseline.extend(baseline_with("node_modules", 0, 0));
|
||||||
|
baseline.extend(baseline_with("node_modules/x/index.js", 1, 1));
|
||||||
|
baseline.extend(baseline_with("old.md", 1, 1));
|
||||||
|
let local: HashMap<String, LocalItem> = [("notes.md".to_string(), local_item(false))]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let rules = rules_for(&["node_modules/"]);
|
||||||
|
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules, &Unscanned::default());
|
||||||
|
|
||||||
|
assert_eq!(local_deletions(&tracked, &local), vec!["old.md"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletions come out shallowest first, so a trashed folder can take its
|
||||||
|
/// children with it.
|
||||||
|
#[test]
|
||||||
|
fn local_deletions_come_out_shallowest_first() {
|
||||||
|
let mut baseline = baseline_with("d/e/f.txt", 1, 1);
|
||||||
|
baseline.extend(baseline_with("d", 0, 0));
|
||||||
|
baseline.extend(baseline_with("d/e", 0, 0));
|
||||||
|
let local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let tracked = tracked_baseline(&baseline, &rules_for(&[]), &Unscanned::default());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
local_deletions(&tracked, &local),
|
||||||
|
vec!["d", "d/e", "d/e/f.txt"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2593,6 +2952,94 @@ mod tests {
|
|||||||
assert_eq!(reason, None);
|
assert_eq!(reason, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- B198: going online-only never evicts what Drive does not have -----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fully_synced_mirror_folder_may_go_online_only() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-synced");
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert_eq!(reason, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The case that lost data: `.git/` is ignored by default, so no pass ever
|
||||||
|
/// uploads it, and the eviction deleted it with the rest.
|
||||||
|
#[test]
|
||||||
|
fn an_ignored_folder_only_this_disk_has_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-ignored");
|
||||||
|
std::fs::create_dir(root.join(".git")).unwrap();
|
||||||
|
std::fs::write(root.join(".git/HEAD"), b"ref").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[".git/"]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
let reason = reason.expect("an ignored folder is only on this disk");
|
||||||
|
assert!(
|
||||||
|
reason.contains(".git") && reason.contains("excluded"),
|
||||||
|
"{reason}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ignored row stays in the baseline for good once its file is gone, so
|
||||||
|
/// counting it as a deletion not carried up would hold the switch forever.
|
||||||
|
#[test]
|
||||||
|
fn a_deleted_ignored_path_does_not_hold_the_switch_back() {
|
||||||
|
let (root, mut baseline) = synced_subtree("evict-ignored-gone");
|
||||||
|
baseline.insert(
|
||||||
|
"build/out.o".to_string(),
|
||||||
|
StoredSyncEntry {
|
||||||
|
rel_path: "build/out.o".to_string(),
|
||||||
|
..baseline["dir/a.txt"].clone()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let rules = rules_for(&["build/"]);
|
||||||
|
let ignored = mirror_tree_unsynced(&root, &baseline, &rules);
|
||||||
|
let tracked = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert_eq!(ignored, None);
|
||||||
|
assert!(tracked.unwrap().contains("build/out.o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_edit_after_the_last_pass_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-edit");
|
||||||
|
std::fs::write(root.join("dir/sub/b.txt"), b"edited since").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("dir/sub/b.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_file_at_the_root_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-new");
|
||||||
|
std::fs::write(root.join("new.txt"), b"n").unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("new.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folder B196 now leaves out of the pass instead of failing it is,
|
||||||
|
/// by the same token, something the eviction must not delete.
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_folder_holds_the_switch_back() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let (root, baseline) = synced_subtree("evict-eacces");
|
||||||
|
let locked = root.join("dir/sub");
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("dir/sub"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_symlink_holds_the_switch_back() {
|
||||||
|
let (root, baseline) = synced_subtree("evict-symlink");
|
||||||
|
std::os::unix::fs::symlink("dir/a.txt", root.join("link")).unwrap();
|
||||||
|
let reason = mirror_tree_unsynced(&root, &baseline, &rules_for(&[]));
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
assert!(reason.unwrap().contains("link"));
|
||||||
|
}
|
||||||
|
|
||||||
/// The same rule one level up: if the sync root itself cannot be read, the
|
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||||
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2607,7 +3054,14 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
let result = walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress,
|
||||||
|
);
|
||||||
|
|
||||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
@@ -2628,30 +3082,40 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut progress = no_progress();
|
let mut progress = no_progress();
|
||||||
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
assert!(
|
||||||
|
walk_local_tree(
|
||||||
|
&root,
|
||||||
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut progress
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
assert!(out.is_empty());
|
assert!(out.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
/// A name that is not valid UTF-8 cannot be keyed into the scan, but it
|
||||||
/// cannot be compared against the baseline either. Refusing the pass is the
|
/// cannot be in the baseline or on Drive either, so leaving it out reads as
|
||||||
/// only safe reading: skipping it would classify the file as deleted.
|
/// the deletion of nothing. It used to fail the pass (B196).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_non_utf8_name_fails_the_scan() {
|
fn a_non_utf8_name_is_left_out_and_reported() {
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::os::unix::ffi::OsStrExt as _;
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
let root = sync_test_dir("scan-non-utf8");
|
let root = sync_test_dir("scan-non-utf8");
|
||||||
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||||
|
std::fs::write(root.join("good.txt"), b"y").unwrap();
|
||||||
|
|
||||||
let rules = rules_for(&[]);
|
let result = scan(&root);
|
||||||
let writing = HashSet::new();
|
|
||||||
let mut out = HashMap::new();
|
|
||||||
let mut progress = no_progress();
|
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
let (out, unscanned) = result.unwrap();
|
||||||
assert!(error.contains("UTF-8"), "{error}");
|
assert_eq!(sorted_keys(&out), vec!["good.txt"]);
|
||||||
|
let report = unscanned.report();
|
||||||
|
assert_eq!(report.len(), 1);
|
||||||
|
assert!(report[0].contains("UTF-8"), "{report:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The complement: a scan that *can* read everything reports everything, so
|
/// The complement: a scan that *can* read everything reports everything, so
|
||||||
@@ -2670,9 +3134,14 @@ mod tests {
|
|||||||
let writing = HashSet::new();
|
let writing = HashSet::new();
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut seen: Vec<String> = Vec::new();
|
let mut seen: Vec<String> = Vec::new();
|
||||||
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
let result = walk_local_tree(
|
||||||
seen.push(rel.to_string())
|
&root,
|
||||||
});
|
&rules,
|
||||||
|
&writing,
|
||||||
|
&mut out,
|
||||||
|
&mut Unscanned::default(),
|
||||||
|
&mut |rel| seen.push(rel.to_string()),
|
||||||
|
);
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
result.unwrap();
|
result.unwrap();
|
||||||
|
|||||||
@@ -33,7 +33,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +57,10 @@ pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::Jo
|
|||||||
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||||
// 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 watched_events = watched.clone();
|
||||||
let mut watcher =
|
let mut watcher =
|
||||||
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
let Ok(event) = res else { return };
|
let Ok(event) = res else { return };
|
||||||
@@ -67,7 +68,8 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
if matches!(event.kind, notify::EventKind::Access(_)) {
|
if matches!(event.kind, notify::EventKind::Access(_)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = tx_events.send(SyncMsg::ReconcileAll);
|
let msg = event_target(&watched_events.lock(), &event.paths);
|
||||||
|
let _ = tx_events.send(msg);
|
||||||
}) {
|
}) {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -256,8 +258,31 @@ fn reconcile_all(core: &Core) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reconcile a watcher event asks for: the one mirror folder all its paths
|
||||||
|
/// lie in, so an edit in one folder does not walk every other one too. An event
|
||||||
|
/// that spans folders, or that no watched folder claims (a watch list a moment
|
||||||
|
/// out of date), falls back to every folder.
|
||||||
|
pub(super) fn event_target(watched: &[(PathBuf, i64)], paths: &[PathBuf]) -> SyncMsg {
|
||||||
|
let mut ids = paths.iter().map(|path| {
|
||||||
|
watched
|
||||||
|
.iter()
|
||||||
|
.filter(|(root, _)| path.starts_with(root))
|
||||||
|
// The deepest root, should one mirror folder sit inside another.
|
||||||
|
.max_by_key(|(root, _)| root.components().count())
|
||||||
|
.map(|(_, id)| *id)
|
||||||
|
});
|
||||||
|
match ids.next() {
|
||||||
|
Some(Some(first)) if ids.all(|id| id == Some(first)) => SyncMsg::Reconcile(first),
|
||||||
|
_ => SyncMsg::ReconcileAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bring the filesystem watches in line with the current mirror-folder set:
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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,
|
||||||
@@ -274,7 +299,7 @@ fn rewatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut have = watched.lock();
|
let mut have = watched.lock().clone();
|
||||||
// Drop watches no longer wanted.
|
// Drop watches no longer wanted.
|
||||||
have.retain(|(path, _)| {
|
have.retain(|(path, _)| {
|
||||||
if want.iter().any(|(p, _)| p == path) {
|
if want.iter().any(|(p, _)| p == path) {
|
||||||
@@ -294,6 +319,7 @@ fn rewatch(
|
|||||||
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
Err(e) => warn!(path = %path.display(), error = %e, "sync: watch failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*watched.lock() = have;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 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;
|
||||||
@@ -98,6 +98,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 {
|
||||||
@@ -161,7 +166,7 @@ pub(super) enum Pending {
|
|||||||
/// 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,
|
||||||
|
|||||||
@@ -90,6 +90,96 @@ pub(super) fn filter_baseline<'a, B>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The paths a local scan left out because it could not read them: a folder
|
||||||
|
/// it has no permission for, an entry that vanished between the listing and
|
||||||
|
/// its `stat`, a name that is not UTF-8.
|
||||||
|
///
|
||||||
|
/// A left-out path is not a deleted one. A pass drops everything an entry
|
||||||
|
/// covers from classification, exactly as it drops ignored paths, so a
|
||||||
|
/// baseline row under an unreadable folder is neither read as a local deletion
|
||||||
|
/// nor answered with a download into a folder the pass cannot write. Failing
|
||||||
|
/// the whole pass instead is what B55 did, and it left one root-owned folder
|
||||||
|
/// stopping every other file in the tree from syncing (B196).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Unscanned {
|
||||||
|
covered: HashSet<String>,
|
||||||
|
/// The subset worth telling the user about, with why: an entry that only
|
||||||
|
/// vanished mid-scan is not, since the next pass sees it settled.
|
||||||
|
unreadable: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unscanned {
|
||||||
|
/// Leave out `rel`, which vanished while the scan looked at it.
|
||||||
|
pub(super) fn vanished(&mut self, rel: String) {
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave out `rel`, which cannot be read for `reason`.
|
||||||
|
pub(super) fn unreadable(&mut self, rel: String, reason: &str) {
|
||||||
|
self.unreadable.push(format!("{rel} ({reason})"));
|
||||||
|
self.covered.insert(rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `rel` is a left-out path or lies under one.
|
||||||
|
pub(super) fn covers(&self, rel: &str) -> bool {
|
||||||
|
if self.covered.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut at = rel;
|
||||||
|
loop {
|
||||||
|
if self.covered.contains(at) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match at.rfind('/') {
|
||||||
|
Some(i) => at = &at[..i],
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unreadable paths with their reasons, sorted, for the activity feed.
|
||||||
|
pub(super) fn report(&self) -> Vec<String> {
|
||||||
|
let mut lines = self.unreadable.clone();
|
||||||
|
lines.sort_unstable();
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a pass can see: [`filter_baseline`], minus everything
|
||||||
|
/// under a path the scan could not read. This is the set [`guard_local_wipe`]
|
||||||
|
/// checks and the set a push pass reads deletions from, since a path outside
|
||||||
|
/// it is absent from the local walk by rule or by failure, never by loss.
|
||||||
|
pub(super) fn tracked_baseline<'a, B>(
|
||||||
|
baseline: &'a HashMap<String, B>,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
unscanned: &Unscanned,
|
||||||
|
) -> HashMap<String, &'a B> {
|
||||||
|
let mut tracked = filter_baseline(baseline, rules);
|
||||||
|
tracked.retain(|rel, _| !unscanned.covers(rel));
|
||||||
|
tracked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline paths a push pass has to trash on Drive: tracked, and gone
|
||||||
|
/// from the local walk. Shallowest first, so a trashed folder takes its
|
||||||
|
/// children with it.
|
||||||
|
///
|
||||||
|
/// Built from [`tracked_baseline`] rather than from the raw baseline. Reading
|
||||||
|
/// it raw trashed every synced path that later became ignored, because the walk
|
||||||
|
/// skips those, and the switch to on-demand that the push pass runs for then
|
||||||
|
/// deleted the local copy too (B197).
|
||||||
|
pub(super) fn local_deletions<B, L>(
|
||||||
|
tracked: &HashMap<String, &B>,
|
||||||
|
local: &HashMap<String, L>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut missing: Vec<String> = tracked
|
||||||
|
.keys()
|
||||||
|
.filter(|rel| !local.contains_key(*rel))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
missing.sort_by_key(|p| p.matches('/').count());
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
/// Refuse to run a pass whose local side has vanished in its entirety.
|
/// Refuse to run a pass whose local side has vanished in its entirety.
|
||||||
///
|
///
|
||||||
/// When every baseline path is absent locally, the likely cause is an unavailable
|
/// When every baseline path is absent locally, the likely cause is an unavailable
|
||||||
@@ -218,7 +308,7 @@ 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)))
|
||||||
@@ -291,7 +381,7 @@ mod file_plan_tests {
|
|||||||
|
|
||||||
/// 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);
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ 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() {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -160,12 +162,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 +206,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()
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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 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::*;
|
||||||
@@ -119,6 +120,9 @@ 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>,
|
||||||
/// 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>,
|
||||||
@@ -199,12 +203,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.
|
||||||
@@ -300,6 +312,11 @@ 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();
|
let stack = adw::ViewStack::new();
|
||||||
let (login_page, login_widgets) = build_login_page();
|
let (login_page, login_widgets) = build_login_page();
|
||||||
@@ -362,6 +379,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
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(auth::load().ok()),
|
||||||
|
unlock_offered: Cell::new(false),
|
||||||
mounted: RefCell::new(false),
|
mounted: RefCell::new(false),
|
||||||
sidebar: sidebar_list.clone(),
|
sidebar: sidebar_list.clone(),
|
||||||
nav: split.clone(),
|
nav: split.clone(),
|
||||||
@@ -520,6 +538,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 +580,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(),
|
||||||
@@ -813,7 +834,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)
|
||||||
@@ -1136,25 +1156,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 +1208,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 +1293,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 +1354,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(),
|
||||||
|
|||||||
@@ -520,9 +520,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 +530,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 +540,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 +647,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 +696,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 +844,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 +872,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 || {
|
||||||
@@ -908,9 +924,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| {
|
||||||
@@ -1147,6 +1162,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 +1182,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 +1239,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 +1254,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");
|
||||||
@@ -2494,16 +2506,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 +2927,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(),
|
||||||
@@ -2972,6 +2988,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 +3016,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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,6 +439,10 @@ 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("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 +487,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 +496,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");
|
||||||
@@ -1096,6 +1093,67 @@ 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 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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -128,7 +128,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 +150,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,6 +227,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 +244,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 +261,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 +316,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()))
|
||||||
},
|
},
|
||||||
|
|attempt| {
|
||||||
|
let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>();
|
||||||
|
mailbox_req_tx
|
||||||
|
.send_blocking((attempt, answer_tx))
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?;
|
||||||
|
let (password, remember) = answer_rx
|
||||||
|
.recv()
|
||||||
|
.map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?;
|
||||||
|
Ok(auth::Mailbox {
|
||||||
|
password: password.into(),
|
||||||
|
remember,
|
||||||
|
})
|
||||||
|
},
|
||||||
|hv| {
|
|hv| {
|
||||||
// Gated: hand the UI the page to show, block on the token the
|
// Gated: hand the UI the challenge, block on the credential
|
||||||
// user earns by solving it.
|
// the user earns by solving it.
|
||||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
let (credential_tx, credential_rx) =
|
||||||
hv_req_tx
|
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||||
.send_blocking((hv.verification_url(), token_tx))
|
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||||
.map_err(|_| {
|
|
||||||
pdfs_core::Error::Other("verification prompt closed".into())
|
pdfs_core::Error::Other("verification prompt closed".into())
|
||||||
})?;
|
})?;
|
||||||
let token = token_rx
|
credential_rx
|
||||||
.recv()
|
.recv()
|
||||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||||
Ok(HumanVerificationCredential::captcha(token))
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -287,7 +347,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 +526,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.
|
||||||
@@ -374,6 +627,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());
|
||||||
|
|||||||
@@ -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()>>>,
|
||||||
@@ -3341,6 +3344,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,
|
||||||
|
|||||||
@@ -484,9 +484,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 +493,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");
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
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 +573,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 +582,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 +622,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(),
|
||||||
|
|||||||
@@ -381,23 +381,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");
|
||||||
@@ -825,6 +836,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
|
||||||
@@ -860,6 +878,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 +958,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 +1230,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 +1243,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,
|
||||||
@@ -1282,6 +1312,20 @@ pub(crate) fn paint_disconnected(ui: &Rc<Ui>) {
|
|||||||
|
|
||||||
/// 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 {
|
||||||
@@ -1316,7 +1360,44 @@ pub(crate) enum SyncState {
|
|||||||
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::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"),
|
||||||
@@ -1387,6 +1468,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 +1538,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);
|
||||||
|
|||||||
@@ -524,6 +524,7 @@ mod tests {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified,
|
modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
pinned_by_folder: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: name.into(),
|
uid: name.into(),
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! The human-verification (CAPTCHA) dialog.
|
//! The human-verification (CAPTCHA) window.
|
||||||
//!
|
//!
|
||||||
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
//! Proton gates logins it does not recognise — a new IP, a VPN exit — behind a
|
||||||
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
//! CAPTCHA. The API answers the login with a challenge instead of a session, and
|
||||||
@@ -8,10 +8,26 @@
|
|||||||
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
//! The page is hosted in a `WebKitWebView` rather than handed to the system
|
||||||
//! browser because it reports completion by posting a message to its host, not
|
//! browser because it reports completion by posting a message to its host, not
|
||||||
//! by redirecting: a browser has nowhere to post it back to.
|
//! by redirecting: a browser has nowhere to post it back to.
|
||||||
|
//!
|
||||||
|
//! The webview runs in a child `pdfs-app` started with [`VERIFY_ARG`], not in the
|
||||||
|
//! app itself. WebKit aborts its whole process when its sandbox cannot start, as
|
||||||
|
//! on hardened systems that keep bubblewrap from the journal socket
|
||||||
|
//! (`docs/BUGS.md` B195); in a child that costs the verification, not the app.
|
||||||
|
//! The URL goes to the child on stdin, so it never shows in a process list, and
|
||||||
|
//! the token comes back on stdout.
|
||||||
|
|
||||||
use crate::*;
|
use crate::*;
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::process::{ExitStatus, Stdio};
|
||||||
use webkit6::prelude::*;
|
use webkit6::prelude::*;
|
||||||
|
|
||||||
|
/// The argument that makes `pdfs-app` the verification window instead of the app.
|
||||||
|
pub(crate) const VERIFY_ARG: &str = "--human-verification";
|
||||||
|
|
||||||
|
/// Marks the token among whatever else lands on the child's stdout: GLib and
|
||||||
|
/// WebKit write debug output there.
|
||||||
|
const TOKEN_PREFIX: &str = "hv-token ";
|
||||||
|
|
||||||
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
/// Bridge the verification page's `postMessage` to a handler this side can read.
|
||||||
///
|
///
|
||||||
/// The page targets its embedding host (`window.parent`), which in a webview is
|
/// The page targets its embedding host (`window.parent`), which in a webview is
|
||||||
@@ -26,15 +42,100 @@ window.addEventListener('message', function (event) {
|
|||||||
});
|
});
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Present the challenge and hand the solved token back through `token_tx`.
|
/// How the verification window ended.
|
||||||
///
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
/// Closing the dialog without solving drops the sender, which the waiting login
|
pub(crate) enum Verification {
|
||||||
/// worker reads as a cancelled sign-in — the same contract as the 2FA prompt.
|
Solved(String),
|
||||||
pub(crate) fn prompt_human_verification(
|
/// Closed without solving.
|
||||||
ui: &Rc<Ui>,
|
Cancelled,
|
||||||
url: &str,
|
/// Never came up, or died: most likely WebKit could not start its sandbox.
|
||||||
token_tx: std::sync::mpsc::Sender<String>,
|
Failed,
|
||||||
) {
|
}
|
||||||
|
|
||||||
|
/// Show the challenge in the verification window and wait until it closes.
|
||||||
|
pub(crate) async fn verify_human(url: String) -> Verification {
|
||||||
|
match gio::spawn_blocking(move || run_verification_child(&url)).await {
|
||||||
|
Ok(Ok(verification)) => verification,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::error!("cannot run the verification window: {e}");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
Err(_) => Verification::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_verification_child(url: &str) -> std::io::Result<Verification> {
|
||||||
|
let mut child = Command::new(own_exe())
|
||||||
|
.arg(VERIFY_ARG)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
// A failed write means the child is already gone; its exit status says
|
||||||
|
// how. Dropping `stdin` closes it.
|
||||||
|
let _ = writeln!(stdin, "{url}");
|
||||||
|
}
|
||||||
|
// Stops reading at the token rather than at EOF: WebKit's helper processes
|
||||||
|
// inherit the pipe and may hold it open a moment after the child quits.
|
||||||
|
let token = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.and_then(|out| find_token(BufReader::new(out)));
|
||||||
|
let status = child.wait()?;
|
||||||
|
Ok(outcome(token, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This very binary, so a development build opens its own window. After an
|
||||||
|
/// upgrade replaced the file, `current_exe` names a deleted path, and the
|
||||||
|
/// installed `pdfs-app` is the next best.
|
||||||
|
fn own_exe() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.filter(|path| path.is_file())
|
||||||
|
.unwrap_or_else(|| PathBuf::from("pdfs-app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_token(out: impl BufRead) -> Option<String> {
|
||||||
|
out.lines()
|
||||||
|
.map_while(Result::ok)
|
||||||
|
.find_map(|line| line.strip_prefix(TOKEN_PREFIX).map(str::to_owned))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token wins over the exit status: the user solved the page, even if
|
||||||
|
/// WebKit's teardown crashed after it.
|
||||||
|
fn outcome(token: Option<String>, status: ExitStatus) -> Verification {
|
||||||
|
match token {
|
||||||
|
Some(token) => Verification::Solved(token),
|
||||||
|
None if status.success() => Verification::Cancelled,
|
||||||
|
None => {
|
||||||
|
tracing::error!(%status, "the verification window failed");
|
||||||
|
Verification::Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run as the verification window: read the page's URL from stdin, show it, and
|
||||||
|
/// print the token once the user has solved it. Closing the window exits
|
||||||
|
/// without a token, which the app reads as a cancelled sign-in.
|
||||||
|
pub(crate) fn run_verification_window() -> glib::ExitCode {
|
||||||
|
let mut url = String::new();
|
||||||
|
if std::io::stdin().lock().read_line(&mut url).is_err() || url.trim().is_empty() {
|
||||||
|
tracing::error!("{VERIFY_ARG} expects the verification URL on stdin");
|
||||||
|
return glib::ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
let url = url.trim().to_owned();
|
||||||
|
|
||||||
|
// Not unique: the app that started it already holds APP_ID.
|
||||||
|
let app = adw::Application::builder()
|
||||||
|
.flags(gio::ApplicationFlags::NON_UNIQUE)
|
||||||
|
.build();
|
||||||
|
app.connect_activate(move |app| build_verification_window(app, &url));
|
||||||
|
// GApplication would refuse VERIFY_ARG as an unknown option.
|
||||||
|
let argv0 = std::env::args().next().unwrap_or_default();
|
||||||
|
app.run_with_args(&[argv0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_verification_window(app: &adw::Application, url: &str) {
|
||||||
let content = webkit6::UserContentManager::new();
|
let content = webkit6::UserContentManager::new();
|
||||||
content.add_script(&webkit6::UserScript::new(
|
content.add_script(&webkit6::UserScript::new(
|
||||||
BRIDGE,
|
BRIDGE,
|
||||||
@@ -53,24 +154,19 @@ pub(crate) fn prompt_human_verification(
|
|||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||||
|
body.append(&adw::HeaderBar::new());
|
||||||
|
body.append(&webview);
|
||||||
|
let window = adw::ApplicationWindow::builder()
|
||||||
|
.application(app)
|
||||||
.title(gettext("Verification"))
|
.title(gettext("Verification"))
|
||||||
.content_width(420)
|
.icon_name(APP_ID)
|
||||||
.content_height(560)
|
.default_width(420)
|
||||||
|
.default_height(560)
|
||||||
|
.content(&body)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let header = adw::HeaderBar::new();
|
let app = app.downgrade();
|
||||||
let body = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
||||||
body.append(&header);
|
|
||||||
body.append(&webview);
|
|
||||||
dialog.set_child(Some(&body));
|
|
||||||
|
|
||||||
// Taken on the first success *or* on close, so the token is sent at most
|
|
||||||
// once and a cancelled dialog always drops the sender.
|
|
||||||
let token_tx = Rc::new(RefCell::new(Some(token_tx)));
|
|
||||||
|
|
||||||
let tx = token_tx.clone();
|
|
||||||
let dlg_weak = dialog.downgrade();
|
|
||||||
content.connect_script_message_received(Some("hv"), move |_, value| {
|
content.connect_script_message_received(Some("hv"), move |_, value| {
|
||||||
let Some(token) = extract_token(&value.to_str()) else {
|
let Some(token) = extract_token(&value.to_str()) else {
|
||||||
// Every `postMessage` on the page reaches here, most of them the
|
// Every `postMessage` on the page reaches here, most of them the
|
||||||
@@ -78,25 +174,17 @@ pub(crate) fn prompt_human_verification(
|
|||||||
// is simply not ours.
|
// is simply not ours.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(tx) = tx.borrow_mut().take() {
|
let mut stdout = std::io::stdout().lock();
|
||||||
let _ = tx.send(token);
|
if let Err(e) = writeln!(stdout, "{TOKEN_PREFIX}{token}").and_then(|()| stdout.flush()) {
|
||||||
|
tracing::error!("cannot hand the verification token back: {e}");
|
||||||
}
|
}
|
||||||
if let Some(dlg) = dlg_weak.upgrade() {
|
if let Some(app) = app.upgrade() {
|
||||||
dlg.close();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Covers the close button, Escape, and the user giving up.
|
|
||||||
dialog.connect_closed(move |_| {
|
|
||||||
token_tx.borrow_mut().take();
|
|
||||||
});
|
|
||||||
|
|
||||||
webview.load_uri(url);
|
webview.load_uri(url);
|
||||||
|
window.present();
|
||||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
|
||||||
// Returns immediately; the login worker stays blocked on the channel until
|
|
||||||
// the handler above sends, or the dialog closes and drops the sender.
|
|
||||||
dialog.present(parent.as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull the verification token out of a message posted by the verification page.
|
/// Pull the verification token out of a message posted by the verification page.
|
||||||
@@ -133,7 +221,12 @@ fn extract_token(raw: &str) -> Option<String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::extract_token;
|
use super::{Verification, extract_token, find_token, outcome};
|
||||||
|
use std::os::unix::process::ExitStatusExt as _;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
/// The wait status of a process killed by SIGABRT.
|
||||||
|
const ABORTED: i32 = 6;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_completion_message_yields_its_token() {
|
fn a_completion_message_yields_its_token() {
|
||||||
@@ -163,4 +256,39 @@ mod tests {
|
|||||||
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
assert!(extract_token(raw).is_none(), "accepted: {raw}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_token_is_found_among_other_output() {
|
||||||
|
let out = "Gtk-Message: something\nhv-token tok-1\nmore\n";
|
||||||
|
assert_eq!(find_token(out.as_bytes()).as_deref(), Some("tok-1"));
|
||||||
|
assert_eq!(find_token("only chatter\n".as_bytes()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_closed_without_a_token_cancels() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(0)),
|
||||||
|
Verification::Cancelled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebKit aborts when its sandbox cannot start (B195). That must read as a
|
||||||
|
/// failure the app explains, not as the user giving up.
|
||||||
|
#[test]
|
||||||
|
fn a_window_that_died_fails() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(None, ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Failed
|
||||||
|
);
|
||||||
|
let exited_1 = ExitStatus::from_raw(1 << 8);
|
||||||
|
assert_eq!(outcome(None, exited_1), Verification::Failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_token_counts_even_if_the_window_died_after_it() {
|
||||||
|
assert_eq!(
|
||||||
|
outcome(Some("tok-1".into()), ExitStatus::from_raw(ABORTED)),
|
||||||
|
Verification::Solved("tok-1".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// The "›" between two breadcrumb segments.
|
||||||
|
pub(crate) fn crumb_separator() -> gtk4::Label {
|
||||||
|
let sep = gtk4::Label::new(Some("›"));
|
||||||
|
sep.add_css_class("dim-label");
|
||||||
|
sep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last breadcrumb segment, the folder on screen: a heading rather than a
|
||||||
|
/// button. It carries a flat button's side padding (`.crumb-current`), so the
|
||||||
|
/// "›" before it sits as far from it as from the button before.
|
||||||
|
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
|
||||||
|
let l = gtk4::Label::builder()
|
||||||
|
.label(label)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
||||||
|
.build();
|
||||||
|
l.add_css_class("heading");
|
||||||
|
l.add_css_class("crumb-current");
|
||||||
|
l.upcast()
|
||||||
|
}
|
||||||
@@ -384,11 +384,20 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else {
|
} else {
|
||||||
parent.to_string()
|
parent.to_string()
|
||||||
});
|
});
|
||||||
// Pinning is per file: the context menu and the selection bar offer it for
|
// Kept through a pinned folder above it, the entry has no pin of its own
|
||||||
// files only, so the pane must not offer it for folders either.
|
// to remove, so the switch shows that state without offering to change it.
|
||||||
d.pin_row.set_active(entry.pinned);
|
d.pin_row.set_active(entry.pinned);
|
||||||
d.pin_row.set_visible(!entry.is_dir);
|
d.pin_row.set_subtitle(&if entry.pinned_by_folder {
|
||||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
gettext("Kept on this computer because a folder it is in is available offline.")
|
||||||
|
} else if entry.is_dir {
|
||||||
|
gettext(
|
||||||
|
"Keep a copy of everything in this folder on this computer, even without a connection.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
gettext("Keep a copy on this computer, even without a connection.")
|
||||||
|
});
|
||||||
|
d.pin_row
|
||||||
|
.set_sensitive(*ui.mounted.borrow() && !entry.pinned_by_folder);
|
||||||
d.open_button.set_label(&if entry.is_dir {
|
d.open_button.set_label(&if entry.is_dir {
|
||||||
gettext("Open folder")
|
gettext("Open folder")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -132,8 +132,13 @@ impl FileList {
|
|||||||
// and the grid collapses to a single column. Allowing a mid-word
|
// and the grid collapses to a single column. Allowing a mid-word
|
||||||
// break is what keeps the two-line-then-ellipsis budget below
|
// break is what keeps the two-line-then-ellipsis budget below
|
||||||
// enforceable for *every* name rather than only the ones that
|
// enforceable for *every* name rather than only the ones that
|
||||||
// happen to have spaces.
|
// happen to have spaces. Pango marks such a break with a hyphen
|
||||||
|
// that is not in the name ("SteamSetup.e-xe"); a file name is
|
||||||
|
// not prose, so break it bare, as Nautilus does.
|
||||||
|
let no_hyphens = gtk4::pango::AttrList::new();
|
||||||
|
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
|
||||||
let label = gtk4::Label::builder()
|
let label = gtk4::Label::builder()
|
||||||
|
.attributes(&no_hyphens)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
.justify(gtk4::Justification::Center)
|
.justify(gtk4::Justification::Center)
|
||||||
.max_width_chars(13)
|
.max_width_chars(13)
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ impl ActionMenu {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A greyed-out check item showing `active`, for a state the user cannot
|
||||||
|
/// change from here.
|
||||||
|
pub(crate) fn locked_toggle(&mut self, label: &str, active: bool) -> &mut Self {
|
||||||
|
let name = self.next_action();
|
||||||
|
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||||
|
action.set_enabled(false);
|
||||||
|
self.group.add_action(&action);
|
||||||
|
self.section
|
||||||
|
.append(Some(label), Some(&format!("menu.{name}")));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Close the current section; the next item starts a new one below a
|
/// Close the current section; the next item starts a new one below a
|
||||||
/// separator. An empty section is skipped, so callers can end a group of
|
/// separator. An empty section is skipped, so callers can end a group of
|
||||||
/// conditional items unconditionally.
|
/// conditional items unconditionally.
|
||||||
@@ -128,20 +140,34 @@ impl ActionMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
/// Where a menu opened over `anchor` should live in the widget tree.
|
||||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
///
|
||||||
/// selectors reach through a popover to its parent, and themes style the
|
/// Not inside a list item: CSS descendant selectors reach through a popover to
|
||||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
/// its parent, and themes style the insides of list items (`gridview > child
|
||||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
|
||||||
/// margin around every section. Under the view itself it is no item's child.
|
/// rules up and puts a wide margin around every section. So the search starts
|
||||||
|
/// at the outermost grid or list view around `anchor`.
|
||||||
|
///
|
||||||
|
/// Not on a widget without a layout manager either: a popover is sized and
|
||||||
|
/// placed again when its parent's layout manager allocates it. The list views
|
||||||
|
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
|
||||||
|
/// first given, often too small, and opened scrolling. The host is the first
|
||||||
|
/// widget from there up that has one.
|
||||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||||
let mut host = anchor.clone();
|
let mut start = anchor.clone();
|
||||||
let mut widget = anchor.parent();
|
let mut widget = anchor.parent();
|
||||||
while let Some(w) = widget {
|
while let Some(w) = widget {
|
||||||
widget = w.parent();
|
widget = w.parent();
|
||||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||||
host = w;
|
start = w;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
host
|
let mut host = Some(start.clone());
|
||||||
|
while let Some(w) = host {
|
||||||
|
if w.layout_manager().is_some() {
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
host = w.parent();
|
||||||
|
}
|
||||||
|
start
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub(crate) mod compat;
|
pub(crate) mod compat;
|
||||||
|
pub(crate) mod crumb;
|
||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
pub(crate) mod file_list;
|
pub(crate) mod file_list;
|
||||||
pub(crate) mod menu;
|
pub(crate) mod menu;
|
||||||
|
|||||||
@@ -127,13 +127,33 @@ impl Drop for Busy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A section's action, such as Send Invitations. It goes under the section's
|
||||||
|
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
|
||||||
|
/// list reads as a setting, and a button alone in one looks misplaced.
|
||||||
|
fn group_button(label: &str) -> gtk4::Button {
|
||||||
|
let button = gtk4::Button::builder()
|
||||||
|
.label(label)
|
||||||
|
.halign(gtk4::Align::End)
|
||||||
|
.margin_top(12)
|
||||||
|
.build();
|
||||||
|
button.add_css_class("suggested-action");
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section's only row while the share is being read.
|
||||||
|
fn loading_row() -> adw::ActionRow {
|
||||||
|
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
|
||||||
|
row.add_prefix(&spinner());
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
/// How an open Share dialog addresses its node.
|
/// How an open Share dialog addresses its node.
|
||||||
///
|
///
|
||||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||||
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||||
/// own inode space, or on someone else's volume, neither of which the primary
|
/// own inode space, or on someone else's volume, neither of which the primary
|
||||||
/// mount's path space can name. Those carry a uid instead, and the daemon
|
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||||
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
/// resolves it with `Core::resolve_anywhere`.
|
||||||
///
|
///
|
||||||
/// The two are separate request variants rather than one request with an
|
/// The two are separate request variants rather than one request with an
|
||||||
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||||
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.selected(0)
|
.selected(0)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
// The role goes with the addresses it applies to, at the end of their row.
|
||||||
role_wrap.add_suffix(&role_drop);
|
role_drop.set_tooltip_text(Some(&gettext("Role")));
|
||||||
|
email_row.add_suffix(&role_drop);
|
||||||
let message_row = adw::EntryRow::builder()
|
let message_row = adw::EntryRow::builder()
|
||||||
.title(gettext("Message (optional)"))
|
.title(gettext("Message (optional)"))
|
||||||
.build();
|
.build();
|
||||||
let invite_btn = gtk4::Button::builder()
|
let invite_btn = group_button(&gettext("Send Invitations"));
|
||||||
.label(gettext("Send Invitations"))
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.margin_top(6)
|
|
||||||
.build();
|
|
||||||
invite_btn.add_css_class("suggested-action");
|
|
||||||
invite_btn.set_sensitive(false);
|
invite_btn.set_sensitive(false);
|
||||||
let invite_wrap = adw::PreferencesRow::builder()
|
|
||||||
.activatable(false)
|
|
||||||
.child(&invite_btn)
|
|
||||||
.build();
|
|
||||||
invite_group.add(&email_row);
|
invite_group.add(&email_row);
|
||||||
invite_group.add(&role_wrap);
|
|
||||||
invite_group.add(&message_row);
|
invite_group.add(&message_row);
|
||||||
invite_group.add(&invite_wrap);
|
invite_group.add(&invite_btn);
|
||||||
|
|
||||||
let people = adw::PreferencesGroup::builder()
|
let people = adw::PreferencesGroup::builder()
|
||||||
.title(gettext("People with access"))
|
.title(gettext("People with access"))
|
||||||
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.title(gettext("Public link"))
|
.title(gettext("Public link"))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
// Until the share is read, each section says so rather than standing
|
||||||
content.set_margin_top(12);
|
// empty under its title.
|
||||||
content.set_margin_bottom(12);
|
let people_loading = loading_row();
|
||||||
content.set_margin_start(12);
|
people.add(&people_loading);
|
||||||
content.set_margin_end(12);
|
let link_loading = loading_row();
|
||||||
content.append(&invite_group);
|
link_group.add(&link_loading);
|
||||||
content.append(&people);
|
|
||||||
content.append(&link_group);
|
let content = adw::PreferencesPage::new();
|
||||||
let clamp = adw::Clamp::builder().child(&content).build();
|
content.add(&invite_group);
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
content.add(&people);
|
||||||
.vexpand(true)
|
content.add(&link_group);
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
toolbar.set_content(Some(&content));
|
||||||
.child(&clamp)
|
|
||||||
.build();
|
|
||||||
toolbar.set_content(Some(&scroll));
|
|
||||||
|
|
||||||
let dialog = adw::Dialog::builder()
|
let dialog = adw::Dialog::builder()
|
||||||
.title(pgettext("verb", "Share"))
|
.title(pgettext("verb", "Share"))
|
||||||
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
target,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
people_rows: RefCell::new(vec![people_loading.upcast()]),
|
||||||
link_rows: RefCell::new(Vec::new()),
|
link_rows: RefCell::new(vec![link_loading.upcast()]),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check the addresses as they are typed: a malformed one marks the row as
|
// Check the addresses as they are typed: a malformed one marks the row as
|
||||||
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
|||||||
repaint_share_link(&state, link.as_ref());
|
repaint_share_link(&state, link.as_ref());
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, .. })) => {
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
share_dialog_unread(&state);
|
||||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||||
}
|
}
|
||||||
_ => toast_error(
|
_ => {
|
||||||
|
share_dialog_unread(&state);
|
||||||
|
toast_error(
|
||||||
&state.ui,
|
&state.ui,
|
||||||
&gettext("Couldn't load sharing"),
|
&gettext("Couldn't load sharing"),
|
||||||
&gettext("The Proton Drive service didn't respond."),
|
&gettext("The Proton Drive service didn't respond."),
|
||||||
),
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The share couldn't be read: what the sections showed (rows still loading,
|
||||||
|
/// or ones that may now be stale) gives way to one line that says so.
|
||||||
|
fn share_dialog_unread(state: &Rc<ShareDialog>) {
|
||||||
|
for row in state.people_rows.borrow_mut().drain(..) {
|
||||||
|
state.people.remove(&row);
|
||||||
|
}
|
||||||
|
for row in state.link_rows.borrow_mut().drain(..) {
|
||||||
|
state.link_group.remove(&row);
|
||||||
|
}
|
||||||
|
let row = dim_row(&gettext("Couldn't load sharing"));
|
||||||
|
state.people.add(&row);
|
||||||
|
state.people_rows.borrow_mut().push(row.upcast());
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuild the "People with access" rows from a fresh share listing.
|
/// Rebuild the "People with access" rows from a fresh share listing.
|
||||||
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
|
||||||
for row in state.people_rows.borrow_mut().drain(..) {
|
for row in state.people_rows.borrow_mut().drain(..) {
|
||||||
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
// A member is the plain case and needs no word; an invitation says
|
||||||
|
// it is still waiting on the other side.
|
||||||
let subtitle = match entry.kind {
|
let subtitle = match entry.kind {
|
||||||
ShareEntryKind::Member => gettext("Member"),
|
ShareEntryKind::Member => String::new(),
|
||||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
|
||||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
|
||||||
};
|
};
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&entry.email)
|
.title(&entry.email)
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
.build();
|
.build();
|
||||||
|
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
|
||||||
|
|
||||||
// External invites can't have their role changed; show it read-only.
|
// External invites can't have their role changed; show it read-only.
|
||||||
// Members and Proton invites get a role dropdown.
|
// Members and Proton invites get a role dropdown.
|
||||||
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
.build();
|
.build();
|
||||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||||
expiry_row.add_suffix(&expiry_drop);
|
expiry_row.add_suffix(&expiry_drop);
|
||||||
let create = gtk4::Button::builder()
|
let create = group_button(&gettext("Create Public Link"));
|
||||||
.label(gettext("Create Public Link"))
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.margin_top(6)
|
|
||||||
.build();
|
|
||||||
create.add_css_class("suggested-action");
|
|
||||||
let create_wrap = adw::PreferencesRow::builder()
|
|
||||||
.activatable(false)
|
|
||||||
.child(&create)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Enter in the password field creates the link.
|
// Enter in the password field creates the link.
|
||||||
let btn = create.clone();
|
let btn = create.clone();
|
||||||
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
state.link_group.add(&role_row);
|
state.link_group.add(&role_row);
|
||||||
state.link_group.add(&pw_row);
|
state.link_group.add(&pw_row);
|
||||||
state.link_group.add(&expiry_row);
|
state.link_group.add(&expiry_row);
|
||||||
state.link_group.add(&create_wrap);
|
state.link_group.add(&create);
|
||||||
rows.push(role_row.upcast());
|
rows.push(role_row.upcast());
|
||||||
rows.push(pw_row.upcast());
|
rows.push(pw_row.upcast());
|
||||||
rows.push(expiry_row.upcast());
|
rows.push(expiry_row.upcast());
|
||||||
rows.push(create_wrap.upcast());
|
rows.push(create.upcast());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*state.link_rows.borrow_mut() = rows;
|
*state.link_rows.borrow_mut() = rows;
|
||||||
|
|||||||
@@ -77,13 +77,16 @@ pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
/// The sync badge of a Drive file, as in the app's browser: pinned, cached,
|
||||||
/// or online only. Folders and local files have none.
|
/// or online only. A folder has one only when kept offline; local files have
|
||||||
|
/// none.
|
||||||
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> {
|
||||||
let Hit::Drive(drive) = hit else {
|
let Hit::Drive(drive) = hit else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if drive.is_dir {
|
if drive.is_dir {
|
||||||
return None;
|
return drive
|
||||||
|
.pinned
|
||||||
|
.then_some(("pdfs-offline-symbolic", "badge-pinned"));
|
||||||
}
|
}
|
||||||
Some(if drive.pinned {
|
Some(if drive.pinned {
|
||||||
("pdfs-offline-symbolic", "badge-pinned")
|
("pdfs-offline-symbolic", "badge-pinned")
|
||||||
|
|||||||
+172
-21
@@ -22,7 +22,8 @@ use ksni::{MenuItem, Status, ToolTip, Tray};
|
|||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, follow, send,
|
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
|
||||||
|
follow, send,
|
||||||
};
|
};
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ enum Phase {
|
|||||||
Offline,
|
Offline,
|
||||||
/// Changes keep failing; the user has to look.
|
/// Changes keep failing; the user has to look.
|
||||||
Attention,
|
Attention,
|
||||||
|
/// Mounted, but Proton refused the session: only signing in again helps.
|
||||||
|
SessionExpired,
|
||||||
/// Signed in, but no mount daemon is running.
|
/// Signed in, but no mount daemon is running.
|
||||||
Disconnected,
|
Disconnected,
|
||||||
SignedOut,
|
SignedOut,
|
||||||
@@ -60,6 +63,7 @@ impl Phase {
|
|||||||
Phase::Paused => "media-playback-pause-symbolic",
|
Phase::Paused => "media-playback-pause-symbolic",
|
||||||
Phase::Offline => "network-offline-symbolic",
|
Phase::Offline => "network-offline-symbolic",
|
||||||
Phase::Attention => "dialog-warning-symbolic",
|
Phase::Attention => "dialog-warning-symbolic",
|
||||||
|
Phase::SessionExpired => "dialog-password-symbolic",
|
||||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +87,17 @@ struct DriveState {
|
|||||||
paused: bool,
|
paused: bool,
|
||||||
/// Queued changes that keep failing, for the "N issues" item.
|
/// Queued changes that keep failing, for the "N issues" item.
|
||||||
failing: u64,
|
failing: u64,
|
||||||
|
/// Whether the daemon is an older release than the tray, left running by
|
||||||
|
/// an upgrade; offers the restart that finishes it.
|
||||||
|
outdated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||||
/// transfer names the file and its percentage; several collapse to counts so the
|
/// transfer names the file and its percentage; several collapse to counts so the
|
||||||
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
|
||||||
/// running job speaks for itself — a scan or an index rebuild is still "busy",
|
/// running job speaks for itself — a scan or an index rebuild is still work
|
||||||
/// and the tray saying nothing there reads as "finished".
|
/// going on, and the menu saying nothing there reads as "finished". The icon is
|
||||||
|
/// [`busy`]'s call, not this line's.
|
||||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||||
match items {
|
match items {
|
||||||
[] => match jobs.first() {
|
[] => match jobs.first() {
|
||||||
@@ -143,12 +151,12 @@ struct DriveTray {
|
|||||||
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||||
match send(socket, &Request::Status) {
|
match send(socket, &Request::Status) {
|
||||||
Ok(status @ Response::Status { .. }) => {
|
Ok(status @ Response::Status { .. }) => {
|
||||||
// Same daemon is up, so a cheap follow-up request gives the sync line.
|
// Same daemon is up, so a cheap follow-up request says what moves.
|
||||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
|
||||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
Ok(Response::Transfers { items, jobs }) => (items, jobs),
|
||||||
_ => String::new(),
|
_ => (Vec::new(), Vec::new()),
|
||||||
};
|
};
|
||||||
status_state(&status, sync, default_mountpoint)
|
status_state(&status, &items, &jobs, default_mountpoint)
|
||||||
}
|
}
|
||||||
// Socket answered but with something unexpected — treat as up but odd.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => unexpected_reply(default_mountpoint),
|
Ok(_) => unexpected_reply(default_mountpoint),
|
||||||
@@ -162,10 +170,20 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
},
|
},
|
||||||
// No daemon: describe login state instead so the menu is still useful.
|
// No daemon: describe login state instead so the menu is still useful.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let (line, phase) = match auth::load() {
|
let (line, phase) = match auth::load() {
|
||||||
|
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
|
||||||
|
// Translators: {username} is the Proton account name. The
|
||||||
|
// account has a mailbox password that has to be entered.
|
||||||
|
gettext_f(
|
||||||
|
"Signed in as {username} — locked",
|
||||||
|
&[("username", &s.username)],
|
||||||
|
),
|
||||||
|
Phase::Attention,
|
||||||
|
),
|
||||||
Ok(s) => (
|
Ok(s) => (
|
||||||
// Translators: {username} is the Proton account name.
|
// Translators: {username} is the Proton account name.
|
||||||
gettext_f(
|
gettext_f(
|
||||||
@@ -189,6 +207,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,12 +224,18 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
|
|||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
paused: false,
|
paused: false,
|
||||||
failing: 0,
|
failing: 0,
|
||||||
|
outdated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The state a running daemon's [`Response::Status`] describes, with `sync`
|
/// The state a running daemon's [`Response::Status`] describes, with `items`
|
||||||
/// saying what it is moving right now.
|
/// and `jobs` saying what it is working on right now.
|
||||||
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
|
fn status_state(
|
||||||
|
status: &Response,
|
||||||
|
items: &[TransferItem],
|
||||||
|
jobs: &[JobItem],
|
||||||
|
default_mountpoint: &Path,
|
||||||
|
) -> DriveState {
|
||||||
let Response::Status {
|
let Response::Status {
|
||||||
mountpoint,
|
mountpoint,
|
||||||
pinned,
|
pinned,
|
||||||
@@ -219,6 +244,9 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
pending_changes,
|
pending_changes,
|
||||||
failing_ops,
|
failing_ops,
|
||||||
paused,
|
paused,
|
||||||
|
session_expired,
|
||||||
|
tokens_unsaved,
|
||||||
|
version,
|
||||||
..
|
..
|
||||||
} = status
|
} = status
|
||||||
else {
|
else {
|
||||||
@@ -226,14 +254,29 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
};
|
};
|
||||||
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
|
||||||
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
|
||||||
let phase = phase_of(
|
let outdated = daemon_outdated(version);
|
||||||
|
let phase = if *session_expired {
|
||||||
|
Phase::SessionExpired
|
||||||
|
} else if *tokens_unsaved || outdated {
|
||||||
|
Phase::Attention
|
||||||
|
} else {
|
||||||
|
phase_of(
|
||||||
paused,
|
paused,
|
||||||
failing_ops,
|
failing_ops,
|
||||||
online,
|
online,
|
||||||
queued.is_some() || !sync.is_empty(),
|
queued.is_some() || busy(items, jobs),
|
||||||
);
|
)
|
||||||
|
};
|
||||||
DriveState {
|
DriveState {
|
||||||
line: match (online, queued) {
|
line: match (online, queued) {
|
||||||
|
// Outranks everything: it is why nothing else moves, and the one
|
||||||
|
// thing that fixes it is the user's to do.
|
||||||
|
_ if *session_expired => gettext("Session expired — sign in again"),
|
||||||
|
// Sync goes on meanwhile, but a reboot ends it until the next login.
|
||||||
|
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
|
||||||
|
// An upgrade replaced the files but not the running service, which
|
||||||
|
// keeps the old release's bugs until it restarts.
|
||||||
|
_ if outdated => gettext("Update not finished — restart Proton Drive"),
|
||||||
_ if paused => gettext("Sync paused"),
|
_ if paused => gettext("Sync paused"),
|
||||||
_ if failing_ops > 0 => ngettext_f(
|
_ if failing_ops > 0 => ngettext_f(
|
||||||
"{n} change needs attention",
|
"{n} change needs attention",
|
||||||
@@ -269,11 +312,20 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
|
|||||||
mounted: true,
|
mounted: true,
|
||||||
paused,
|
paused,
|
||||||
mountpoint: PathBuf::from(mountpoint),
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
sync,
|
sync: sync_line(items, jobs),
|
||||||
failing: failing_ops,
|
failing: failing_ops,
|
||||||
|
outdated,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the daemon is changing something: bytes moving, or a job the user
|
||||||
|
/// is waiting on. A mirror folder's routine scan and the like are background
|
||||||
|
/// jobs; they still get the menu line, but the icon spinning every two minutes
|
||||||
|
/// for a scan that finds nothing reads as constant syncing.
|
||||||
|
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
|
||||||
|
!items.is_empty() || jobs.iter().any(|j| !j.background)
|
||||||
|
}
|
||||||
|
|
||||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||||
/// so first; failures outrank everything else the daemon reports.
|
/// so first; failures outrank everything else the daemon reports.
|
||||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||||
@@ -342,7 +394,7 @@ impl Tray for DriveTray {
|
|||||||
|
|
||||||
fn status(&self) -> Status {
|
fn status(&self) -> Status {
|
||||||
match self.state.phase {
|
match self.state.phase {
|
||||||
Phase::Attention => Status::NeedsAttention,
|
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
|
||||||
_ => Status::Active,
|
_ => Status::Active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,9 +452,19 @@ impl Tray for DriveTray {
|
|||||||
// Nothing to mount without a session; the app's sign-in page is the
|
// Nothing to mount without a session; the app's sign-in page is the
|
||||||
// way in.
|
// way in.
|
||||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
||||||
|
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
|
||||||
|
open_manager(&["--page", "login"])
|
||||||
|
})),
|
||||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.state.outdated {
|
||||||
|
items.push(item(&gettext("Restart Proton Drive"), |this| {
|
||||||
|
service::restart();
|
||||||
|
this.state.line = gettext("Connecting…");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if self.state.mounted {
|
if self.state.mounted {
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
@@ -476,6 +538,10 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The socket lives in the state dir, which nothing has made yet when the app
|
||||||
|
// starts the tray on a fresh install (B194).
|
||||||
|
dirs.ensure().context("create the app's directories")?;
|
||||||
|
|
||||||
// Ensure only one instance of the tray runs.
|
// Ensure only one instance of the tray runs.
|
||||||
let tray_sock = dirs.tray_socket();
|
let tray_sock = dirs.tray_socket();
|
||||||
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
|
||||||
@@ -534,11 +600,7 @@ fn main() -> Result<()> {
|
|||||||
heard.take(feed);
|
heard.take(feed);
|
||||||
}
|
}
|
||||||
let st = match &heard.status {
|
let st = match &heard.status {
|
||||||
Some(status) => status_state(
|
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
|
||||||
status,
|
|
||||||
sync_line(&heard.items, &heard.jobs),
|
|
||||||
&default_mountpoint,
|
|
||||||
),
|
|
||||||
// Lost the daemon: ask once, which describes the sign-in instead.
|
// Lost the daemon: ask once, which describes the sign-in instead.
|
||||||
None => poll_state(&socket, &default_mountpoint),
|
None => poll_state(&socket, &default_mountpoint),
|
||||||
};
|
};
|
||||||
@@ -583,6 +645,95 @@ mod tests {
|
|||||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn status(extra: &str) -> Response {
|
||||||
|
status_from(env!("CARGO_PKG_VERSION"), extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_from(version: &str, extra: &str) -> Response {
|
||||||
|
serde_json::from_str(&format!(
|
||||||
|
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
|
||||||
|
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
|
||||||
|
"version":"{version}"{extra}}}}}"#
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
|
||||||
|
/// good, and nothing said that signing in again was the way out.
|
||||||
|
#[test]
|
||||||
|
fn a_refused_session_outranks_everything_else() {
|
||||||
|
let state = status_state(
|
||||||
|
&status(r#","session_expired":true"#),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
Path::new("/m"),
|
||||||
|
);
|
||||||
|
assert_eq!(state.phase, Phase::SessionExpired);
|
||||||
|
assert_eq!(state.line, "Session expired — sign in again");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B61: the keyring refusing the refreshed tokens was only in the log.
|
||||||
|
#[test]
|
||||||
|
fn a_sign_in_the_keyring_refused_needs_attention() {
|
||||||
|
let state = status_state(
|
||||||
|
&status(r#","tokens_unsaved":true"#),
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
Path::new("/m"),
|
||||||
|
);
|
||||||
|
assert_eq!(state.phase, Phase::Attention);
|
||||||
|
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #33: a daemon an upgrade left running on the old release is named,
|
||||||
|
/// above a pause, and the menu offers the restart that finishes it.
|
||||||
|
#[test]
|
||||||
|
fn a_daemon_older_than_the_tray_needs_a_restart() {
|
||||||
|
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
|
||||||
|
assert_eq!(state.phase, Phase::Attention);
|
||||||
|
assert_eq!(state.line, "Update not finished — restart Proton Drive");
|
||||||
|
assert!(state.outdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_daemon_that_does_not_say_has_a_live_session() {
|
||||||
|
let state = status_state(&status(""), &[], &[], Path::new("/m"));
|
||||||
|
assert_eq!(state.phase, Phase::Paused);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job(title: &str, background: bool) -> JobItem {
|
||||||
|
JobItem {
|
||||||
|
title: title.to_string(),
|
||||||
|
detail: String::new(),
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
background,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_routine_scan_is_not_busy_but_a_sync_is() {
|
||||||
|
assert!(!busy(&[], &[]));
|
||||||
|
assert!(!busy(&[], &[job("Checking Documents", true)]));
|
||||||
|
assert!(busy(
|
||||||
|
&[],
|
||||||
|
&[
|
||||||
|
job("Checking Documents", true),
|
||||||
|
job("Syncing Photos", false)
|
||||||
|
]
|
||||||
|
));
|
||||||
|
assert!(busy(&[], &[job("Uploading files", false)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_job_from_an_older_daemon_counts_as_busy() {
|
||||||
|
let job: JobItem = serde_json::from_str(
|
||||||
|
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!job.background);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||||
assert_eq!(issues_label(1), "1 issue — View");
|
assert_eq!(issues_label(1), "1 issue — View");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const STOCK: &[&str] = &[
|
|||||||
"checkbox-symbolic",
|
"checkbox-symbolic",
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
"dialog-error-symbolic",
|
"dialog-error-symbolic",
|
||||||
|
"dialog-password-symbolic",
|
||||||
"dialog-warning-symbolic",
|
"dialog-warning-symbolic",
|
||||||
"document-edit-symbolic",
|
"document-edit-symbolic",
|
||||||
"document-open-recent-symbolic",
|
"document-open-recent-symbolic",
|
||||||
|
|||||||
+536
-468
File diff suppressed because it is too large.
Load diff
+4163
-3184
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,211 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [3.4.1] — 2026-10-16
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **After a password reset, the drive never mounted and the app stayed on "Connecting…".** A
|
||||||
|
reset gives the account new keys, and Proton locks the Drive volume that was encrypted to the
|
||||||
|
old ones. The service then failed every start with `failed to decrypt: missing key`. It now
|
||||||
|
starts a new My Files volume, as the web app does. The files in the locked volume can still be
|
||||||
|
restored from [drive.proton.me](https://drive.proton.me) with the previous password
|
||||||
|
(B210, [#40](https://github.com/narrrl/proton-drive-linux/issues/40)).
|
||||||
|
- **A blocked sign-in said the app version was no longer supported.** Proton refuses sign-in for
|
||||||
|
a while after it suspects abuse, and its message then asks you to update. The app and
|
||||||
|
`pdfs login` now say that Proton is refusing sign-in for now and that waiting helps
|
||||||
|
(B209, [#39](https://github.com/narrrl/proton-drive-linux/issues/39)).
|
||||||
|
- **`pdfs diagnose` failed the `database` check on a fresh install.** The daemon creates the
|
||||||
|
database after the first sign-in, so a missing one is now a warning.
|
||||||
|
|
||||||
|
## [3.4.0] — 2026-10-12
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A binary cache for the Nix flake.** Every release and every build of `main` is uploaded to
|
||||||
|
`proton-drive-linux.cachix.org`, so NixOS installs download the client instead of compiling
|
||||||
|
it. See [Install → Binary cache](INSTALL.md#binary-cache).
|
||||||
|
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||||
|
- **Folders can be made available offline in the app.** The context menu, the details pane and
|
||||||
|
the selection bar now offer it for folders too. Every file in the folder and its subfolders
|
||||||
|
downloads in the background with its progress in the tray and the app, and pinned folders carry
|
||||||
|
the offline badge. A file kept through its folder shows as available offline but cannot be
|
||||||
|
made online only on its own. `pdfs pin` on a folder now returns at once instead of timing out
|
||||||
|
on a large folder, and pinned files download to disk instead of into memory.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Removing the package left the drive mounted, and deletes there still reached Proton Drive.**
|
||||||
|
The service kept running from the removed program, so deleting `~/ProtonDrive` afterwards moved
|
||||||
|
its files to the Proton Drive trash. The .deb, .rpm and Arch packages now stop the service of
|
||||||
|
every logged-in user before they are removed. Upgrades are not affected.
|
||||||
|
- **`pdfs move` between two locations could move a folder with a file open for writing in it**,
|
||||||
|
when the file had been opened just after it first reached Proton Drive. The write then landed
|
||||||
|
after the move. Such a move is refused now, as it always was meant to be (B207).
|
||||||
|
- **The Fedora package installed KDE packages on GNOME.** Its recommendation of KWallet is now
|
||||||
|
only for Plasma, the GNOME keyring only for desktops other than Plasma, and the AppIndicator
|
||||||
|
extension only for GNOME.
|
||||||
|
|
||||||
|
## [3.3.0] — 2026-10-11
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A Nix flake for NixOS.** `nix build github:narrrl/proton-drive-linux` builds the client, and
|
||||||
|
the flake's NixOS module installs it and runs the mount in every graphical session. Pin the
|
||||||
|
input to a release tag such as `github:narrrl/proton-drive-linux/v3.3.0` to stay on releases.
|
||||||
|
See [Install → NixOS](INSTALL.md#nixos-flake).
|
||||||
|
([#37](https://github.com/narrrl/proton-drive-linux/issues/37))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A failed photo upload said "You're offline" while everything else worked.** Only a failed
|
||||||
|
connection now reads as offline. Other failures show what went wrong, and Upload is greyed out
|
||||||
|
on an account without a photo library.
|
||||||
|
([#38](https://github.com/narrrl/proton-drive-linux/issues/38))
|
||||||
|
- **After an upgrade, the old version kept running in the background, and nothing said so.**
|
||||||
|
Installing a package does not restart the service, so fixed bugs, such as an unreadable folder
|
||||||
|
stopping a synced folder, came back until the next login. The tray, the app, `pdfs status` and
|
||||||
|
`pdfs diagnose` now say when the service is older than the app, and the tray and the app offer
|
||||||
|
to restart it. ([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||||
|
|
||||||
|
## [3.2.1] — 2026-10-11
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Writing to a file again soon after a partial write could fail with an I/O error.** When the
|
||||||
|
first write changed only part of a file this client had not read, a second write, or saving
|
||||||
|
over the file, failed until the first one reached Drive. It now fetches the rest of the file
|
||||||
|
and goes ahead.
|
||||||
|
- **A CAPTCHA could not be solved on hardened systems.** When the verification page cannot open in
|
||||||
|
the app, as on secureblue, the app now offers it in your browser and continues the sign-in
|
||||||
|
once you are done. `pdfs login` now handles a CAPTCHA the same way instead of failing.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
- **The daemon kept several CPU cores busy while offline with changes queued.** Its upload
|
||||||
|
workers checked the queue in a tight loop for as long as the outage lasted. They now sleep
|
||||||
|
until the connection comes back. ([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **An expired sign-in was shown as "offline" for good.** When Proton refuses the session, the
|
||||||
|
tray now says "Session expired — sign in again" with a Sign In Again… item, the app shows the
|
||||||
|
same with a button, and `pdfs status` and `pdfs diagnose` tell you to run `pdfs login`. Cached
|
||||||
|
files stay readable and changes stay queued until you sign in.
|
||||||
|
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **The sign-in could be lost after `pdfs login`, `pdfs unlock` or a slow network.** The old
|
||||||
|
daemon wrote its spent tokens over a new sign-in when it stopped, a timeout could drop a token
|
||||||
|
refresh halfway, and a refresh was retried with a token Proton had already used. Each process
|
||||||
|
now writes only into its own session, a refresh always finishes, and it is never repeated.
|
||||||
|
([#35](https://github.com/narrrl/proton-drive-linux/issues/35))
|
||||||
|
- **A sign-in refreshed while the keyring was locked was lost on the next restart.** The client
|
||||||
|
now keeps retrying, holds the new tokens in the kernel keyring meanwhile, and the tray says
|
||||||
|
"Sign-in not saved — unlock your keyring" until it is written.
|
||||||
|
|
||||||
|
## [3.2.0] — 2026-10-08
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **One unreadable folder stopped a synced folder from syncing at all.** A folder you may not
|
||||||
|
read, such as a cache a container created, failed every pass, so nothing else in the synced
|
||||||
|
folder reached Drive. Such a folder is now left out and named once in the activity feed, and
|
||||||
|
everything around it syncs. A file deleted during a scan, and a name that is not UTF-8, no
|
||||||
|
longer fail the pass either.
|
||||||
|
([#33](https://github.com/narrrl/proton-drive-linux/issues/33))
|
||||||
|
- **Switching a folder to online-only could delete files that were never on Drive.** Ignored
|
||||||
|
folders such as `.git/`, symlinks, unreadable folders and files changed after the last sync were
|
||||||
|
deleted with the local copy. The switch now refuses and names what is only on this computer.
|
||||||
|
- **Switching to online-only trashed synced files that had become ignored.** Adding a synced path
|
||||||
|
to `.pdfsignore` and then switching moved it to the Drive trash. Ignoring never deletes now, as
|
||||||
|
the user guide says.
|
||||||
|
- **A stopped share stayed on the Shared by me page for minutes.** Stop Sharing now takes it off
|
||||||
|
the page at once, and an emptied share no longer shows with no people and no link.
|
||||||
|
- **The tray icon showed syncing every two minutes.** The routine check of a synced folder no
|
||||||
|
longer counts as syncing. Only work that changes files spins the icon. An edit in one synced
|
||||||
|
folder also no longer rescans all the others.
|
||||||
|
- **Right-click menus sometimes opened too short and had to be scrolled.**
|
||||||
|
- **The Share dialog looked unfinished.** Its buttons now sit under their sections instead of in a
|
||||||
|
row of their own. The role is chosen beside the addresses, people show with avatars, and both
|
||||||
|
sections show that they are loading.
|
||||||
|
- **Breadcrumbs spaced the "›" unevenly, and grid file names were broken with hyphens they do not
|
||||||
|
have.**
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **One main menu.** The menu button beside "Proton Drive" is gone. Preferences, Keyboard
|
||||||
|
Shortcuts, About and Sign Out are all in the account row at the bottom of the sidebar. F10 opens
|
||||||
|
it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Move another computer's backup to this one.** **Move to This Computer…** on the Computers page,
|
||||||
|
or `pdfs devices migrate UID`, moves every folder another computer backs up, for example an old
|
||||||
|
Windows PC, into this computer's backup on Proton Drive. Then it offers them for syncing here. The
|
||||||
|
folders move on Proton Drive itself, so nothing is uploaded again. The other computer stays in the
|
||||||
|
account, without those folders.
|
||||||
|
|
||||||
|
## [3.1.2] — 2026-10-08
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The app crashed when Proton asked for a CAPTCHA on hardened systems.** On secureblue, WebKit
|
||||||
|
cannot start the sandbox for the verification page and ends its process, which took the app
|
||||||
|
down with it. The page now opens in a separate window process: if that process fails, the
|
||||||
|
sign-in stops with a message that says why, and TROUBLESHOOTING.md has a workaround.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
- **On a fresh install the tray icon did not start.** The tray and the app now create their state
|
||||||
|
directory themselves instead of waiting for the service to do it. This also lets the Takeout
|
||||||
|
page remember staged archives before the first mount.
|
||||||
|
([#29](https://github.com/narrrl/proton-drive-linux/issues/29))
|
||||||
|
|
||||||
|
## [3.1.1] — 2026-10-07
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The client identifies itself with its release.** Sign-in and every API call used to send the
|
||||||
|
fixed `external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0`. If Proton retired that
|
||||||
|
version, every release would fail to sign in at once and no update could fix it. The client now
|
||||||
|
sends `external-drive-linux@<version>-stable` and `proton-drive-linux/<version>`. `app_version`
|
||||||
|
and `user_agent` in `config.json` are optional overrides now. The fixed values that releases up
|
||||||
|
to 3.1.0 wrote there count as unset and are removed on the next save. A value you set by hand is
|
||||||
|
kept.
|
||||||
|
- The About dialog shows the release version, and its debug info shows the identity that is
|
||||||
|
actually sent.
|
||||||
|
|
||||||
|
## [3.1.0] — 2026-10-07
|
||||||
|
|
||||||
|
Schema: **42**, unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Two-password accounts.** An account with a separate mailbox password now signs in: the app
|
||||||
|
and `pdfs login` ask for it after the login password and check it against the account, so a
|
||||||
|
typo is a retry and not a daemon that never mounts. The mailbox password is never stored.
|
||||||
|
What it unlocks is kept in kernel memory for the login session, so it is asked for once per
|
||||||
|
session and is gone after a reboot or when you log out of the machine.
|
||||||
|
- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked
|
||||||
|
account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login`
|
||||||
|
and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in
|
||||||
|
the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes
|
||||||
|
it. Off by default: it gives up the separation a second password is for.
|
||||||
|
- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting
|
||||||
|
for its mailbox password, and the app asks for it when it opens.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases
|
||||||
|
instead of the mailbox password, and a blob written by 3.0.x is converted on the first start.
|
||||||
|
A stored password the account rejects (a two-password account that was signed in with only
|
||||||
|
its login password) leaves the account locked instead of failing every start.
|
||||||
|
- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user
|
||||||
|
`ptrace` are off for it.
|
||||||
|
- **The offline cache is re-keyed** from the new secret, so the first start after the update
|
||||||
|
reads it as cold and fetches the tree again once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a
|
||||||
|
mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after
|
||||||
|
two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog
|
||||||
|
while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26))
|
||||||
|
- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token
|
||||||
|
rotation now updates only the tokens in the stored blob.
|
||||||
|
|
||||||
## [3.0.0] — 2026-10-05
|
## [3.0.0] — 2026-10-05
|
||||||
|
|
||||||
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
|
Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes
|
||||||
@@ -1599,6 +1804,19 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
|||||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[3.4.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.1
|
||||||
|
[3.4.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.4.0
|
||||||
|
[3.3.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.3.0
|
||||||
|
[3.2.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.1
|
||||||
|
|
||||||
|
[3.2.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.2.0
|
||||||
|
|
||||||
|
[3.1.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.2
|
||||||
|
|
||||||
|
[3.1.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.1
|
||||||
|
|
||||||
|
[3.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.1.0
|
||||||
|
|
||||||
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
[3.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v3.0.0
|
||||||
|
|
||||||
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
[2.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.10.0
|
||||||
|
|||||||
+5
-4
@@ -65,9 +65,9 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `pdfs pin PATH` | Download a file completely and keep it on this computer. |
|
| `pdfs pin PATH` | Download a file completely and keep it on this computer. For a folder, its files download in the background (`pdfs transfers` shows the progress). |
|
||||||
| `pdfs unpin PATH` | Release it back to online only. |
|
| `pdfs unpin PATH` | Release it back to online only. A file inside a folder kept offline cannot be released on its own. |
|
||||||
| `pdfs pins` | List files kept available offline. |
|
| `pdfs pins` | List files and folders kept available offline. |
|
||||||
|
|
||||||
## Synced folders
|
## Synced folders
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
| `pdfs sync limit [--up RATE] [--down RATE]` | Cap bandwidth, for example `500K` or `2M` per second. `0` removes a cap. Without options, prints the current caps. |
|
||||||
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
| `pdfs sync queue` | List changes that have not reached Proton Drive yet. |
|
||||||
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
|
| `pdfs sync issues` | List queued changes Proton Drive refused or that keep failing, and what to do. |
|
||||||
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. The upload stays queued. |
|
| `pdfs sync export ID DEST` | Save a copy of a queued upload's content. `DEST` may be a folder; an existing file is never overwritten. The upload stays queued. |
|
||||||
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
| `pdfs sync discard ID [--yes]` | Drop a queued change and undo it here: a new file or folder goes, anything else goes back to what Proton Drive has. Content not exported first is lost. |
|
||||||
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
| `pdfs sync retry [ID]` | Retry one queued operation, or every failed one. |
|
||||||
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
| `pdfs sync restore [--device UID] [--yes]` | Re-attach this computer's backed-up folders to local folders and download them. `--device` restores another computer's folders. See [RECOVERY.md](RECOVERY.md). |
|
||||||
@@ -103,6 +103,7 @@ commands are for scripts and for cases where the mount is not available.
|
|||||||
| `pdfs devices rename UID NAME` | Rename a computer. |
|
| `pdfs devices rename UID NAME` | Rename a computer. |
|
||||||
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
| `pdfs devices rm UID` | Remove a computer and its backup from the account. |
|
||||||
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
| `pdfs devices adopt UID` | Make this machine continue another computer's backup, for example after a reinstall. `--clear` returns to matching by hostname. |
|
||||||
|
| `pdfs devices migrate UID [--yes]` | Move another computer's folders into this computer's backup, then restore them here like `pdfs sync restore`. The other computer stays registered, without those folders. `--yes` skips the confirmation and accepts every proposed path. See [RECOVERY.md](RECOVERY.md). |
|
||||||
|
|
||||||
## Trash
|
## Trash
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -38,7 +38,7 @@ Inside `content/`:
|
|||||||
> not reached Proton Drive and exists nowhere else.
|
> not reached Proton Drive and exists nowhere else.
|
||||||
|
|
||||||
Everything in these directories except the keyring is **decrypted**. See
|
Everything in these directories except the keyring is **decrypted**. See
|
||||||
[ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
[ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext).
|
||||||
|
|
||||||
## `config.json`
|
## `config.json`
|
||||||
|
|
||||||
@@ -89,7 +89,11 @@ fix it.
|
|||||||
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
| `open_with` | object | `xdg-open` | — | See [`open_with`](#open_with) |
|
||||||
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
| `prompt` | object | built-in window | — | See [`prompt`](#prompt) |
|
||||||
|
|
||||||
`app_version` and `user_agent` identify the client to Proton. Leave them alone.
|
`app_version` and `user_agent` identify the client to Proton. When the keys are absent, the
|
||||||
|
client sends its own release (`external-drive-linux@<version>-stable`, `proton-drive-linux/<version>`).
|
||||||
|
Leave them out unless a support case asks for a different value. The fixed
|
||||||
|
`external-drive-linux@0.1.0-alpha` and `proton-drive-linux/0.1.0` that releases up to 3.1.0 wrote
|
||||||
|
here are treated as absent and removed on the next save.
|
||||||
|
|
||||||
### `ignore_patterns`
|
### `ignore_patterns`
|
||||||
|
|
||||||
@@ -190,13 +194,16 @@ Command-line flags win over the file: `--gtk`, `--dmenu`, `--fzf` and `--menu`.
|
|||||||
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
| `PDFS_LOCALEDIR` | app, tray, prompt | Load translations from another directory (development) |
|
||||||
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
| `PDFS_STAND_IN_WIDGETS` | app | `1` uses the libadwaita 1.5 fallback widgets on newer systems (development) |
|
||||||
|
|
||||||
The acceptance suite has its own `PDFS_ACCEPTANCE_*` variables, listed in [TESTING.md](TESTING.md).
|
The tests have their own variables (`PDFS_ACCEPTANCE_*`, `PDFS_SIM_*`, `PDFS_MIGRATE_DB`),
|
||||||
|
listed in [TESTING.md](TESTING.md).
|
||||||
|
|
||||||
## The systemd service
|
## The systemd service
|
||||||
|
|
||||||
`proton-drive.service` is a user unit. It restarts the service whenever it exits, restarts it when
|
`proton-drive.service` is a user unit started with your graphical session
|
||||||
it stops answering for two minutes (`WatchdogSec=120`), and caps its memory at 6 GiB. To change
|
(`WantedBy=graphical-session.target`). It restarts the service whenever it exits, restarts it
|
||||||
the unit without editing the packaged file, use a drop-in:
|
when it stops answering for two minutes (`WatchdogSec=120`), throttles it above 2 GiB of memory
|
||||||
|
(`MemoryHigh`) and stops it at 6 GiB (`MemoryMax`). To change the unit without editing the
|
||||||
|
packaged file, use a drop-in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user edit proton-drive.service
|
systemctl --user edit proton-drive.service
|
||||||
|
|||||||
+23
-9
@@ -31,12 +31,12 @@ time when the installed library has them (`crates/pdfs-gui/src/compat.rs`).
|
|||||||
| Path | Contents |
|
| Path | Contents |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
| `crates/pdfs-core` | Shared library: authentication and keyring (`auth`), configuration (`config`), SQLite schema and queries (`db/`), content cache (`cache`), control protocol (`control`), search scoring, sync ignore rules, Google Takeout parsing, machine profile |
|
||||||
| `crates/pdfs-fuse` | The daemon: FUSE filesystem, read path (`reads`), write queue (`queue`, `drain`, `upload`), sync engine (`sync/`), remote events (`background`), push events for front ends (`events`), control handlers (`control`), photos, devices, supervisor |
|
| `crates/pdfs-fuse` | The daemon: FUSE handlers (`filesystem`), the tree (`state`), reads (`reads`), the queue and the drain (`queue`, `drain`, `upload`), link state (`link`), the sync engine (`sync`, `sync/`), moves between locations (`relocate`), remote events (`background`), push events (`events`), control handlers (`control`), photos, devices, the supervisor, and the simulation tests (`sim/`) |
|
||||||
| `crates/pdfs-cli` | The `pdfs` binary |
|
| `crates/pdfs-cli` | The `pdfs` binary |
|
||||||
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
| `crates/pdfs-gui` | `pdfs-app`, `pdfs-tray` and `pdfs-prompt`. Pages in `src/app/pages/`, shared widgets in `src/app/widgets/`, CSS and icons in `resources/` |
|
||||||
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
| `po/` | Translation template, catalogs and the scripts that maintain them |
|
||||||
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
| `packaging/` | systemd unit, desktop files, icon, PKGBUILD, RPM spec, AUR recipes |
|
||||||
| `scripts/` | FUSE acceptance suite, map-data generators |
|
| `scripts/` | FUSE acceptance suite, map-data generators, the wiki publisher |
|
||||||
| `docs/` | This documentation |
|
| `docs/` | This documentation |
|
||||||
|
|
||||||
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
The GUI talks to the daemon only through the control socket. Anything a front end needs that the
|
||||||
@@ -87,8 +87,9 @@ cargo test --workspace --locked
|
|||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
```
|
```
|
||||||
|
|
||||||
For changes to the FUSE layer, the write queue or the sync engine, also run the live acceptance
|
For changes to the FUSE layer, the queue or the sync engine, also run the
|
||||||
cases described in [TESTING.md](TESTING.md).
|
[simulation runs](TESTING.md#simulation-runs), which CI runs only after a push to `main`, and the
|
||||||
|
[acceptance suite](TESTING.md#acceptance-suite) on a test account.
|
||||||
|
|
||||||
For changes to GUI strings, check that every catalog is complete:
|
For changes to GUI strings, check that every catalog is complete:
|
||||||
|
|
||||||
@@ -136,6 +137,15 @@ file to `po/POTFILES.in`, run `po/update.sh`, and translate the new entries in e
|
|||||||
`#[serde(default)]`, so older files still load, and document it in
|
`#[serde(default)]`, so older files still load, and document it in
|
||||||
[CONFIGURATION.md](CONFIGURATION.md).
|
[CONFIGURATION.md](CONFIGURATION.md).
|
||||||
|
|
||||||
|
**A bug fix.** Add or update the entry in [BUGS.md](BUGS.md#adding-an-entry), cover the fix with a
|
||||||
|
unit test, a simulation scenario or a `regression B<n>` acceptance case, and cite the entry in the
|
||||||
|
code as `docs/BUGS.md B<n>`. Do not cite planning notes that are not in the repository.
|
||||||
|
|
||||||
|
**A user-visible change.** Update the matching page (user guide, CLI reference, configuration)
|
||||||
|
and the `[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). `scripts/publish-wiki.sh` copies
|
||||||
|
the six user pages (`INSTALL`, `USER_GUIDE`, `CLI`, `CONFIGURATION`, `TROUBLESHOOTING`, `RECOVERY`)
|
||||||
|
to the GitHub wiki, so keep their file names.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.yml` then:
|
||||||
@@ -143,15 +153,19 @@ Releases are cut from `main` by pushing a `v*` tag. `.github/workflows/release.y
|
|||||||
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
1. checks that the tag, the workspace version in `Cargo.toml` and `pkgver` in
|
||||||
`packaging/PKGBUILD` agree;
|
`packaging/PKGBUILD` agree;
|
||||||
2. runs the quality gates;
|
2. runs the quality gates;
|
||||||
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`;
|
3. builds the release binaries and packages a `.tar.gz`, a `.deb` and a GPG-signed `.rpm`, and
|
||||||
4. publishes them as a GitHub release;
|
builds the Nix flake at the tag, which is what a flake input pinned to `vX.Y.Z` gets, and
|
||||||
|
pushes it to the `proton-drive-linux` Cachix cache (secret `CACHIX_AUTH_TOKEN`);
|
||||||
|
4. publishes them as a GitHub release, with the version's section of `docs/CHANGELOG.md` as the
|
||||||
|
notes;
|
||||||
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
5. calls `.github/workflows/aur.yml`, which updates and pushes the AUR recipes and commits them
|
||||||
back to `main`.
|
back to `main`.
|
||||||
|
|
||||||
To prepare a release:
|
To prepare a release:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Bump the version in Cargo.toml and packaging/PKGBUILD, then refresh the lock file.
|
# 1. Bump the version in Cargo.toml, packaging/PKGBUILD, po/pdfs.pot and the pinned flake
|
||||||
|
# URL in docs/INSTALL.md, then refresh the lock file.
|
||||||
cargo update --workspace
|
cargo update --workspace
|
||||||
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
# 2. Move the [Unreleased] notes in docs/CHANGELOG.md under the new version.
|
||||||
# 3. Commit, tag and push.
|
# 3. Commit, tag and push.
|
||||||
@@ -160,5 +174,5 @@ git tag vX.Y.Z
|
|||||||
git push origin main vX.Y.Z
|
git push origin main vX.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
Before a release that touches the filesystem or sync, run the managed live mode matrix from
|
Before a release, work through [Before a release](TESTING.md#before-a-release). Open
|
||||||
[TESTING.md](TESTING.md). Open release-assurance items are listed in [ROADMAP.md](ROADMAP.md).
|
release-assurance items are listed in [ROADMAP.md](ROADMAP.md#release-assurance).
|
||||||
+71
-2
@@ -82,6 +82,68 @@ paru -S proton-drive-for-linux # or yay, or any other AUR helper
|
|||||||
|
|
||||||
The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
|
The recipes live in [`packaging/aur/`](../packaging/aur/README.md).
|
||||||
|
|
||||||
|
### NixOS (flake)
|
||||||
|
|
||||||
|
The repository is a flake. Add it as an input and enable the module, which installs the package,
|
||||||
|
links the user service into every graphical session and turns on the `fusermount3` wrapper and
|
||||||
|
GNOME Keyring for you:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux";
|
||||||
|
|
||||||
|
outputs = { nixpkgs, proton-drive-linux, ... }: {
|
||||||
|
nixosConfigurations.<host> = nixpkgs.lib.nixosSystem {
|
||||||
|
modules = [
|
||||||
|
proton-drive-linux.nixosModules.default
|
||||||
|
{ services.proton-drive-linux.enable = true; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That URL follows `main`. To stay on releases, pin the input to a release tag instead, and change
|
||||||
|
the tag to update:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.proton-drive-linux.url = "github:narrrl/proton-drive-linux/v3.3.0";
|
||||||
|
```
|
||||||
|
|
||||||
|
Each release tag is built with Nix before the release is published.
|
||||||
|
|
||||||
|
#### Binary cache
|
||||||
|
|
||||||
|
CI uploads every build of `main` and of each release tag to a Cachix binary cache, so Nix can
|
||||||
|
download the client instead of compiling it. A system build does not read a flake input's cache
|
||||||
|
settings, so add the cache to your NixOS configuration once:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nix.settings = {
|
||||||
|
substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||||
|
trusted-public-keys = [
|
||||||
|
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The cache holds the build against the `nixpkgs` this flake locks. Setting
|
||||||
|
`inputs.proton-drive-linux.inputs.nixpkgs.follows = "nixpkgs"` builds against yours instead, which
|
||||||
|
the cache does not have, so Nix compiles it. `nix run` and `nix shell` ask whether to use the
|
||||||
|
cache the first time; answer yes, or run `cachix use proton-drive-linux`.
|
||||||
|
|
||||||
|
To try it without changing your system, run the app straight from the flake:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:narrrl/proton-drive-linux#proton-drive-linux -- --help # the CLI
|
||||||
|
nix shell github:narrrl/proton-drive-linux -c pdfs-app # the desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
The mount itself needs the setuid `fusermount3` from NixOS (`programs.fuse.enable`, on by default
|
||||||
|
with the module) and a Secret Service such as GNOME Keyring or KWallet. To leave out ffmpeg and
|
||||||
|
with it video thumbnails, override the package with `withFfmpeg = false`. The recipe lives in
|
||||||
|
[`packaging/nix/`](../packaging/nix/).
|
||||||
|
|
||||||
### Other distributions
|
### Other distributions
|
||||||
|
|
||||||
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
|
Unpack the `.tar.gz` into a directory on your `PATH`, then install the service unit as shown in
|
||||||
@@ -240,8 +302,15 @@ pdfs logout # removes the session from the
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
Then remove the package (`sudo apt remove proton-drive-linux`, `sudo dnf remove
|
||||||
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`), and, if you want no trace left,
|
proton-drive-linux`, or `sudo pacman -R proton-drive-for-linux`). Removing the package also stops
|
||||||
the local data:
|
the service of every logged-in user, in case a step above was skipped.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Do not delete `~/ProtonDrive` while it is mounted. It is your Proton Drive, not a copy: every
|
||||||
|
> file deleted there is moved to the Proton Drive trash. Once the service is stopped the folder is
|
||||||
|
> an empty mount point, and removing it is safe.
|
||||||
|
|
||||||
|
If you want no trace left, remove the local data as well:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
rm -rf ~/.config/proton-drive-linux ~/.local/state/proton-drive-linux ~/.cache/proton-drive-linux
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
# 3.0.0: what is done and what is left
|
|
||||||
|
|
||||||
Tracks `docs/MILESTONE-3.0.0.md`. Update it with every commit that moves a phase.
|
|
||||||
|
|
||||||
## Done
|
|
||||||
|
|
||||||
- **Phase 0**: fake Drive, simulation runs, CI job (2.9.0).
|
|
||||||
- **Phase 1**: queued-op preconditions and own sealed revisions in the database (2.9.0).
|
|
||||||
- **Phase 2**: local ids, inode = local id, one tree for every mount (2.10.0).
|
|
||||||
- **Phase 3, first part**: mkdir, create, rename, unlink and rmdir on the mount are recorded
|
|
||||||
locally and sent from the queue; `"local_first": false` brings back the old path (5e0f30c).
|
|
||||||
- Fixes from the simulation and account runs since then: B125, B127, B130 and B141 to B193.
|
|
||||||
- **Sync issues, first cut** (§6.2): refusals sorted into issues, shown at once in the app,
|
|
||||||
tray and CLI; Export button and `pdfs sync export`; `pdfs sync issues`; Discard button and
|
|
||||||
`pdfs sync discard`; a warning badge on the node in the file browser.
|
|
||||||
|
|
||||||
## Phase 3: left for 3.0.0
|
|
||||||
|
|
||||||
1. **Sync issues (§6.2)**: first cut done.
|
|
||||||
- [x] The drain sorts a refusal from Drive into an issue kind (quota, access, missing,
|
|
||||||
limit, rejected) and stores it on the queued op (schema 38).
|
|
||||||
- [x] An op with an issue counts as "needs attention" at once, not after six failures.
|
|
||||||
- [x] The queue listing carries the issue kind and whether its content can be exported.
|
|
||||||
- [x] Export a queued file's content (control request, `pdfs sync export`).
|
|
||||||
- [x] `pdfs sync issues`, and the issue shown in `pdfs sync queue`.
|
|
||||||
- [x] The app's Sync page shows each issue with what the user can do; Export and Retry
|
|
||||||
buttons; translated in every catalog.
|
|
||||||
- [x] A name collision lands under a conflict name (create, rename, move, re-homed create)
|
|
||||||
and goes in the activity log, as does a queued edit kept as a conflict copy. The app
|
|
||||||
words each one in the user's language (the last one since B181).
|
|
||||||
- [x] Discard an issue on purpose (after export, or for a change with no content), and
|
|
||||||
undo the local change it stood for: a node made here goes, a node Drive has goes back
|
|
||||||
to what Drive has. Control request, `pdfs sync discard`, Discard button with a
|
|
||||||
confirmation; translated in every catalog.
|
|
||||||
- [x] Show the issue on the node itself: a folder listing carries each node's issue, and
|
|
||||||
the file browser shows a warning badge with what it means. The listing is published
|
|
||||||
again when the set of issues changes.
|
|
||||||
2. **Planner and executor (§5.4, §5.5)**: ops keyed by local id; remove `mint_local_uid`,
|
|
||||||
`finish_create` and `adopt_real_uid`; a landing create sets `remote_uid` on its row.
|
|
||||||
- [x] Queued ops carry `lid` and `parent_lid` (schema 39). The claim reads the parent's uid
|
|
||||||
through `parent_lid`, and a landing folder rewrites the ops inside it by local id.
|
|
||||||
- [x] Node rows link to their parent by lid (`nodes.parent_lid`, schema 40), and a restart
|
|
||||||
places each node under its parent's row as it is now.
|
|
||||||
- [x] Queries over children, subtrees and ancestors by `parent_lid`, and no rewrite of the
|
|
||||||
children's `parent_uid` when a folder lands (R3), nor of the ops made inside it (R2).
|
|
||||||
- [x] No more placeholders: a node made here goes by its local id until it lands. In four
|
|
||||||
parts:
|
|
||||||
- [x] 6a: a node made here goes by `local~<lid>`, a function of its row, instead of a
|
|
||||||
minted `local~<ms>-<n>`; schema 41 rewrites the old placeholders (nodes, ops, pins).
|
|
||||||
- [x] 6b: an op is found by its node's local id under either uid, the claim keeps one op
|
|
||||||
per local id on the wire, and the drain sends the uid the row has now;
|
|
||||||
`remap_local_uid` is gone.
|
|
||||||
- [x] 6c: children, subtrees, ancestors and ops' parents by lid, so a landing create
|
|
||||||
rewrites no other row or op; what is read for a uid names the parent as its row has it
|
|
||||||
now. The row keeps its stand-in in `nodes.uid` rather than `NULL`: it is as stable,
|
|
||||||
and `NULL` would have changed some 40 node queries for no change in behaviour. Pins
|
|
||||||
stay keyed by uid and move with the row in the landing transaction (B148).
|
|
||||||
- [x] 6d: a node written under its stand-in after it landed updates its landed row
|
|
||||||
instead of bringing the placeholder row back, so a write that finds its create
|
|
||||||
landed takes the uid from the row and no longer polls the tree for up to 2 s
|
|
||||||
(`landed_uid` is gone). In memory, `pending`, the uploads and the tree stay keyed by
|
|
||||||
the uid Drive knows: ops are matched by lid in the database (6b), `hidden` and
|
|
||||||
`creating` only ever hold remote uids, and `landed_row_uid` is the one place at the
|
|
||||||
mount's edge where a stand-in a handle still holds becomes the real uid.
|
|
||||||
- [x] An executor that runs ops in dependency order, many at once: 16 drain workers instead
|
|
||||||
of 3, and a worker that lands an op wakes the idle ones, which slept through the ops it
|
|
||||||
made claimable. The claim already keeps the order: a create waits for its parent's
|
|
||||||
uid, a node's ops go one at a time. Ops stay blocking calls on worker threads: each
|
|
||||||
one spends its time waiting on Drive, so tasks would buy nothing threads do not.
|
|
||||||
3. **Speed (§7)**: run independent ops in parallel, batch trashes and moves; measure 1,000 files
|
|
||||||
and 100 folders on Wi-Fi against LAN (target: within 10 %).
|
|
||||||
- [x] The measurement: `a_thousand_files_drain_as_fast_on_wifi_as_on_lan` in `sim/daemon.rs`,
|
|
||||||
run with `PDFS_SIM_MEASURE=1`. It prints the times and asserts nothing yet.
|
|
||||||
- [x] Baseline on eb1c105, three drain threads: the syscalls take 3.7 s on LAN and 4.3 s on
|
|
||||||
Wi-Fi; the drain takes 138 s on LAN and 938 s on Wi-Fi, 6.8 times as long.
|
|
||||||
- [x] Sixteen workers: the syscalls take 5.2 s on LAN and 5.0 s on Wi-Fi, which meets the
|
|
||||||
target; the drain takes 47 s on LAN and 247 s on Wi-Fi. Most of a create on Wi-Fi is
|
|
||||||
its read-back waiting for Drive to list the new node (250 ms, then 1 s), so batching
|
|
||||||
(step 8) helps trashes and moves, not creates.
|
|
||||||
- [x] Batched trashes and moves: left for after 3.0.0 (see below). The target is met
|
|
||||||
without them, the drain waits on the creates' read-back, and splitting one answer into
|
|
||||||
many results is where the B15x to B17x races lived.
|
|
||||||
4. **Done-when checks**:
|
|
||||||
- [ ] Simulation passes on the Wi-Fi profile: `one_client_on_a_slow_link` failed in CI on
|
|
||||||
seeds 2 and 3. Two bugs (B127, B151) and a harness gap: a settle did not read the log, so
|
|
||||||
it could not explain a known bug's damage. All three fixed; not yet confirmed in CI.
|
|
||||||
Replays then found two races on the LAN profile too (B152, B153), both fixed. CI on
|
|
||||||
7269cb5 failed LAN seed 9, a regression from the B153 fix (B154), and Wi-Fi seed 2,
|
|
||||||
a write whose answer was lost (B155). Both fixed. One slow-link run of seed 2 failed
|
|
||||||
once with a rename answering `ENOENT` as a folder move landed. Not reproduced since;
|
|
||||||
it may be B152. Replays of slow-link seed 4 failed two of six times on a file written
|
|
||||||
again after its create lost its answer (B156, fixed). With sixteen drain workers
|
|
||||||
(16fca10), seed 4 failed every replay on a write after an upload that was not read
|
|
||||||
back (B157, fixed). Seed 3 then failed four of ten runs on two renames of one file
|
|
||||||
landing out of order (B158, fixed). Flaky-link seed 4 failed one of ten runs on a
|
|
||||||
lookup that answered `EAGAIN` while the root was listed again after a restart (B159,
|
|
||||||
fixed). Slow-link seed 4 also had folders reach Drive 30 s late, after the
|
|
||||||
drain's idle poll (B160, fixed). Slow-link seed 3 failed on a file moved while its
|
|
||||||
create was on the wire, which the mount showed under its old name (B161, fixed).
|
|
||||||
Seed 4 failed on a folder removed and made again, whose create adopted the removed
|
|
||||||
folder (B162, fixed). CI on 903452a passed every profile. CI on f9b1638 failed
|
|
||||||
slow-link seed 2: a step that unlinks an open file, appends and reads it back hung
|
|
||||||
for 150 s. The B130 matcher did not fire, and CI kept no stacks; one replay passed.
|
|
||||||
It may be a kernel notice sent from a runtime worker by another path than B130's,
|
|
||||||
which a68dae9 (not yet pushed then) also takes off the runtime. CI now keeps a hung
|
|
||||||
run's stacks.
|
|
||||||
- [x] Known-bug list in `sim/run.rs` is empty: the run on 812eb3d (`PDFS_SIM_KNOWN=fail`,
|
|
||||||
twelve seeds) passed every profile, so the matchers are gone. That confirms B125 to
|
|
||||||
B133; B134 stays open with `local_first` off only, which the runs do not mount. The confirmation run (`PDFS_SIM_KNOWN=fail`)
|
|
||||||
on da013c2 failed `one_client_on_a_flaky_link` seeds 6 and 11 on two new bugs, both
|
|
||||||
after restarts: a write over a create never read back (B182) and a trash that forgot
|
|
||||||
what its create sent (B183). Replays with those fixed found a third: a create renamed
|
|
||||||
after it lost its answer (B184). All three fixed; no known-bug matcher fired. It was
|
|
||||||
stopped before `three_clients`. The rerun on 3073052 found a create that waited out
|
|
||||||
a retry after a write replaced its blob (B185, fixed), and a file deleted offline
|
|
||||||
while open that lost the bytes its handle read (B186, fixed; older than 3.0.0).
|
|
||||||
The run on 2baad67 found that a restart before such a file's trash went out queued
|
|
||||||
its kept bytes again, for good (B188, fixed), and changes that sat out their retry
|
|
||||||
after an outage only the drain had met (B189, fixed; older than 3.0.0), and a file
|
|
||||||
saved just before a stop whose release the stop dropped (B190, fixed; older too). The run
|
|
||||||
on 1436c5f found a file deleted after its failed create was written again left on Drive
|
|
||||||
(B191, fixed). Its test for B193 found a new empty file waiting out the idle poll (B192,
|
|
||||||
fixed), and seed 6 a deleted file's trash taking a file of the user's renamed to the name
|
|
||||||
its create was sent under (B193, fixed).
|
|
||||||
- [ ] A clean account run. The one on a6f36d5 (2026-10-04, LAN: 427 passed, 12 failed)
|
|
||||||
failed B113 and B121 on every on-demand mount, two mirror-to-mirror move cases, and
|
|
||||||
the journal check. B121 was a bug (B149, fixed); B113's case still expected creates
|
|
||||||
to reach Drive at once and now accepts queued ones. The move cases were the harness:
|
|
||||||
it retried every source after one was busy, and expected a refusal where a mirror to
|
|
||||||
mirror move carries the local copy along. The journal showed a conflict copy retrying
|
|
||||||
into a deleted folder (B150, fixed). Not confirmed yet. The run on 2.10.0
|
|
||||||
(2026-10-04: 260 passed, 3 failed) failed a new file that read back empty (B163,
|
|
||||||
fixed), a 128-entry folder that listed 129 (B164, fixed), and its cleanup, which ran
|
|
||||||
while the daemon was restarting: the stop waited on a busy thread until systemd killed
|
|
||||||
it (B165, bounded). The next run on the working tree (2026-10-04: 259 passed, 4
|
|
||||||
failed) found a wide folder dropping files whose create landed while it was listed
|
|
||||||
(B166, fixed), or listing one empty: the new file that read back empty again
|
|
||||||
(B166 too). Its cleanup found no control socket because it ran 1.5 s after the
|
|
||||||
last restart, before the new daemon had bound it: `pdfs status` reports that as not
|
|
||||||
running, which the run's queue wait takes for an empty queue (the script's side).
|
|
||||||
Looking into it found the start spending 1.7 s rebuilding listings (B167, fixed).
|
|
||||||
Its journal check caught a file removed with its folder while uploading being
|
|
||||||
sent again, to the root (B168, fixed), and a stop that let a mirror pass go on
|
|
||||||
until the runtime ended under it (B169, fixed). CI on 9bbc994 failed
|
|
||||||
`one_client_on_a_good_link`: a write opened as an upload landed took the revision
|
|
||||||
it replaced as its base, and the next write failed with `EIO` (B170, fixed). The
|
|
||||||
suite's seed 9 found a deleted file's trash waiting out its create's backoff (B171,
|
|
||||||
fixed). Run three at a time on 2502af6, seed 8 failed two of nine runs: a file renamed
|
|
||||||
while its upload was read back showed its old name again (B172, fixed). The loop
|
|
||||||
on d850265 failed seed 12 once in nine runs: a file deleted just as its create
|
|
||||||
landed stayed on Drive (B173, fixed). The suite on 378667f failed seed 6: a rename
|
|
||||||
in the same gap was refused with `EACCES` (B174, fixed). The loop on 378667f failed
|
|
||||||
seed 8: a file replaced by a rename in that gap stayed on Drive (B175, fixed), and
|
|
||||||
seed 12: a write closed as its file's create landed was dropped (B176, fixed).
|
|
||||||
The account run on 378667f: a folder listed as its create landed asked Drive by
|
|
||||||
its stand-in and answered `EIO` (B177, fixed). A folder trashed while sync was
|
|
||||||
paused kept a create below it as a trash: a paused drain worker had claimed it
|
|
||||||
(B178, fixed). A file read as its create landed read empty (B179, fixed).
|
|
||||||
The suite on 0f3c06f: a stop gave up on a drain worker that slept through its
|
|
||||||
wake-up (B180, fixed). The account run on 903452a (2026-10-04, Wi-Fi: 262 passed,
|
|
||||||
1 failed) failed only its cleanup, which found no control socket: after the mirror
|
|
||||||
target's last restart the queue wait took the missing daemon for an empty queue
|
|
||||||
(the script's side, fixed in 6539c73).
|
|
||||||
5. **Release**: 2.10.0 is tagged; the 2.9.0 tag still waits for the user's go-ahead.
|
|
||||||
|
|
||||||
## After 3.0.0
|
|
||||||
|
|
||||||
- Remove the old online path in `filesystem.rs` and the `local_first` switch (the release after
|
|
||||||
3.0.0).
|
|
||||||
- **Phase 4**: one applier for remote changes.
|
|
||||||
- **Phase 5**: mirror folders on the same planner and executor.
|
|
||||||
- **Batched trashes and moves** (plan step 8). Not needed for the §7 target: the syscalls take
|
|
||||||
5.2 s on LAN and 5.0 s on Wi-Fi. The drain's time goes to the creates' read-back (250 ms,
|
|
||||||
then 1 s each), which batching does not shorten. A batch call answers for many ops at once,
|
|
||||||
and splitting that answer back per op is the step the B15x to B17x races came from. First
|
|
||||||
measure `rm -r` of 1,000 files on Wi-Fi. If it is slow, drop the queued trashes of a trashed
|
|
||||||
folder's children before batching anything: trashing the folder takes them with it.
|
|
||||||
- **Any time**: `Size` and block geometry as types; a deadline on the remote calls outside
|
|
||||||
the drain (the drain's have one: `link::upload_deadline`, `Core::block_on_within`).
|
|
||||||
- Open questions in §12 of the milestone.
|
|
||||||
- `PLAN.md`: the acceptance script notices network drops and reports `interrupted` instead of
|
|
||||||
TIMEOUT.
|
|
||||||
+5
-5
@@ -5,24 +5,24 @@
|
|||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
| [Installation](INSTALL.md) | Packages, building from source, first sign-in, updating and uninstalling |
|
||||||
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
| [User guide](USER_GUIDE.md) | My files, available offline, synced folders, sync issues and conflicts, Photos, sharing, Trash and versions, Computers, the search launcher, the tray |
|
||||||
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
| [Command-line reference](CLI.md) | Every `pdfs` command, grouped by task, and scripting with `--json` |
|
||||||
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
| [Configuration](CONFIGURATION.md) | `config.json`, environment variables, file locations, the systemd service |
|
||||||
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
| [Troubleshooting](TROUBLESHOOTING.md) | Diagnosing problems, common symptoms, starting over, reporting a bug |
|
||||||
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
|
| [Recovering on a new computer](RECOVERY.md) | What survives a lost machine and how to restore your synced folders |
|
||||||
|
| [Security model](SECURITY_MODEL.md) | What protects your files, what is kept on this computer, what the client connects to |
|
||||||
| [Changelog](CHANGELOG.md) | What changed in each release |
|
| [Changelog](CHANGELOG.md) | What changed in each release |
|
||||||
|
|
||||||
## For contributors
|
## For contributors
|
||||||
|
|
||||||
| Page | What it covers |
|
| Page | What it covers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; releasing |
|
| [Development guide](DEVELOPMENT.md) | Building, running and debugging from source; common changes; releasing |
|
||||||
| [Architecture](ARCHITECTURE.md) | Crates, read and write paths, sync engine, control protocol, threads, threat model |
|
| [Architecture](ARCHITECTURE.md) | Crates, the local tree, read and write paths, the queue, the sync engine, the control protocol, threads, threat model |
|
||||||
| [Testing](TESTING.md) | Unit tests and the FUSE acceptance suite |
|
| [Testing](TESTING.md) | Unit tests, simulation runs and the FUSE acceptance suite |
|
||||||
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
| [Translating](TRANSLATING.md) | Adding and updating translations |
|
||||||
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
| [Roadmap](ROADMAP.md) | Planned work and verification still owed |
|
||||||
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
| [Bug ledger](BUGS.md) | Every tracked defect with its analysis and status |
|
||||||
| [UI/UX plan](UI_UX_PLAN.md) | Design audit and notes behind the 2.x desktop app |
|
|
||||||
|
|
||||||
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
Also see [CONTRIBUTING.md](../CONTRIBUTING.md) and [SECURITY.md](../SECURITY.md) in the repository
|
||||||
root.
|
root.
|
||||||
@@ -133,6 +133,29 @@ pdfs sync restore --device UID
|
|||||||
The folders stay part of the other computer's backup and sync in both directions. This machine
|
The folders stay part of the other computer's backup and sync in both directions. This machine
|
||||||
does not take over that computer's backup.
|
does not take over that computer's backup.
|
||||||
|
|
||||||
|
### Moving another computer's backup here
|
||||||
|
|
||||||
|
To switch computers for good, for example from a Windows PC to this Linux machine, move the old
|
||||||
|
computer's folders into this computer's backup instead. Use **Move to This Computer…** on that
|
||||||
|
computer's row, or:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs devices list # find the old computer's UID
|
||||||
|
pdfs devices migrate UID
|
||||||
|
```
|
||||||
|
|
||||||
|
Each folder moves on Proton Drive itself, so nothing is uploaded again and no storage is used
|
||||||
|
twice. A folder whose name this computer already uses gets the old computer's name added, for
|
||||||
|
example `Documents (DESKTOP-1)`. The restore picker then opens, as in
|
||||||
|
[Step 3](#step-3-restore-the-folders), so you choose where each folder goes here.
|
||||||
|
|
||||||
|
The old computer stays in your account, without those folders. Unlike
|
||||||
|
[adoption](#step-2-continue-the-old-computers-backup), this machine keeps its own name and backup.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Stop Proton Drive on the old computer first. A client still running there sees its folders
|
||||||
|
> disappear, and may upload them again as new.
|
||||||
|
|
||||||
## 3. How the restore works
|
## 3. How the restore works
|
||||||
|
|
||||||
This section is for the curious and for anyone debugging a restore.
|
This section is for the curious and for anyone debugging a restore.
|
||||||
|
|||||||
+47
-39
@@ -1,8 +1,7 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Planned work and open verification, as of 3.0.0. Individual defects and their status live in
|
Planned work and open verification after 3.0.0. Individual defects and their status are in
|
||||||
[BUGS.md](BUGS.md), which is the authoritative ledger; this page groups what is left by theme.
|
[BUGS.md](BUGS.md); this page groups what is left by theme. Nothing here has a date.
|
||||||
Nothing here is a promise of a date.
|
|
||||||
|
|
||||||
- [Sync engine](#sync-engine)
|
- [Sync engine](#sync-engine)
|
||||||
- [Data safety and recovery](#data-safety-and-recovery)
|
- [Data safety and recovery](#data-safety-and-recovery)
|
||||||
@@ -14,38 +13,42 @@ Nothing here is a promise of a date.
|
|||||||
## Sync engine
|
## Sync engine
|
||||||
|
|
||||||
3.0.0 records changes made through the mount locally and sends them from the queue
|
3.0.0 records changes made through the mount locally and sends them from the queue
|
||||||
([MILESTONE-3.0.0.md](MILESTONE-3.0.0.md)). What comes after it:
|
([ARCHITECTURE.md §4](ARCHITECTURE.md#4-write-path)). What comes next:
|
||||||
|
|
||||||
| Item | State | Notes |
|
| Item | State | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Remove the online path | Not started | The old way of sending a change before the syscall returns, and the `local_first` switch that brings it back, go in the release after 3.0.0 |
|
| Remove the online path | Not started | The old way of sending a change inside the syscall, and the `local_first` switch that brings it back, go in the next release |
|
||||||
| One applier for remote changes | Not started | Phase 4 of the milestone: a remote tree, and remote changes applied through the same planner as local ones |
|
| One applier for remote changes | Not started | Everything that comes from Drive (events, listings) changes the tree in one place, which is also the only place that tells the kernel. Replaces today's invalidation helpers and in-memory overlays |
|
||||||
| Synced folders on the queue | Not started | Phase 5: synced folders sent by the same planner and executor as the mount |
|
| Synced folders on the queue | Not started | Mirrored folders feed the local tree and use the same queue as the mount; `sync_entry` becomes the record of the last agreed state. Switching a folder's mode then moves no data except to evict or fetch content |
|
||||||
| Batch trashes and moves | Deferred | See below |
|
| Batch trashes and moves | Deferred | See below |
|
||||||
|
| Provisional values as types | Not started | A size or block geometry that is not known yet as its own type, so it cannot reach the kernel as if it were known |
|
||||||
|
| Deadlines on every remote call | Partly done | The drain's calls and calls a syscall waits on have one; background calls outside the drain do not yet |
|
||||||
|
|
||||||
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take up to 150 nodes per
|
**Batching trashes and moves.** `trash_nodes` and `move_nodes_streaming` take many nodes per call,
|
||||||
call, but the queue still sends one node per call. It was left out of 3.0.0 on purpose:
|
but the queue sends one node per call. It was left out of 3.0.0 on purpose:
|
||||||
|
|
||||||
- The speed target of the milestone (§7) is met without it. Making 1,000 files in 100 folders
|
- The 3.0.0 speed target is met without it. Making 1,000 files in 100 folders through the mount
|
||||||
takes 5.2 s on a LAN and 5.0 s on Wi-Fi.
|
takes 5.2 s on a LAN profile and 5.0 s on a Wi-Fi profile in the simulation
|
||||||
- What the queue spends its time on is the creates. Each one waits for Drive to list the new node
|
(`PDFS_SIM_MEASURE`, see [TESTING.md](TESTING.md#simulation-runs)).
|
||||||
before it counts as landed (250 ms, then 1 s), and batching does not help with that.
|
- The drain spends its time on creates. Each waits for Drive to list the new node before it
|
||||||
- Sending many ops in one call means splitting one answer back into many results. Races in
|
counts as landed, and batching does not shorten that.
|
||||||
exactly that step were the largest class of bugs found for 3.0.0 (B15x to B17x).
|
- One call answering for many ops has to be split back into results per op. Races in exactly that
|
||||||
|
step were the largest class of bugs found for 3.0.0 (B15x to B17x in [BUGS.md](BUGS.md)).
|
||||||
|
|
||||||
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
|
Before building it, measure `rm -r` of a folder with 1,000 files on Wi-Fi. If that is slow, the
|
||||||
cheaper fix is to drop the queued trashes of the files in a folder when the folder itself is
|
cheaper fix is to drop the queued trashes of a trashed folder's contents: trashing the folder takes
|
||||||
trashed, since trashing the folder takes its contents with it.
|
them along.
|
||||||
|
|
||||||
## Data safety and recovery
|
## Data safety and recovery
|
||||||
|
|
||||||
| Item | State | Notes |
|
| Item | State | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a blocking `pdfs sync flush`. Today the queue is visible through `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
| Protect queued changes at shutdown | Not started | A systemd inhibitor lock while `staging/` holds unsent data, a warning when signing out, and a `pdfs sync flush` that waits for the queue. Today the queue is visible in `pdfs sync queue`, the tray and **Sync → Overview**, but nothing stops a shutdown (B66) |
|
||||||
| Restore pins from the machine profile | Not started | `profile.json` records pins; `sync restore` does not apply them yet |
|
| Restore pins from the machine profile | Not started | `profile.json` records pins; `pdfs sync restore` does not apply them yet |
|
||||||
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the UI (B68) |
|
| Profile backup health | Not started | A failed profile upload is a `WARN` in the log and nothing in the app (B68) |
|
||||||
| Rotated credentials survive a keyring failure | Open | Surface and retry keyring write failures so a rotated single-use refresh token is not lost (B61) |
|
| Rotated credentials survive a keyring failure | Open | Report and retry keyring write failures, so a rotated single-use refresh token is not lost (B61) |
|
||||||
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
| Restore never picks a new device silently | Open | Ask when the machine's device is ambiguous after a loss of local state (B67) |
|
||||||
|
| Encrypt local state at rest | Not started | SQLCipher for `cache.db` and an AEAD for the content cache, keyed from the keyring. `sdk_cache.db` is already encrypted. See [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -53,9 +56,10 @@ trashed, since trashing the folder takes its contents with it.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
| File-manager integration | Not started | Context-menu actions (available offline, share link, versions) and sync badges for Nautilus, Dolphin and Thunar |
|
||||||
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
| Terminal UI | Not started | `pdfs tui` with transfers, sync state and logs |
|
||||||
| Multiple accounts | Not started | Separate config, state and mountpoint per profile, one daemon each |
|
| Multiple accounts | Not started | Separate config, state and mountpoint per account, one daemon each |
|
||||||
| Mount options | Not started | Read-only mount, `allow_other` |
|
| Mount options | Not started | Read-only mount, `allow_other` |
|
||||||
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, creating one fails with `EOPNOTSUPP` |
|
| Symbolic links | Not started | Proton Drive has no link type; links would need a placeholder format. Until then, `symlink` fails with `EPERM` |
|
||||||
|
| `fsync` that waits for Drive | Idea | `fsync` means "durable locally". A mount option or an xattr could offer "on Drive" without slowing the default |
|
||||||
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
| Add someone else's photo to your album | Blocked | Needs `copyPhoto` in the SDK |
|
||||||
| Photo name search | Not started | Photos search by date and filters only |
|
| Photo name search | Not started | Photos search by date and filters only |
|
||||||
| Automatic photo upload from a folder | Not started | |
|
| Automatic photo upload from a folder | Not started | |
|
||||||
@@ -66,34 +70,38 @@ trashed, since trashing the folder takes its contents with it.
|
|||||||
|
|
||||||
| Item | Notes |
|
| Item | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate so an accounting drift cannot wrap in release builds |
|
| Saturating stream-ring accounting | `self.bytes -= …` in `crates/pdfs-fuse/src/reads.rs` should saturate, so an accounting drift cannot wrap in release builds |
|
||||||
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
| Block geometry learned one read late | The first read of a file plans on 4 MiB blocks; accepted as a bounded cost (B87) |
|
||||||
| Path-based commands under secondary mounts | `pin`, `rename`, `move`, `rm` and sharing cannot name a path inside an on-demand synced folder yet (B88) |
|
| Path-based commands under online-only folders | `pin`, `unpin`, opening a file, uploads, sharing and versions cannot name a path inside an online-only synced folder yet. `rm`, `rename`, `mkdir` and `move` can (B88) |
|
||||||
| Unicode normalization | NFC and NFD names are distinct, deliberately (B36) |
|
| Unicode normalization | NFC and NFD names are distinct, on purpose (B36) |
|
||||||
| Pipelined node enumeration | Faster cold listings of large folders |
|
| Pipelined node enumeration | Faster first listings of large folders |
|
||||||
|
|
||||||
## Verification still owed
|
## Verification still owed
|
||||||
|
|
||||||
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or offline coverage but have not been
|
Fixes marked *unverified* in [BUGS.md](BUGS.md) have unit or simulation coverage but have not
|
||||||
driven against a live account. The larger outstanding runs:
|
been driven against a live account. The larger runs still owed:
|
||||||
|
|
||||||
|
- **A clean account run on 3.0.0.** The last account run before the release (2026-10-04, on Wi-Fi)
|
||||||
|
failed only its cleanup, on a script bug fixed since. No full run with `--journal-check` has
|
||||||
|
passed on the released code yet.
|
||||||
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
- **Disaster-recovery drill.** Register a device, sync a folder, delete all local state, restore
|
||||||
with `pdfs sync restore` and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
with `pdfs sync restore`, and compare the tree byte for byte. See [RECOVERY.md](RECOVERY.md).
|
||||||
- **Second-account sharing.** Shared trees on a foreign volume, accepting invitations, changing
|
- **Second-account sharing.** Shared folders on a foreign volume, accepting invitations, changing
|
||||||
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
roles, the viewer cases of `regression B34`, and an editor writing into someone else's share.
|
||||||
- **Fault injection.** Network loss mid-upload, a full disk during staging, and crash points
|
- **Fault injection on a real account.** Network loss mid-upload, a full disk during staging, and
|
||||||
across the write publication path (after each fsync, rename and queue commit).
|
crashes at each step of publishing a write. The simulation covers link loss and restarts against
|
||||||
|
the fake Drive, not a kill at an arbitrary point.
|
||||||
|
- **Acceptance runs across a network drop.** A case that spans an outage times out while the queue
|
||||||
|
replays, instead of being reported as interrupted.
|
||||||
- **CAPTCHA sign-in** against a real verification request (B8).
|
- **CAPTCHA sign-in** against a real verification request (B8).
|
||||||
- **Managed live mode matrix** before each release:
|
- **The overnight hang** (B91) is not diagnosed. The supervisor and `pdfs diagnostics` exist to
|
||||||
`scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>`. See [TESTING.md](TESTING.md).
|
catch the next one.
|
||||||
- **The overnight hang** (B91) is not diagnosed yet. The supervisor and `pdfs diagnostics` exist to
|
|
||||||
catch the next occurrence.
|
|
||||||
|
|
||||||
## Release assurance
|
## Release assurance
|
||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Tag, workspace and PKGBUILD version agreement | Enforced by the release workflow |
|
| Tag, workspace and PKGBUILD version agree | Enforced by the release workflow |
|
||||||
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
| Clean-install test of the `.deb` and `.rpm` in a VM | Pending (B62) |
|
||||||
| Protocol version identity between front ends and daemon | Open (B63) |
|
| Protocol version identity between front ends and daemon | Open (B63) |
|
||||||
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
| SBOM, provenance and checksums for release artifacts | Open (B65). The RPM is GPG-signed |
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Security model
|
||||||
|
|
||||||
|
This page explains what protects your files and account when you use this client, what it keeps
|
||||||
|
on your computer, and what it does not protect against. It is a summary for users. The details,
|
||||||
|
with references to the code, are in the
|
||||||
|
[threat model](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext). To
|
||||||
|
report a vulnerability, see [SECURITY.md](../SECURITY.md).
|
||||||
|
|
||||||
|
This is an unofficial hobby project, not a Proton product. It has had no independent security
|
||||||
|
audit. The source is open under the MIT license, so you can read and build all of it yourself.
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
Proton Drive is end-to-end encrypted, and this client does not change that:
|
||||||
|
|
||||||
|
- **Files are encrypted on this computer before they are uploaded,** and decrypted on this
|
||||||
|
computer after they are downloaded. Proton never receives your keys or the content of your
|
||||||
|
files.
|
||||||
|
- **The cryptography comes from the SDK, not from this client.** The client uses
|
||||||
|
[`proton-sdk` and `proton-drive-rs`](https://github.com/narrrl/proton-sdk-rs), a Rust port of
|
||||||
|
Proton's official C# and TypeScript Drive SDKs. OpenPGP is done by the
|
||||||
|
[rPGP](https://github.com/rpgp/rpgp) library. The client itself does not implement any
|
||||||
|
encryption.
|
||||||
|
- **Your password is not sent to Proton.** Sign-in uses Proton's SRP protocol, which proves that
|
||||||
|
you know the password without sending it.
|
||||||
|
|
||||||
|
## What is kept on this computer
|
||||||
|
|
||||||
|
| What | Where | Protected by |
|
||||||
|
|---|---|---|
|
||||||
|
| Session tokens and the key passphrases | The system keyring (Secret Service: GNOME Keyring, KWallet, KeePassXC) | The keyring's own encryption and your login |
|
||||||
|
| Your password | Nowhere | — |
|
||||||
|
| Cached files, thumbnails and changes waiting to upload | `~/.cache/proton-drive-linux/content/` | File modes only: **not encrypted** |
|
||||||
|
| File and folder names, the folder tree, the activity log | `~/.local/state/proton-drive-linux/cache.db` | File modes only: **not encrypted** |
|
||||||
|
| The SDK's metadata cache | `~/.local/state/proton-drive-linux/sdk_cache.db` | Encrypted with a key derived from your mailbox password |
|
||||||
|
| Settings | `~/.config/proton-drive-linux/config.json` | No secrets are stored here |
|
||||||
|
|
||||||
|
The cache holds decrypted content so that files open fast and work offline. **The client assumes
|
||||||
|
that your disk is encrypted** (LUKS, or an encrypted home directory). On an unencrypted disk,
|
||||||
|
someone who has the powered-off computer can read the cached files and the names in your Drive.
|
||||||
|
|
||||||
|
Accounts with a separate mailbox password (two-password mode) are locked by default. Their key
|
||||||
|
passphrases are kept in the kernel keyring for your login session only, and you unlock again
|
||||||
|
after each login. Storing them in the system keyring is an opt-in.
|
||||||
|
|
||||||
|
## Who can use the client
|
||||||
|
|
||||||
|
- The state, cache and config directories must belong to you and have mode `0700`. The daemon
|
||||||
|
checks this at every start and refuses to run otherwise.
|
||||||
|
- The control socket that the app, tray and `pdfs` use has mode `0600`. Only your user account
|
||||||
|
can connect to it.
|
||||||
|
- Any program that runs as your user can read, change and delete files in `~/ProtonDrive`, the same
|
||||||
|
as in any other folder you own. It is your Proton Drive, not a copy: a deleted file is moved to
|
||||||
|
the Proton Drive trash, where you can restore it.
|
||||||
|
|
||||||
|
## Network connections
|
||||||
|
|
||||||
|
The client connects only to:
|
||||||
|
|
||||||
|
- **Proton**, for the Drive and account API, and for the CAPTCHA page when Proton asks for one.
|
||||||
|
- **OpenFreeMap**, for the street map of Places in Photos, only if you turn it on in Preferences. It is off by default, because the tile server learns which parts of the
|
||||||
|
world you look at. The default map is drawn from data shipped with the app.
|
||||||
|
|
||||||
|
There is no telemetry, no analytics and no update check. The SDK can report timings of its own
|
||||||
|
operations, and the client only writes them to its local log.
|
||||||
|
|
||||||
|
## What this client does not protect against
|
||||||
|
|
||||||
|
- **Malware running as your user.** It can read the cache, connect to the control socket, and use
|
||||||
|
your Drive through the mount, as it can with any data you have access to.
|
||||||
|
- **Swap.** Keys and decrypted data are in ordinary memory and can be written to swap. Use
|
||||||
|
encrypted swap.
|
||||||
|
- **The kernel page cache.** File content read through the mount is cached by the kernel like any
|
||||||
|
other file.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- The `.rpm` is signed with GPG.
|
||||||
|
- The `.deb` and the `.tar.gz` are built by GitHub Actions from the tagged source, and are not
|
||||||
|
signed.
|
||||||
|
- The AUR packages build from the tagged source or from `main`, or install the release binaries
|
||||||
|
checked against their SHA-256 sums.
|
||||||
|
- The Nix flake builds from source. The binary cache on Cachix holds builds made by CI.
|
||||||
+207
-451
@@ -1,509 +1,265 @@
|
|||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
The normal Rust suite exercises the metadata database, sync planner, offline
|
Three layers check the client, from fast and offline to slow and against a real account:
|
||||||
queue, write staging, cache, and FUSE handler state without requiring a Proton
|
|
||||||
account:
|
|
||||||
|
|
||||||
```console
|
| Layer | Needs | Runs in CI |
|
||||||
|
|---|---|---|
|
||||||
|
| [Unit tests](#unit-tests) | Nothing | Every push and pull request |
|
||||||
|
| [Simulation runs](#simulation-runs) | `/dev/fuse` | Every push to `main` |
|
||||||
|
| [Acceptance suite](#acceptance-suite) | Nothing for the reference run; a signed-in account for the rest | The reference run only |
|
||||||
|
|
||||||
|
## Unit tests
|
||||||
|
|
||||||
|
```bash
|
||||||
cargo test --workspace
|
cargo test --workspace
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
```
|
```
|
||||||
|
|
||||||
## Filesystem API and live FUSE acceptance suite
|
Tests sit in `#[cfg(test)]` modules next to the code. Database tests are in
|
||||||
|
`crates/pdfs-core/src/db/tests.rs`; a schema change adds one that migrates from the previous
|
||||||
|
version.
|
||||||
|
|
||||||
The account-free suite exercises the filesystem syscall contract against a
|
To check the migrations against a real database, point `PDFS_MIGRATE_DB` at it and run the
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
ignored test. It migrates a copy taken with `VACUUM INTO` and never writes the database named:
|
||||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
|
||||||
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
|
||||||
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
|
||||||
under concurrent mutation, metadata updates, extended attributes, allocation and
|
|
||||||
punched holes, refusal semantics for unimplemented operations, concurrent I/O,
|
|
||||||
and real application workloads. It never reads application state or contacts
|
|
||||||
Proton:
|
|
||||||
|
|
||||||
```console
|
```bash
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
PDFS_MIGRATE_DB=~/.local/state/proton-drive-linux/cache.db \
|
||||||
|
cargo test -p pdfs-core a_copy_of_a_real_database -- --ignored
|
||||||
```
|
```
|
||||||
|
|
||||||
Kernel/FUSE behavior and remote convergence need a real mount. With no
|
## Simulation runs
|
||||||
arguments, the script tests the account the running service is logged in to:
|
|
||||||
|
|
||||||
```console
|
The simulation runs (`crates/pdfs-fuse/src/sim/`) start real daemons with real FUSE mounts against
|
||||||
scripts/fuse-acceptance.sh # every case, every mount kind
|
an in-memory Drive, `FakeDrive`. The daemon runs as in production (drain workers, sync engine,
|
||||||
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pair instead of four
|
event feed, online probe, control socket); only its Drive is fake. `FakeDrive` has the faults the
|
||||||
|
real one has: listings that lag a create, renames refused for a stale name hash, no atomic
|
||||||
|
replace, delayed and reordered events, requests that stall or lose their answer, per-request
|
||||||
|
latency, and blocks of uneven size.
|
||||||
|
|
||||||
|
A run draws a sequence of steps from a seed: syscalls through a client's mount, the link going
|
||||||
|
down and up, restarts, settles (every queue must drain within a budget), and files deleted while
|
||||||
|
the drain holds a write to them. Every syscall is checked against an in-memory model of the
|
||||||
|
filesystem as it happens. At the end every link comes up and the run checks:
|
||||||
|
|
||||||
|
1. **No loss and POSIX.** Drive's copy of each client's folder is what its model says.
|
||||||
|
2. **No false conflicts.** No conflict copy anywhere; each file has one writer.
|
||||||
|
3. **Convergence.** Each mount shows Drive's tree.
|
||||||
|
4. **Liveness.** No syscall took longer than its budget, and every queue drained once the links
|
||||||
|
were up. A call that never returns is caught by a watchdog that writes every thread's stack to
|
||||||
|
`stacks.txt` in the run's state directory.
|
||||||
|
|
||||||
|
| Test | Seeds |
|
||||||
|
|---|---|
|
||||||
|
| `one_client_on_a_good_link` | 12 |
|
||||||
|
| `one_client_deleting_files_as_they_upload` | 3 |
|
||||||
|
| `one_client_with_stale_hashes_and_reordered_echoes` | 6 |
|
||||||
|
| `one_client_on_a_flaky_link` | 4 |
|
||||||
|
| `one_client_on_a_slow_link` | 4 |
|
||||||
|
| `three_clients_one_writer_each` | 3 |
|
||||||
|
|
||||||
|
`sim/daemon.rs` holds scenario tests for single bugs, run the same way.
|
||||||
|
|
||||||
|
The tests mount FUSE, so they are ignored by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test -p pdfs-fuse --lib sim:: -- --ignored --test-threads=1 # what CI runs
|
||||||
|
PDFS_SIM_SEEDS=50 cargo test -p pdfs-fuse --lib sim::run -- --ignored --test-threads=1
|
||||||
|
PDFS_SIM_SEED=7 RUST_LOG=pdfs_fuse=debug cargo test -p pdfs-fuse --lib \
|
||||||
|
sim::run::tests::one_client_on_a_flaky_link -- --ignored --exact --nocapture
|
||||||
```
|
```
|
||||||
|
|
||||||
An account run does this, in order:
|
| Variable | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `PDFS_SIM_SEEDS` | How many seeds each test runs, from 1 |
|
||||||
|
| `PDFS_SIM_SEED` | Replay one seed. A failing run prints the seed, the last steps, and where it kept its state |
|
||||||
|
| `PDFS_SIM_MEASURE` | Run `a_thousand_files_drain_as_fast_on_wifi_as_on_lan`, which times 1,000 files in 100 folders on a LAN and a Wi-Fi profile and prints the times |
|
||||||
|
|
||||||
1. Runs the account-free contract as the reference.
|
A failure is a seed that can be replayed, but the daemon uses real threads, so a race may need
|
||||||
2. Runs the contract in a fresh `pdfs-acceptance-<id>` folder in My files and
|
several replays. In CI, the `simulation` job uploads the stacks of a hung run as an artifact.
|
||||||
diffs it against the reference. After the last case it reads back a file
|
|
||||||
the run wrote and records its digest. A read that fails there, as one did
|
|
||||||
with the link down (B119), is a failed result named "a file of this run
|
|
||||||
reads back at the end", and the cleanup still runs.
|
|
||||||
3. Registers two new sync folders under `~/.cache/pdfs-acceptance/run-<id>/`
|
|
||||||
and runs the contract and the move cases in every on-demand/mirror pairing.
|
|
||||||
4. Removes everything it created, locally and remotely, and reports a
|
|
||||||
`cleanup` result. The run fails if anything is left behind.
|
|
||||||
|
|
||||||
Cleanup identifies remote folders by uid, never by name alone, so nothing of
|
## Acceptance suite
|
||||||
yours is touched. It removes each sync folder with `sync rm --delete-remote`,
|
|
||||||
trashes the My files folder, and then deletes both permanently from the trash.
|
|
||||||
It only counts a folder as gone once a fresh trash listing no longer shows it.
|
|
||||||
It then reads `pdfs sync queue` and counts any queued op that still names one
|
|
||||||
of its folders as left behind too, since such an op keeps its staged bytes and
|
|
||||||
retries forever (B102).
|
|
||||||
|
|
||||||
Ops that were queued before a target starts are not the run's. The run prints
|
`scripts/fuse-acceptance.sh` checks the filesystem contract a program sees: creation and open
|
||||||
one `NOTE` line naming them, and the queue waits ignore them. They are told
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`, `sendfile`,
|
||||||
apart by id and queue time together, since SQLite can give a new op the id of
|
`copy_file_range`, durability barriers, renames and their `renameat2` flags, open-file lifetime,
|
||||||
one that has drained.
|
names, `readdir` under concurrent change, metadata, extended attributes, holes, refusals of
|
||||||
|
unsupported operations, concurrent I/O, and real application workloads.
|
||||||
|
|
||||||
A write is queued when the kernel releases the file, and `close(2)` returns
|
> [!CAUTION]
|
||||||
without waiting for that. A case that reads `pdfs sync queue` right after
|
> Every mode except `--offline-only` creates and deletes real files in Proton Drive. Use a test
|
||||||
writing therefore waits for the ops it expects (`Daemon.queued`) instead of
|
> account with nothing you cannot lose.
|
||||||
reading once. "regression B102" saw one revision of two before it did.
|
|
||||||
|
|
||||||
Cleanup also runs on a failure, Ctrl-C, `SIGTERM` or `SIGHUP`. While it runs,
|
### Modes
|
||||||
two more interrupts are ignored; a third abandons it.
|
|
||||||
|
|
||||||
Each run records what it is about to create in
|
```bash
|
||||||
`~/.cache/pdfs-acceptance/run-<id>/manifest.json` before it creates it, and
|
scripts/fuse-acceptance.sh --offline-only # the reference run; no account
|
||||||
holds a lock on that directory. If a run dies without cleaning up (`kill -9`, a
|
scripts/fuse-acceptance.sh # the signed-in account, every mode pairing
|
||||||
hard timeout, a power cut), the next run finds the unlocked manifest and
|
scripts/fuse-acceptance.sh --quick # one on-demand/mirror pairing instead of four
|
||||||
finishes that cleanup first. It then removes stray `pdfs-acceptance-<32 hex>`
|
scripts/fuse-acceptance.sh --live /mnt/testmount # mounts you set up yourself
|
||||||
folders that no live run claims: in My files (older than
|
scripts/fuse-acceptance.sh --managed-live /mnt/a /mnt/b
|
||||||
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and
|
|
||||||
in the trash. A run does not start while that reap leaves anything behind.
|
|
||||||
|
|
||||||
The run creates and destroys real remote folders, so a dedicated test account
|
|
||||||
without irreplaceable data is still the safest target.
|
|
||||||
|
|
||||||
`--live PATH...` tests mounts you set up yourself and leaves the sync folder
|
|
||||||
configuration alone. It runs destructive POSIX operations only below a fresh
|
|
||||||
`pdfs-acceptance-*` directory, refuses paths that are not FUSE mounts, and
|
|
||||||
removes its directory on success, failure, or interruption:
|
|
||||||
|
|
||||||
```console
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**The reference run** always runs first, against a temporary local directory. It records the
|
||||||
|
facts each case establishes, and every mount is then **compared** against those facts. A case
|
||||||
|
fails when the mount differs from an ordinary filesystem, not only when it raises. A fact that may
|
||||||
|
legitimately differ (inode numbers, block counts, which optional syscalls exist) is **noted**
|
||||||
|
instead, and listed under the target's `accepted` line. The choice is made in the test body, so
|
||||||
|
the accepted differences are visible where they are accepted. Prefer `record`; use `note` only
|
||||||
|
for something a network filesystem cannot do.
|
||||||
|
|
||||||
|
**An account run** (no mode):
|
||||||
|
|
||||||
|
1. Runs the reference.
|
||||||
|
2. Runs the contract in a new `pdfs-acceptance-<id>` folder in My files, and at the end reads back
|
||||||
|
a file the run wrote.
|
||||||
|
3. Registers two sync folders under `~/.cache/pdfs-acceptance/run-<id>/` and runs the contract and
|
||||||
|
the move cases in every pairing of on-demand and mirror. Before a folder switches to on-demand,
|
||||||
|
a new file is written into it; once mounted, the file must be listed with its bytes. In every
|
||||||
|
pairing each folder must still hold the file written into it at setup.
|
||||||
|
4. Removes everything it made, locally and on Drive, and reports `cleanup`. A run that leaves
|
||||||
|
anything behind fails.
|
||||||
|
|
||||||
|
Cleanup finds remote folders by uid, never by name, so nothing of yours is touched. It removes
|
||||||
|
each sync folder with `sync rm --delete-remote`, trashes the My files folder, deletes both from
|
||||||
|
the trash for good, and checks that a fresh trash listing no longer shows them. Queued ops that
|
||||||
|
still name one of its folders count as left behind. Cleanup also runs on a failure, Ctrl-C,
|
||||||
|
`SIGTERM` or `SIGHUP`; while it runs, two more interrupts are ignored and a third abandons it.
|
||||||
|
|
||||||
|
Each run writes what it is about to create to `~/.cache/pdfs-acceptance/run-<id>/manifest.json`
|
||||||
|
first and holds a lock on that directory. A later run finishes the cleanup of a run that died, and
|
||||||
|
removes stray `pdfs-acceptance-<32 hex>` folders no live run claims: in My files (older than
|
||||||
|
`PDFS_ACCEPTANCE_REAP_AGE`, default one hour), under the acceptance home, and in the trash. It
|
||||||
|
does not start while anything is left.
|
||||||
|
|
||||||
|
Ops that were queued before the run started are not the run's: it prints one `NOTE` line naming
|
||||||
|
them, and its queue waits ignore them.
|
||||||
|
|
||||||
|
**`--live PATH...`** runs the contract on mounts you set up, and leaves the sync configuration
|
||||||
|
alone. It works only below a new `pdfs-acceptance-*` directory, refuses paths that are not FUSE
|
||||||
|
mounts, and removes its directory afterwards. The first path must be a FUSE mount; later paths may
|
||||||
|
be another mount or a mirrored folder. If all paths show the **same remote folder**, set
|
||||||
|
`PDFS_ACCEPTANCE_CONVERGENCE=1` to also check that bytes written through the first appear through
|
||||||
|
the others.
|
||||||
|
|
||||||
|
**`--managed-live A B`** takes two existing, empty, unmounted directories, registers them as sync
|
||||||
|
folders, and runs every pairing as an account run does, then deletes the remote folders. It
|
||||||
|
refuses non-empty paths, mounts, and paths already in `pdfs sync list`.
|
||||||
|
|
||||||
### Reading the output
|
### Reading the output
|
||||||
|
|
||||||
A run opens with what it is about to test: the mode, the `pdfs` binary and its
|
A run opens with the mode, the `pdfs` binary and version, Python, the kernel, the per-case limit
|
||||||
version, Python and the kernel, the per-case limit, the options and the start
|
and the options. Each target then names its root, the storage behind it, the daemon and the
|
||||||
time. Each target then names its root, the storage behind it, the daemon it
|
number of cases, followed by one line per case:
|
||||||
talks to and how many cases it runs, followed by one line per case:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
==> live FUSE /mnt/testmount
|
==> live FUSE /mnt/testmount
|
||||||
-> root /mnt/testmount/pdfs-acceptance-…
|
-> root /mnt/testmount/pdfs-acceptance-…
|
||||||
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
-> storage FUSE mount /mnt/testmount (fuse.protondrive)
|
||||||
-> daemon pdfs 2.8.3, user@proton.me, online, 0 queued ops
|
-> daemon pdfs 3.0.0, user@proton.me, online, 0 queued ops
|
||||||
-> cases 45 of 45
|
-> cases 45 of 45
|
||||||
[ 1/45] creation and open flags .................................. ok 0.41s
|
[ 1/45] creation and open flags .................................. ok 0.41s
|
||||||
…
|
…
|
||||||
[21/45] throughput floors ........................................ ok 48.20s
|
[21/45] throughput floors ........................................ ok 48.20s
|
||||||
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
write 31 MiB/s, read 54 MiB/s, 4 KiB writes 12.0 MiB/s, metadata 9 ops/s
|
||||||
…
|
…
|
||||||
[43/45] regression B115: pdfs ls reports a file's real size ...... ok 6.12s
|
|
||||||
-> reference matches the ordinary filesystem in 412 compared observations
|
-> reference matches the ordinary filesystem in 412 compared observations
|
||||||
-> result 44 passed, 1 skipped in 11m02s
|
-> result 44 passed, 1 skipped in 11m02s
|
||||||
```
|
```
|
||||||
|
|
||||||
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` when it does not apply to the
|
A case ends as `ok`, `FAIL`, `TIMEOUT`, `skip` (it does not apply to the target), or `known` (an
|
||||||
target (no daemon, no share at that role), or `known` when an `open B<n>` case
|
`open B<n>` case reproduced a bug that is still open). Dimmed lines under a case hold its details.
|
||||||
reproduced a bug that is still open (see below). The dimmed lines under a case
|
A failed `regression B<n>` case points to its entry in [BUGS.md](BUGS.md).
|
||||||
are its details: the reason for a skip, measured rates, a hash. A failed
|
|
||||||
`regression B<n>` case also points to its entry in `docs/BUGS.md`. When a case
|
|
||||||
prints something itself, such as a service restart, the case line is printed
|
|
||||||
again once the case ends.
|
|
||||||
|
|
||||||
The run ends with a table of every target's counts and time, the report paths,
|
The run ends with a table of every target, the report paths, the slowest cases, cases over
|
||||||
the slowest cases, the cases over `--budget`, the known issues that still
|
`--budget`, known issues that still reproduce, errors the daemon logged, and each failure. The
|
||||||
reproduce, any errors the daemon logged, and each failure with its target. The
|
last line is `PASS:` or `FAIL:` with the totals. Colour is used only on a terminal and never with
|
||||||
last line is `PASS:` or `FAIL:` and the totals. Colour is used only on a
|
`NO_COLOR` set.
|
||||||
terminal and never with `NO_COLOR` set.
|
|
||||||
|
|
||||||
### Edge cases and performance
|
### What the cases cover
|
||||||
|
|
||||||
Besides the syscall contract, the suite covers:
|
Besides the syscall contract:
|
||||||
|
|
||||||
- **Block boundaries.** Files of 0 bytes up to two 4 MiB blocks plus 3 bytes,
|
- **Block boundaries.** Files from 0 bytes to two 4 MiB blocks plus 3 bytes, every size next to a
|
||||||
including every size next to a boundary, then overwrites that grow, shrink
|
boundary, and overwrites that grow, shrink and reshape them.
|
||||||
and reshape them.
|
- **Unusual names.** 255 bytes, NFC and NFD forms of the same text, newline, tab, emoji,
|
||||||
- **Unusual names.** A 255-byte name, NFC and NFD forms of the same text,
|
characters Windows forbids, leading and trailing spaces, a case-only rename.
|
||||||
newline, tab, emoji, characters Windows forbids, leading and trailing
|
- **A deep tree and a wide folder.** 24 levels renamed at the top; 128 entries created
|
||||||
spaces, and a case-only rename.
|
concurrently, unlinked concurrently, then `rm -rf`.
|
||||||
- **A deep tree and a wide directory.** 24 levels of nesting renamed at the
|
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an open reader, the
|
||||||
top; 128 entries created concurrently, concurrent unlinks, then `rm -rf`.
|
`EISDIR`, `ENOTDIR` and `ENOENT` refusals.
|
||||||
Online, each create is a round trip to the server, and the kernel runs
|
- **Handle coherency.** Unsaved writes seen by a second handle, two `O_APPEND` writers, 30 rewrite
|
||||||
creates in one directory one at a time. So the wide case gets twice the
|
generations, a truncate under a reader.
|
||||||
per-case timeout and notes the time per create as `wide.create_ms`.
|
- **Throughput.** 64 MiB written and read, 1 MiB in 4 KiB writes, metadata on 100 files. The case
|
||||||
- **Rename patterns.** Swaps, chains, log rotation, replacing a file under an
|
fails below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS` (default 20
|
||||||
open reader, the `EISDIR`/`ENOTDIR`/`ENOENT` refusals, and a folder moved out
|
operations per second, 3 on a FUSE mount). `0` turns a floor off.
|
||||||
and back.
|
- **Operations the filesystem does not implement.** `git`, `rsync`, `cp -a`, `tar` and installers
|
||||||
- **Handle coherency.** Unsynced writes seen by a second handle, two `O_APPEND`
|
probe for symlinks, hard links, FIFOs, device nodes, locks, `O_DIRECT` and `SEEK_HOLE`. Each must
|
||||||
writers, 30 rewrite generations, and a truncate under a reader.
|
work or be refused cleanly: a known errno, never `EIO`, never a hang.
|
||||||
- **Throughput.** 64 MiB written and read sequentially, 1 MiB in 4 KiB writes,
|
- **Moves between locations** (`managed` in `--list`). Trees moved between two sync folders in
|
||||||
and metadata operations on 100 files. The rates are printed. The case fails
|
both directions, several sources in one `pdfs move`, and a tree through My files and out again.
|
||||||
below `PDFS_ACCEPTANCE_MIN_MIBPS` (default 10) or `PDFS_ACCEPTANCE_MIN_OPS`
|
The refusals must move nothing and lose nothing: a name already taken, a folder into itself, a
|
||||||
(default 20 operations per second, or 3 on a FUSE mount, where each create
|
mirrored source holding something Drive lacks, a file still open for writing.
|
||||||
and unlink waits for Drive before it returns).
|
- **The CLI.** `pdfs` into a closed pipe exits 0 with nothing on stderr; `pdfs mkdir`, `rename`
|
||||||
|
and `rm` by local path, and their refusals.
|
||||||
|
|
||||||
### The mount is diffed against an ordinary filesystem
|
### Regressions and open bugs
|
||||||
|
|
||||||
The local reference run is not only a self-test of the runner. Every case
|
A case named `regression B<n>` reproduces an entry of [BUGS.md](BUGS.md) so it stays fixed. Most
|
||||||
records the facts it established, and a live run is compared against those
|
need a live mount and a reachable daemon, and are skipped without one. `--list` prints every case
|
||||||
recordings, so a case fails when the mount *differs* from an ordinary
|
and the targets it runs on.
|
||||||
filesystem — not only when it raises. The target's `reference` line names the
|
|
||||||
exact key:
|
|
||||||
|
|
||||||
```
|
A regression case checks the outcome **on Drive**, so it waits for the queue first. Reading back
|
||||||
-> reference 1 observation of 412 differs from the ordinary filesystem:
|
through the mount proves nothing: the local caches return the bytes just written whether or not
|
||||||
! creation and open flags.truncated.size: live FUSE /mnt/testmount=6 reference=0
|
they reached Drive. A write is queued when the kernel releases the file, after `close(2)` has
|
||||||
```
|
returned, so a case that reads `pdfs sync queue` right after writing waits for the ops it expects.
|
||||||
|
|
||||||
Facts are recorded in one of two ways, chosen in the test body:
|
A case named `open B<n>` checks a bug that is still open. While it reproduces, the case ends as
|
||||||
|
`known`: listed at the end, but not a failure. When it passes, the bug is fixed: rename the case to
|
||||||
|
`regression B<n>` and make it fail with `check`. No case is open at the moment.
|
||||||
|
|
||||||
- **compared** — a claim about filesystem semantics. The mount must agree.
|
Some cases need what a test account cannot provide alone and skip without it: B34 needs a folder
|
||||||
- **noted** — context that legitimately differs: inode numbers, block counts,
|
shared read-only by a **second account**, B34b one shared with editor rights, and B79 an on-demand
|
||||||
reported mode bits, and which optional syscalls exist at all. Listed under
|
synced folder.
|
||||||
the target's `accepted` line, never a failure.
|
|
||||||
|
|
||||||
Because the choice is made at each call site, the set of accepted divergences
|
### Timeouts, reports and hung mounts
|
||||||
is visible in the test that accepts it. There is no separate allowlist to drift
|
|
||||||
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
|
||||||
ordinary filesystem can do something a network filesystem genuinely cannot.
|
|
||||||
|
|
||||||
### Operations the filesystem does not implement
|
A hung FUSE call cannot be interrupted from Python. Each case runs under a soft alarm (`--timeout`,
|
||||||
|
default 180 s, or `PDFS_ACCEPTANCE_TIMEOUT`) that reports the case and lets cleanup run, backed by
|
||||||
|
a hard stop that dumps every thread's stack and exits. After a timeout the run checks whether the
|
||||||
|
mount still answers a `stat` and a listing within 15 s. If it does, the target goes on; if not, its
|
||||||
|
remaining cases are skipped. Cases that make many round trips by design get twice the limit.
|
||||||
|
|
||||||
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
| Option | Effect |
|
||||||
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
|---|---|
|
||||||
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
| `--report-json PATH` | Every result and recorded observation. Attach it to a bug report |
|
||||||
asserts that each either works or is *refused cleanly* — a recognised errno,
|
| `--report-junit PATH` | JUnit XML. `known` is reported as skipped |
|
||||||
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
| `--budget SECONDS` | Flags cases that pass but take longer (`PDFS_ACCEPTANCE_BUDGET`) |
|
||||||
one that is dirty breaks software that never asked for the feature.
|
| `--journal-check` | Fails the run if `proton-drive.service` logged an error during it, or the warnings "queued write conflicts; keeping a conflict copy" and "a fuse worker has held the same job for a long time" |
|
||||||
|
| `--durability` | Restarts the service mid-suite and checks the bytes written before it. Proves the queue survives a restart. `PDFS_ACCEPTANCE_UNIT` names another unit |
|
||||||
### Regressions from the bug ledger
|
| `--fail-fast` | Stops at the first failure |
|
||||||
|
| `--list` | Prints every case and its targets |
|
||||||
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
|
||||||
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
|
||||||
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
|
||||||
renamed after close must keep its content) need a real mount and a reachable
|
|
||||||
daemon; they are skipped, not failed, without one.
|
|
||||||
|
|
||||||
**B100** (a copied tree reads back at its exact sizes) copies 48 files of
|
|
||||||
assorted sizes into the mount at once, as `cp -r` does. On a live mount it
|
|
||||||
waits for the queue, checks every size and byte, runs `pdfs refresh` on each
|
|
||||||
folder to drop the cached listing, and checks again. The refresh is what
|
|
||||||
brought back the provisional sizes the bug reported.
|
|
||||||
|
|
||||||
**B101** (a write during its create's upload reaches Drive) holds new files open for
|
|
||||||
different times before writing, so their empty creates land before, during and after the
|
|
||||||
bytes arrive, then checks every file after the queue drains.
|
|
||||||
|
|
||||||
**B113** (moving a file before its upload makes no conflict) pauses sync with
|
|
||||||
`pdfs sync pause --for 10m` and writes three files. While their uploads are held,
|
|
||||||
it renames one, moves one into a folder, and renames and moves the third. It then
|
|
||||||
resumes sync, waits for the queue, and checks the bytes and names and that no
|
|
||||||
conflict copy appeared. It skips when sync was already paused. If the run dies
|
|
||||||
before the resume, the pause ends by itself after ten minutes.
|
|
||||||
|
|
||||||
**B88** (`pdfs mkdir`, `rename` and `rm` by local path) creates, renames and
|
|
||||||
removes a folder and a file by their paths in the mount. Then it checks the
|
|
||||||
refusals: `pdfs rename` of the mount's root to its own name, and `pdfs rm` of a
|
|
||||||
file outside every mount. `pdfs rm` of the root itself is tried only on a sync
|
|
||||||
folder the run created. In a mirrored sync folder all three commands must be
|
|
||||||
refused instead, and nothing may change.
|
|
||||||
|
|
||||||
**pdfs output into a closed pipe** runs `pdfs ls`, `pdfs --json ls` and
|
|
||||||
`pdfs status` with stdout on a pipe whose reader is already gone. Each must exit
|
|
||||||
0 with nothing on stderr, as `pdfs ls | head -1` needs.
|
|
||||||
|
|
||||||
**B102** (removing a folder drops the uploads queued inside it) uploads a file
|
|
||||||
into each of two nested folders, pauses sync and rewrites both. While the
|
|
||||||
revisions are held, it removes one folder with `pdfs rm` and the other through
|
|
||||||
the mount. Neither revision may stay queued, and neither folder may come back
|
|
||||||
after the resume. It skips when sync was already paused.
|
|
||||||
|
|
||||||
**B94** (a deleted `.part` file leaves nothing queued) writes a `.part` file,
|
|
||||||
checks that its create is parked, and unlinks it. The parked op must leave the
|
|
||||||
queue within 30 seconds. The sweep that lets an hour-old park through is not
|
|
||||||
driven.
|
|
||||||
|
|
||||||
**B95** (a `.tmp` file uploads at once) writes `index.tmp` and `preview.temp`
|
|
||||||
and waits for the queue, which fails at once on a parked op. `pdfs ls` must
|
|
||||||
list both, and their bytes must read back.
|
|
||||||
|
|
||||||
**B111** (renaming a file straight back works) renames a file there and back
|
|
||||||
eight times through the mount. On a mount it also does so three times with
|
|
||||||
`pdfs rename`, after the queue is empty, so the renames race nothing but Drive.
|
|
||||||
It runs against the reference too.
|
|
||||||
|
|
||||||
**B121** (a file renamed and moved at once lands) renames a file, moves it into
|
|
||||||
a subfolder and renames it back across folders through the mount, with no pause
|
|
||||||
between the steps. On a mount it then runs `pdfs rename` and `pdfs move` back to
|
|
||||||
back. The file must end up where it was sent, with its bytes, and the case
|
|
||||||
reports how long each half took. It runs against the reference too.
|
|
||||||
|
|
||||||
**B98** (an upload superseded by a write ends) writes 64 MiB and waits for its
|
|
||||||
upload to show in `pdfs transfers`, then appends to the file. The queue must
|
|
||||||
drain, the old upload must leave the transfer list, and the file must hold
|
|
||||||
every byte with no conflict copy. It skips when the upload is never seen on
|
|
||||||
the wire, and it gets twice the per-case timeout.
|
|
||||||
|
|
||||||
**B80** (`pdfs search` finds a new file after a rewrite) writes a file whose
|
|
||||||
name carries a unique token, waits for the queue, rewrites it and waits again.
|
|
||||||
`pdfs search` must find it within 30 seconds.
|
|
||||||
|
|
||||||
**B48** (an emptied folder can be removed at once) writes twelve files into a
|
|
||||||
folder, lists it, and moves one more in from elsewhere. It unlinks every
|
|
||||||
child, runs `pdfs refresh` on a mount, and checks that nothing is listed and
|
|
||||||
that `rmdir` works. It runs against the reference too.
|
|
||||||
|
|
||||||
**B114** (a name `pdfs rm` or `rename` took away stops resolving) looks up two
|
|
||||||
files, removes one with `pdfs rm` and renames the other with `pdfs rename`. At
|
|
||||||
once, neither old name may be listed or resolve.
|
|
||||||
|
|
||||||
**B115** (`pdfs ls` reports a file's real size) writes files of 6 bytes, 4097
|
|
||||||
bytes and one block plus 7, and compares the sizes `pdfs ls` lists with the
|
|
||||||
written ones, after the upload and again after `pdfs refresh`.
|
|
||||||
|
|
||||||
**B116** (trashing a folder drops the creates queued in its subfolders) makes
|
|
||||||
`b116/sub/deeper` and waits for Drive. With sync paused, it writes a `.part`
|
|
||||||
file there and renames it to its final name, so its create is due but held.
|
|
||||||
It then runs `pdfs rm b116`, and the create must be gone from the queue. It
|
|
||||||
restores the folder with `pdfs restore` before it resumes sync. If the file
|
|
||||||
ends up in the mount's root anyway, the case fails and moves the file to the
|
|
||||||
trash.
|
|
||||||
|
|
||||||
**B117** (a moved file's queued upload keeps its path) rewrites an uploaded
|
|
||||||
file with sync paused and moves it with `pdfs move`. `pdfs sync queue` must
|
|
||||||
list the upload under its new path, and the journal must not show the upload
|
|
||||||
put off with "pending operation deferred". The bytes must arrive at the new
|
|
||||||
path with no conflict copy. Without `journalctl`, only the queue is checked.
|
|
||||||
|
|
||||||
**B119** (an uploaded file stays cached) writes a small file and one of a block
|
|
||||||
plus 119 bytes, waits for the queue, and expects `pdfs ls` to list both as
|
|
||||||
cached, so reading them does not need the network.
|
|
||||||
|
|
||||||
B74 was found by exactly this mechanism: the B70 case failed, and narrowing it
|
|
||||||
produced a smaller reproduction that got its own case. The defect is fixed and
|
|
||||||
live-validated (2026-07-26); the case now guards against its return.
|
|
||||||
|
|
||||||
**B79** (an on-demand device folder accepts writes) discovers its target from
|
|
||||||
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
|
||||||
folder. It is the cheap standing guard against a *cross-mount* permission
|
|
||||||
regression: the primary mount holds device-folder nodes too, and the queue guard
|
|
||||||
intersects what every live inode space says about a uid, so a fail-closed
|
|
||||||
classification in one mount denies writes in another.
|
|
||||||
|
|
||||||
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
|
||||||
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
|
||||||
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
|
||||||
accepted share at that role — B34 needs a **second account** to have shared a
|
|
||||||
folder read-only, which is the one part of the sharing suite that cannot be
|
|
||||||
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
|
||||||
gets its mode bits and `W_OK` checked but is never written to, because it is
|
|
||||||
someone else's document.
|
|
||||||
|
|
||||||
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
|
||||||
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
|
||||||
alone would pass a fix that still admitted the write into `pending_op`, which is
|
|
||||||
the actual harm — a background drain failing 403 forever.
|
|
||||||
|
|
||||||
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
|
||||||
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
|
||||||
the directory is absent (an account with no accepted shares).
|
|
||||||
|
|
||||||
A regression case asserts against the *remote* outcome, so it must wait for the
|
|
||||||
queue to drain before reading. Reading straight back through the mount proves
|
|
||||||
nothing: the local attribute and content caches will happily return the bytes
|
|
||||||
that were just written even when nothing reached Drive, which is precisely how
|
|
||||||
B74 stayed invisible.
|
|
||||||
|
|
||||||
### Bugs that are still open
|
|
||||||
|
|
||||||
Cases named `open B<n>` check a bug that `docs/BUGS.md` still lists as open.
|
|
||||||
While the bug reproduces, the case ends as `known`: it is listed at the end of
|
|
||||||
the run, but does not fail it. So a known bug does not hide new failures, and
|
|
||||||
the run notices when a fix lands, because the case then passes. Rename it to
|
|
||||||
`regression B<n>` then, and fail it with `check` instead of `KnownIssue`. No
|
|
||||||
bug is open at the moment. B114 to B117 started as such cases.
|
|
||||||
|
|
||||||
### What a run verifies
|
|
||||||
|
|
||||||
A full account run with `--journal-check` checks these `docs/BUGS.md` entries,
|
|
||||||
many of them fixed but not yet verified:
|
|
||||||
|
|
||||||
- B48, B80, B94, B95, B98, B100, B101, B102, B111, B114, B115, B116, B117, B119
|
|
||||||
and B121, each with its own `regression B<n>` case.
|
|
||||||
- B118, with "application workloads" and "block boundaries", which failed on
|
|
||||||
it. It needs Drive to be slow to list a new node, so a clean run is weak
|
|
||||||
evidence.
|
|
||||||
- B120, with `--journal-check`, but only in a run that goes offline while it
|
|
||||||
writes. A mkdir made online goes to Drive at once, so only then does a
|
|
||||||
folder land from the queue.
|
|
||||||
- B86, with the mode-switch check in the full matrix.
|
|
||||||
- B104, B106, B108 and B109, with the cases that found them: "rename
|
|
||||||
patterns", "application workloads" and the account run's own setup.
|
|
||||||
|
|
||||||
B105, B107 and B110 are races. The suite hit each of them once, so a clean run
|
|
||||||
is weak evidence for them. `--journal-check` reports B110's stall warning, and
|
|
||||||
every case that writes checks for B105's conflict copies.
|
|
||||||
|
|
||||||
The suite does not drive:
|
|
||||||
|
|
||||||
- the GUI and the photo gallery (B81, B92, B93, B97);
|
|
||||||
- states it cannot set up: a `parent_uid` cycle (B82), staged bytes that name
|
|
||||||
no node (B96), a stuck trash refresh (B103), conflict copies left from an
|
|
||||||
earlier start (B99), a foreign FUSE mount in the local-file index (B90),
|
|
||||||
revisions without 4 MiB blocks (B85), and several shares from one owner
|
|
||||||
(B112);
|
|
||||||
- B94's hour-long expiry of a park;
|
|
||||||
- load and failure injection (B22, B27, B30, B37, B42).
|
|
||||||
|
|
||||||
Check those by hand, or with the unit tests their entries name.
|
|
||||||
|
|
||||||
### Timeouts, reports, and hung mounts
|
|
||||||
|
|
||||||
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
|
||||||
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
|
||||||
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
|
||||||
That backstop is the only way to learn *where* a mount hung. After a timeout the
|
|
||||||
run checks whether the mount still answers a `stat` and a listing within 15
|
|
||||||
seconds. If it does, the target goes on with its next case, so one slow stretch
|
|
||||||
of the link does not cost every later case. If it does not, the target's later
|
|
||||||
cases are not run: against a wedged mount they produce noise, not information.
|
|
||||||
Cases that make many round trips by design ("application workloads", "unusual
|
|
||||||
but legal names", "a wide directory" and "regression B98") get twice the limit.
|
|
||||||
|
|
||||||
```console
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
|
||||||
--timeout 300 --report-junit results.xml --budget 30
|
|
||||||
```
|
|
||||||
|
|
||||||
`--report-json` also writes every recorded observation, which is what to attach
|
|
||||||
to a bug report. A known issue has the status `known` there; JUnit has no such
|
|
||||||
status, so `--report-junit` reports it as skipped. `--budget SECONDS` flags cases that pass but got slower —
|
|
||||||
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
|
||||||
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
|
||||||
stops at the first failure.
|
|
||||||
|
|
||||||
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
|
||||||
it ran. A suite can pass every assertion while the daemon logs failures behind
|
|
||||||
it; that has happened, and it was caught only by reading the journal by hand.
|
|
||||||
Two warnings count as errors too: "queued write conflicts; keeping a conflict
|
|
||||||
copy" (how B113 showed) and "a fuse worker has held the same job for a long
|
|
||||||
time" (how B110 showed).
|
|
||||||
|
|
||||||
With `--live`, abandoned `pdfs-acceptance-*` roots from a crashed run are
|
|
||||||
removed at startup, but only if they match the exact generated name and are
|
|
||||||
over an hour old (`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never
|
|
||||||
disturbed. An account run instead uses the manifest and reaper described
|
|
||||||
above.
|
|
||||||
|
|
||||||
### Automated mode matrix
|
|
||||||
|
|
||||||
An account run drives this matrix itself, in folders it creates. To use
|
|
||||||
directories of your choice instead, the managed live runner accepts two
|
|
||||||
existing, empty, unmounted directories. It
|
|
||||||
registers both as new sync folders, waits for initial synchronization, then
|
|
||||||
tests all four pairs in order: on-demand/on-demand, on-demand/mirror,
|
|
||||||
mirror/on-demand, and mirror/mirror. Every transition must become idle, and an
|
|
||||||
on-demand folder must actually appear as a mount before its contract runs.
|
|
||||||
|
|
||||||
```console
|
|
||||||
mkdir -p /mnt/pdfs-test-a /mnt/pdfs-test-b
|
|
||||||
scripts/fuse-acceptance.sh --managed-live /mnt/pdfs-test-a /mnt/pdfs-test-b
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates remote folders and permanently deletes those test remotes during
|
|
||||||
cleanup. It refuses non-empty paths, existing mounts, and paths already present
|
|
||||||
in `pdfs sync list`. Use `PDFS_ACCEPTANCE_SYNC_TIMEOUT` to extend transition
|
|
||||||
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
|
||||||
example `target/debug/pdfs`.
|
|
||||||
|
|
||||||
After the contract, each mode pair also runs the move cases (`--list` shows
|
|
||||||
them as `managed`). They move trees between the two folders in both
|
|
||||||
directions, move several sources in one `pdfs move`, and pass a tree through
|
|
||||||
My files and out again. Every case waits for Drive before and after the move.
|
|
||||||
It then checks that the tree is only at the destination, that every byte
|
|
||||||
arrived, and that neither side made conflict copies. The refusal cases prove
|
|
||||||
that nothing moves and nothing is lost when:
|
|
||||||
|
|
||||||
- the destination already has the name;
|
|
||||||
- a folder would move into itself;
|
|
||||||
- a mirror source holds something Drive lacks (a symlink);
|
|
||||||
- a file in an on-demand source is still open for writing.
|
|
||||||
|
|
||||||
The My files case writes a test folder into your real My files and removes it
|
|
||||||
afterwards. It skips when the daemon reports no My files mount, or when the
|
|
||||||
locations are on different volumes.
|
|
||||||
|
|
||||||
Before a mirror folder switches to on-demand, the runner writes a new file into
|
|
||||||
it and syncs. Once the folder is mounted, the file must be listed with its
|
|
||||||
bytes (B86). Each switch reports a `regression B86` result under a separate
|
|
||||||
`mode switches` target. The bug only shows in a folder that was mounted in an
|
|
||||||
earlier pairing, because that mount's listing is stale. That happens in the
|
|
||||||
third pairing, so `--quick` runs the check but does not replay the bug.
|
|
||||||
|
|
||||||
In every pairing, each folder must still hold the file the runner wrote into it
|
|
||||||
at setup, byte for byte. These checks report under `mode switches` as well. A
|
|
||||||
switch to on-demand frees the local copy, so these reads go to Drive. If the
|
|
||||||
link drops, the daemon fails such a read with EIO once its two-minute read
|
|
||||||
budget runs out. That is correct, so the runner waits until the daemon reports
|
|
||||||
online again, tries once more and notes the retry under the result. Only the
|
|
||||||
second try decides the result.
|
|
||||||
|
|
||||||
### Durability drill
|
|
||||||
|
|
||||||
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
|
||||||
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
|
||||||
only copy of a file whose upload has not finished, so this is the case that
|
|
||||||
proves the queue is persistent rather than in-memory. It is opt-in because it
|
|
||||||
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
|
||||||
|
|
||||||
### Selecting cases
|
### Selecting cases
|
||||||
|
|
||||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
`PDFS_ACCEPTANCE_ONLY` runs the cases whose name contains the text, ignoring case. Separate several
|
||||||
contains the supplied text, ignoring case (for example
|
with commas (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A filter that matches no case at all fails, as the
|
||||||
`PDFS_ACCEPTANCE_ONLY=namespace`). Separate several texts with commas to run
|
typo it is. A release run leaves it unset.
|
||||||
every case that matches any of them (`PDFS_ACCEPTANCE_ONLY=B116,B117`). A
|
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
|
||||||
|
|
||||||
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
| Variable | Effect |
|
||||||
in the account-free reference run, which is not an error — that target simply has
|
|---|---|
|
||||||
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
| `PDFS_ACCEPTANCE_PDFS` | The `pdfs` binary to use, for example `target/debug/pdfs` |
|
||||||
|
| `PDFS_ACCEPTANCE_SYNC_TIMEOUT` | Seconds a sync folder may take to settle after a change of mode |
|
||||||
|
| `PDFS_ACCEPTANCE_REAP_AGE` | Age in seconds after which a stray `pdfs-acceptance-*` folder is removed (default 3600) |
|
||||||
|
| `PDFS_ACCEPTANCE_CONVERGENCE` | `1` checks that several `--live` paths show the same bytes |
|
||||||
|
| `PDFS_ACCEPTANCE_MIN_MIBPS`, `PDFS_ACCEPTANCE_MIN_OPS` | Throughput floors |
|
||||||
|
| `PDFS_ACCEPTANCE_UNIT` | The systemd unit `--durability` restarts |
|
||||||
|
|
||||||
Passing more paths runs the same filesystem suite independently against each.
|
## Before a release
|
||||||
The first path must be the FUSE mount under test; later paths may be another
|
|
||||||
FUSE mount or a normal local mirror filesystem:
|
|
||||||
|
|
||||||
```console
|
1. The quality gates and the simulation runs pass in CI.
|
||||||
PDFS_ACCEPTANCE_SYNC_TIMEOUT=180 \
|
2. A full account run with `--journal-check` passes on a test account:
|
||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount /mnt/testmount
|
`scripts/fuse-acceptance.sh --journal-check`.
|
||||||
```
|
3. Fixes marked *unverified* in [BUGS.md](BUGS.md) that a run now covers are marked verified.
|
||||||
|
|
||||||
If two paths really are views of the **same remote folder**, enable an
|
What no automated run covers yet is listed under
|
||||||
additional byte-for-byte convergence check explicitly:
|
[Verification still owed](ROADMAP.md#verification-still-owed).
|
||||||
|
|
||||||
```console
|
|
||||||
PDFS_ACCEPTANCE_CONVERGENCE=1 \
|
|
||||||
scripts/fuse-acceptance.sh --live /mnt/first-view /mnt/second-view
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not enable that flag merely because two sync folders belong to the same
|
|
||||||
account. Their configured remote UIDs must be identical.
|
|
||||||
|
|
||||||
Normally the live suite applies the same API contract independently to each
|
|
||||||
supplied mount. In convergence mode it exercises the primary once and verifies
|
|
||||||
that the resulting bytes appear through every secondary view. A pass is
|
|
||||||
strong evidence for those paths, not a blanket data-loss guarantee. Before a
|
|
||||||
release, also run the recovery drills in `docs/RECOVERY.md` and inspect
|
|
||||||
`docs/BUGS.md` for live-verification items.
|
|
||||||
|
|
||||||
The runner accepts already-mounted paths instead of starting a daemon. This
|
|
||||||
keeps authentication and keyring setup explicit and prevents it from silently
|
|
||||||
reusing or replacing the normal application state directory.
|
|
||||||
+50
-9
@@ -69,8 +69,31 @@ pdfs status
|
|||||||
## Signing in
|
## Signing in
|
||||||
|
|
||||||
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
|
**"Proton is asking for a CAPTCHA"** — Proton wants human verification, usually on a VPN or an
|
||||||
unfamiliar network. The CLI cannot show it. Sign in with `pdfs-app`, which displays the challenge;
|
unfamiliar network. `pdfs login` prints the verification page's address: open it in your browser,
|
||||||
the stored session then works for the CLI too.
|
complete it, then press Enter. The app shows the page in its own window.
|
||||||
|
|
||||||
|
**"Verify in Your Browser"** — WebKit, which shows the CAPTCHA page in the app, could not start
|
||||||
|
its sandbox. Hardened systems such as secureblue keep its bubblewrap from the journal socket, and
|
||||||
|
the log then shows `bwrap: Can't get type of source /run/systemd/journal/socket: Permission
|
||||||
|
denied` and `Failed to fully launch dbus-proxy`. Choose **Open Verification Page**, complete the
|
||||||
|
page in your browser, then choose **Continue**. If that sign-in is refused too, check that `bwrap`
|
||||||
|
and `xdg-dbus-proxy` are installed. As a last resort, sign in once with the sandbox switched off
|
||||||
|
for this one run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 pdfs-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Quit the app first, so this starts a new instance. The page then runs without WebKit's isolation,
|
||||||
|
so use this only for the sign-in. The stored session keeps working without it. Signing in from
|
||||||
|
your usual network, without a VPN, often avoids the CAPTCHA in the first place.
|
||||||
|
|
||||||
|
**"This version of the app is no longer supported"** or **"Proton is temporarily refusing
|
||||||
|
sign-in from this device"** — Proton blocks sign-in for a while after too many attempts or from a
|
||||||
|
network it distrusts, and sends the first message even when the release is current. Do not retry
|
||||||
|
for several hours, up to a day, then sign in once. If it still fails, run
|
||||||
|
`RUST_LOG=debug pdfs login` and look for `code=` in the `proton api request failed` line: 2028 is
|
||||||
|
this block, 5003 is a release Proton no longer accepts.
|
||||||
|
|
||||||
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
**Sign-in works but is forgotten after a restart** — no Secret Service provider is running, or its
|
||||||
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
keyring is locked. Install and unlock GNOME Keyring, KWallet or KeePassXC (with Secret Service
|
||||||
@@ -90,6 +113,12 @@ fusermount3 -uz ~/ProtonDrive
|
|||||||
systemctl --user restart proton-drive.service
|
systemctl --user restart proton-drive.service
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`fetch My Files root: … failed to decrypt: missing key`** in the service log — the account
|
||||||
|
keys cannot open your Drive volume, which happens after a password reset. Proton locks the volume
|
||||||
|
that was encrypted to the old keys. From 3.4.1 the client starts a new, empty My Files volume
|
||||||
|
in that case. Your old files stay in the locked volume: open [drive.proton.me](https://drive.proton.me)
|
||||||
|
and restore them there with your previous password.
|
||||||
|
|
||||||
**The folder is empty or missing** — check that the service runs and is signed in
|
**The folder is empty or missing** — check that the service runs and is signed in
|
||||||
(`pdfs status`). The service waits for a stored session before it mounts.
|
(`pdfs status`). The service waits for a stored session before it mounts.
|
||||||
|
|
||||||
@@ -113,29 +142,41 @@ pdfs refresh Documents
|
|||||||
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
**"Permission denied" when writing** — the item is in a share where you are a viewer, or under
|
||||||
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
`Shared with me/`, which is read-only as a folder. Check your role with `pdfs shared-with-me`.
|
||||||
|
|
||||||
**A program says "Operation not supported"** — Proton Drive cannot store symbolic links, hard
|
**`ln` or another program fails with "Operation not permitted"** — Proton Drive cannot store
|
||||||
links, device files or FIFOs. The error is intentional. Use a synced folder for
|
symbolic links, hard links, device files or FIFOs, so creating one fails at once. The error is
|
||||||
projects that need them, or exclude those paths.
|
intentional. Use a synced folder for projects that need them, or exclude those paths.
|
||||||
|
|
||||||
## Uploads and sync
|
## Uploads and sync
|
||||||
|
|
||||||
**Something is stuck uploading** — list the queue and retry:
|
**A change shows here but not on other devices** — changes are recorded on this computer first
|
||||||
|
and sent from a queue. List what is still waiting, and what needs attention:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdfs sync queue
|
pdfs sync queue
|
||||||
|
pdfs sync issues
|
||||||
|
```
|
||||||
|
|
||||||
|
The app shows the same on **Sync → Overview**. A change waits while the service is offline or
|
||||||
|
sync is paused (`pdfs status`), and backs off after a failure. To retry at once:
|
||||||
|
|
||||||
|
```bash
|
||||||
pdfs sync retry # every failed operation
|
pdfs sync retry # every failed operation
|
||||||
pdfs sync retry 42 # one operation
|
pdfs sync retry 42 # one operation
|
||||||
```
|
```
|
||||||
|
|
||||||
The app shows the same list on **Sync → Overview**. An operation that keeps failing shows its
|
A change Proton Drive refuses (storage full, access taken away, folder gone) is listed by
|
||||||
error there. A change Proton Drive will not take can be dropped instead, which undoes it here;
|
`pdfs sync issues` with what to do. It stays queued until you fix the cause or drop it. Dropping
|
||||||
save a file's content first if you want to keep it:
|
it undoes it here; save a file's content first if you want to keep it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pdfs sync export 42 ~/Desktop/
|
pdfs sync export 42 ~/Desktop/
|
||||||
pdfs sync discard 42
|
pdfs sync discard 42
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you suspect the local-first behaviour of 3.0.0 itself, `"local_first": false` in `config.json`
|
||||||
|
makes the Proton Drive folder wait for Proton Drive again while online (see
|
||||||
|
[CONFIGURATION.md](CONFIGURATION.md#local_first)), and please report the problem.
|
||||||
|
|
||||||
**A file in a synced folder does not upload** — check that:
|
**A file in a synced folder does not upload** — check that:
|
||||||
|
|
||||||
- it is not matched by `.pdfsignore` or `ignore_patterns`;
|
- it is not matched by `.pdfsignore` or `ignore_patterns`;
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
# UI/UX audit and improvement plan
|
|
||||||
|
|
||||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
|
||||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
|
||||||
stated otherwise.
|
|
||||||
|
|
||||||
## 1. Summary
|
|
||||||
|
|
||||||
The client is functionally rich but the UI is built page by page, and it shows:
|
|
||||||
|
|
||||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
|
||||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
|
||||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
|
||||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
|
||||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
|
||||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
|
||||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
|
||||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
|
||||||
pinned files, import and developer info all share the Settings page.
|
|
||||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
|
||||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
|
||||||
folder in the title.
|
|
||||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
|
||||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
|
||||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
|
||||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
|
||||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
|
||||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
|
||||||
- **Several visible bugs** (section 9).
|
|
||||||
|
|
||||||
## 2. Design principles
|
|
||||||
|
|
||||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
|
||||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
|
||||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
|
||||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
|
||||||
needs attention, and be one click from pausing or from the list of problems.
|
|
||||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
|
||||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
|
||||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
|
||||||
dialog, not as filled buttons in headers.
|
|
||||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
|
||||||
spinner, never flash the whole page to a "Loading…" status page.
|
|
||||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
|
||||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
|
||||||
|
|
||||||
## 3. New information architecture
|
|
||||||
|
|
||||||
### Sidebar
|
|
||||||
|
|
||||||
```
|
|
||||||
Proton Drive (header: app name)
|
|
||||||
My files
|
|
||||||
Photos
|
|
||||||
Shared with me (badge: pending invitations)
|
|
||||||
Shared by me
|
|
||||||
Computers
|
|
||||||
Trash
|
|
||||||
─────────────
|
|
||||||
Sync (badge: issue count, icon reflects state)
|
|
||||||
───────────── footer
|
|
||||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
|
||||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
|
||||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
|
||||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
|
||||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
|
||||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
|
||||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
|
||||||
|
|
||||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
|
||||||
|
|
||||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
|
||||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
|
||||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
|
||||||
|
|
||||||
Below, an `InlineViewSwitcher` with four views:
|
|
||||||
|
|
||||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
|
||||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
|
||||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
|
||||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
|
||||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
|
||||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
|
||||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
|
||||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
|
||||||
The primary mount appears first and is not removable.
|
|
||||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
|
||||||
activatable (open or reveal the target), and "Clear history".
|
|
||||||
|
|
||||||
### Preferences dialog (slimmed Settings)
|
|
||||||
|
|
||||||
| Page | Contents |
|
|
||||||
|---|---|
|
|
||||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
|
||||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
|
||||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
|
||||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
|
||||||
|
|
||||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
|
||||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
|
||||||
version/user agent (About → Debug info).
|
|
||||||
|
|
||||||
## 4. Backend work required
|
|
||||||
|
|
||||||
Everything below is new; nothing like it exists today.
|
|
||||||
|
|
||||||
### B-1 Pause and resume (global, then per folder)
|
|
||||||
|
|
||||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
|
||||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
|
||||||
`paused` and `paused_until`.
|
|
||||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
|
||||||
offline; gate on `paused` at the same point and reuse the wake path.
|
|
||||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
|
||||||
poll and watcher keep recording, they just do not apply.
|
|
||||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
|
||||||
pause means "no uploads, no mirror reconcile".
|
|
||||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
|
||||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
|
||||||
(`pdfs-fuse/src/transfers.rs`).
|
|
||||||
|
|
||||||
### B-2 Pending ops and transfers
|
|
||||||
|
|
||||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
|
||||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
|
||||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
|
||||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
|
||||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
|
||||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
|
||||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
|
||||||
|
|
||||||
### B-3 Conflicts
|
|
||||||
|
|
||||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
|
||||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
|
||||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
|
||||||
which side is local vs remote when known.
|
|
||||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
|
||||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
|
||||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
|
||||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
|
||||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
|
||||||
content stays in version history, so this is reversible).
|
|
||||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
|
||||||
- Activity rows for each resolution.
|
|
||||||
|
|
||||||
## 5. Shell and visual polish (all pages)
|
|
||||||
|
|
||||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
|
||||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
|
||||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
|
||||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
|
||||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
|
||||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
|
||||||
explicitly labelled button when the service is down).
|
|
||||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
|
||||||
only on first load.
|
|
||||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
|
||||||
Replace hex colours with named colours; test light, dark and high contrast.
|
|
||||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
|
||||||
(`style-dark.css` if needed), so it can be edited and linted.
|
|
||||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
|
||||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
|
||||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
|
||||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
|
||||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
|
||||||
so later localisation is mechanical.
|
|
||||||
|
|
||||||
## 6. Page-by-page plan
|
|
||||||
|
|
||||||
### My files
|
|
||||||
|
|
||||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
|
||||||
|
|
||||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
|
||||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
|
||||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
|
||||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
|
||||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
|
||||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
|
||||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
|
||||||
the search consistently.
|
|
||||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
|
||||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
|
||||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
|
||||||
Move to Trash (destructive).
|
|
||||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
|
||||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
|
||||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
|
||||||
hidden for them (fix `details.rs:259`).
|
|
||||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
|
||||||
Location for search results. Rubberband selection. One selection model shared between views.
|
|
||||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
|
||||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
|
||||||
"All versions…"). Same actions as the context menu.
|
|
||||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
|
||||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
|
||||||
reversible), or keep confirm and drop Undo — not both.
|
|
||||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
|
||||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
|
||||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
|
||||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
|
||||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
|
||||||
column, correct cached badges (search hits hardcode `cached: false`).
|
|
||||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
|
||||||
|
|
||||||
### Photos (was Gallery)
|
|
||||||
|
|
||||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
|
||||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
|
||||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
|
||||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
|
||||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
|
||||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
|
||||||
"Filter by date" if kept.
|
|
||||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
|
||||||
page does not fill the viewport).
|
|
||||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
|
||||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
|
||||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
|
||||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
|
||||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
|
||||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
|
||||||
"No photos yet" with upload buttons.
|
|
||||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
|
||||||
Back returns to where the user came from.
|
|
||||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
|
||||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
|
||||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
|
||||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
|
||||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
|
||||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
|
||||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
|
||||||
|
|
||||||
### Shared with me / Shared by me
|
|
||||||
|
|
||||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
|
||||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
|
||||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
|
||||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
|
||||||
access…, Stop sharing….
|
|
||||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
|
||||||
human role names, link expiry.
|
|
||||||
|
|
||||||
### Computers
|
|
||||||
|
|
||||||
- Show the real device name for "This computer" with rename inline.
|
|
||||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
|
||||||
confirm that requires typing the device name since it deletes backups.
|
|
||||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
|
||||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
|
||||||
- Terminology: "computer" everywhere in UI copy.
|
|
||||||
|
|
||||||
### Trash
|
|
||||||
|
|
||||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
|
||||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
|
||||||
with a destructive confirm, proper plurals, Retry on error.
|
|
||||||
|
|
||||||
### Login
|
|
||||||
|
|
||||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
|
||||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
|
||||||
|
|
||||||
## 7. Tray
|
|
||||||
|
|
||||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
|
||||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
|
||||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
|
||||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
|
||||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
|
||||||
running and the label must not suggest otherwise).
|
|
||||||
|
|
||||||
## 8. Glossary (one term, one icon)
|
|
||||||
|
|
||||||
| Concept | UI term | Icon |
|
|
||||||
|---|---|---|
|
|
||||||
| Root of Drive | My files | `folder-symbolic` |
|
|
||||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
|
||||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
|
||||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
|
||||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
|
||||||
| Folder sync mode | Synced / Online only | — |
|
|
||||||
| Where My files appears on disk | Proton Drive folder — never "mountpoint" in UI | — |
|
|
||||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
|
||||||
| Remove a computer | Remove computer… | destructive, in menu |
|
|
||||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
|
||||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
|
||||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
|
||||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
|
||||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
|
||||||
|
|
||||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
|
||||||
Proton's own apps. Title Case for
|
|
||||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
|
||||||
|
|
||||||
## 9. Bugs found during the audit (quick wins)
|
|
||||||
|
|
||||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
|
||||||
(`photo_viewer.rs:594-598`).
|
|
||||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
|
||||||
(`main.rs:779`, `takeout.rs:131-136`).
|
|
||||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
|
||||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
|
||||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
|
||||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
|
||||||
7. F2 silently does nothing with multiple items selected.
|
|
||||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
|
||||||
(`photos.rs:958-997`).
|
|
||||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
|
||||||
counter "12 of 60".
|
|
||||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
|
||||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
|
||||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
|
||||||
12. Going on-demand deletes local copies without confirmation.
|
|
||||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
|
||||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
|
||||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
|
||||||
raw lowercase link role.
|
|
||||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
|
||||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
|
||||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
|
||||||
`browser.rs:1155`).
|
|
||||||
|
|
||||||
## 10. Phased roadmap
|
|
||||||
|
|
||||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
|
||||||
|
|
||||||
| Phase | Content | Crates | Size |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
|
||||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
|
||||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
|
||||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
|
||||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
|
||||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
|
||||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
|
||||||
|
|
||||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
|
||||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
|
||||||
because later phases build on the new header/page skeleton.
|
|
||||||
|
|
||||||
### Verification per phase
|
|
||||||
|
|
||||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
|
||||||
`cargo test --workspace`.
|
|
||||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
|
||||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
|
||||||
the losing side stays recoverable (trash or version history).
|
|
||||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
|
||||||
each page, narrow-window check (sidebar collapse breakpoint).
|
|
||||||
+54
-15
@@ -7,6 +7,7 @@ How Proton Drive for Linux works and how to use it day to day. For installation
|
|||||||
- [My files](#my-files)
|
- [My files](#my-files)
|
||||||
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
- [Keeping files on this computer](#keeping-files-on-this-computer)
|
||||||
- [Syncing local folders](#syncing-local-folders)
|
- [Syncing local folders](#syncing-local-folders)
|
||||||
|
- [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive)
|
||||||
- [Conflicts](#conflicts)
|
- [Conflicts](#conflicts)
|
||||||
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
- [Pausing and limiting sync](#pausing-and-limiting-sync)
|
||||||
- [Photos](#photos)
|
- [Photos](#photos)
|
||||||
@@ -33,7 +34,7 @@ the service to do things over a local socket:
|
|||||||
|
|
||||||
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
Your Drive, your file names and your photos are end-to-end encrypted by Proton. On this computer,
|
||||||
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
the service must decrypt what it shows you, and it keeps a decrypted cache and index on disk. Use
|
||||||
full-disk or home-directory encryption. [ARCHITECTURE.md §8](ARCHITECTURE.md#8-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
full-disk or home-directory encryption. [ARCHITECTURE.md §10](ARCHITECTURE.md#10-threat-model-what-this-client-writes-to-disk-in-plaintext)
|
||||||
lists exactly what is stored.
|
lists exactly what is stored.
|
||||||
|
|
||||||
The app uses these words consistently, and so does this guide:
|
The app uses these words consistently, and so does this guide:
|
||||||
@@ -43,7 +44,7 @@ The app uses these words consistently, and so does this guide:
|
|||||||
| **My files** | The root of your Drive |
|
| **My files** | The root of your Drive |
|
||||||
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
|
| **Proton Drive folder** | Where My files appears on disk, `~/ProtonDrive` by default |
|
||||||
| **Online only** | A file or folder whose content downloads when opened |
|
| **Online only** | A file or folder whose content downloads when opened |
|
||||||
| **Available offline** | A single file kept on this computer |
|
| **Available offline** | A file, or a folder with everything in it, kept on this computer |
|
||||||
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
|
| **Synced folder** | A local folder with a full copy on this computer, backed up to Proton Drive |
|
||||||
| **Proton Drive service** | The background service (`proton-drive.service`) |
|
| **Proton Drive service** | The background service (`proton-drive.service`) |
|
||||||
|
|
||||||
@@ -54,9 +55,11 @@ downloads only when a program reads it:
|
|||||||
|
|
||||||
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
- **Reads fetch only what is needed.** A video player that seeks, or a tool that reads a file
|
||||||
header, downloads only those parts. Media streams while it plays.
|
header, downloads only those parts. Media streams while it plays.
|
||||||
- **Writes are safe offline.** When a program closes a file it wrote, the service stores the new
|
- **Changes work at local speed.** Creating, renaming, moving and deleting files and folders
|
||||||
content on disk and queues the upload. The queue survives network loss, restarts and crashes.
|
returns at once, online or offline. When a program closes a file it wrote, the new content is
|
||||||
`pdfs sync queue` lists what has not reached Proton Drive yet.
|
stored on disk. Every change is queued and sent to Proton Drive in the background; the changes
|
||||||
|
to one file are sent in the order you made them. The queue survives network loss, restarts and
|
||||||
|
crashes. See [Changes waiting for Proton Drive](#changes-waiting-for-proton-drive).
|
||||||
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
- **Recently read content is cached** on disk up to the cache size limit (5 GiB by default) and
|
||||||
evicted oldest first.
|
evicted oldest first.
|
||||||
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
- **Items shared with you** appear under `Shared with me/` inside the Proton Drive folder. Shares
|
||||||
@@ -72,11 +75,14 @@ try to create them get a clean error, not a hang or `EIO`.
|
|||||||
|
|
||||||
## Keeping files on this computer
|
## Keeping files on this computer
|
||||||
|
|
||||||
Turn on **Make available offline** for a file in My files, or run `pdfs pin <path>`. The file is
|
Turn on **Available offline** for a file or folder in My files, or run `pdfs pin <path>`. A file
|
||||||
downloaded completely and never evicted from the cache. `pdfs pins` lists these files, and
|
is downloaded completely and never evicted from the cache. For a folder, every file in it and in
|
||||||
**Preferences → Storage → Available offline** shows them in the app.
|
its subfolders downloads in the background, and the tray and the app show the progress.
|
||||||
|
`pdfs pins` lists what is kept, and **Preferences → Storage → Available offline** shows it in the
|
||||||
|
app.
|
||||||
|
|
||||||
Turning it off (`pdfs unpin <path>`) frees the space. The file stays on Proton Drive.
|
Turning it off (`pdfs unpin <path>`) frees the space. The files stay on Proton Drive. A file inside
|
||||||
|
a folder kept offline stays with its folder: turn the folder off instead.
|
||||||
|
|
||||||
## Syncing local folders
|
## Syncing local folders
|
||||||
|
|
||||||
@@ -93,7 +99,9 @@ app and in the web app.
|
|||||||
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
Each synced folder can also be switched to **Online only**. The local copy is replaced by a mount
|
||||||
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
of the folder, so the files stay visible but take no disk space until opened. Switch with the
|
||||||
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
**Synced / Online only** switch on the folder's card, or `pdfs sync mode <id> ondemand` and back
|
||||||
with `pdfs sync mode <id> mirror`. `pdfs locations` lists every local place the service occupies.
|
with `pdfs sync mode <id> mirror`. The switch waits until everything in the folder is on Proton
|
||||||
|
Drive, and refuses while the folder holds something that never goes there: an ignored path such
|
||||||
|
as `.git/`, a symlink, or a folder you may not read. The message names it. `pdfs locations` lists every local place the service occupies.
|
||||||
|
|
||||||
### Moving between synced folders and My files
|
### Moving between synced folders and My files
|
||||||
|
|
||||||
@@ -143,6 +151,39 @@ places, and both apply:
|
|||||||
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
Rules are re-read on every pass. Ignoring never deletes anything: a file that becomes ignored is
|
||||||
simply no longer tracked, and its copy on Proton Drive stays.
|
simply no longer tracked, and its copy on Proton Drive stays.
|
||||||
|
|
||||||
|
A folder you may not read, such as a cache a container created as root, is skipped the same way,
|
||||||
|
and the activity feed names it once. Add it to `.pdfsignore` to stop that message.
|
||||||
|
|
||||||
|
## Changes waiting for Proton Drive
|
||||||
|
|
||||||
|
A change you made in the Proton Drive folder exists only on this computer until it reaches
|
||||||
|
Proton Drive. `pdfs status`, the tray and **Sync → Overview** count what is still waiting, and
|
||||||
|
`pdfs sync queue` lists it.
|
||||||
|
|
||||||
|
Sometimes Proton Drive refuses a change: the storage is full, a share was taken away, the folder
|
||||||
|
is gone, the folder holds too many items, or Drive does not accept a name. The change then needs
|
||||||
|
your attention. It shows at once in **Sync → Overview**, in the tray, and in `pdfs sync issues`,
|
||||||
|
with what it means and what you can do. A file or folder with such a change carries a warning
|
||||||
|
badge in **Files**; hover it to see why.
|
||||||
|
|
||||||
|
Nothing is deleted. The change stays queued and is retried. You can:
|
||||||
|
|
||||||
|
- **Retry** it now, from the same list or with `pdfs sync retry`, for example after freeing space.
|
||||||
|
- **Export** a file's content to keep your version (`pdfs sync export <id> <file>`).
|
||||||
|
- **Discard** the change (`pdfs sync discard <id>`). It is undone here and never sent: a new file
|
||||||
|
or folder goes away, anything else goes back to what Proton Drive has. Export first if you want
|
||||||
|
to keep the content.
|
||||||
|
|
||||||
|
Two cases are not refused, and the activity log records both:
|
||||||
|
|
||||||
|
- A new file or folder whose name is already taken on Proton Drive by something this computer had
|
||||||
|
not seen yet lands under a `(sync-conflict …)` name, as described below.
|
||||||
|
- A new file whose folder was deleted on another device meanwhile lands at the top of My files,
|
||||||
|
so its content is not lost.
|
||||||
|
|
||||||
|
Before you shut down a computer for good, check that nothing is waiting: see
|
||||||
|
[RECOVERY.md](RECOVERY.md#lost-with-the-machine).
|
||||||
|
|
||||||
## Conflicts
|
## Conflicts
|
||||||
|
|
||||||
When the same file changed on this computer and somewhere else since the last sync, neither side
|
When the same file changed on this computer and somewhere else since the last sync, neither side
|
||||||
@@ -166,11 +207,6 @@ The version you do not keep goes to the Trash, so a wrong choice can be undone.
|
|||||||
reading files keeps working. `pdfs sync resume` continues.
|
reading files keeps working. `pdfs sync resume` continues.
|
||||||
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
- **Limit bandwidth** under **Preferences → General → Network**, or with
|
||||||
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
`pdfs sync limit --up 2M --down 10M`. `0` removes a limit.
|
||||||
- **Retry** a failed upload from **Sync → Overview**, or with `pdfs sync retry`.
|
|
||||||
- **Discard** a change Proton Drive refused from the same list, or with `pdfs sync discard`: it is
|
|
||||||
undone here and never sent. Export a file first to keep your version.
|
|
||||||
- A file or folder with a change Proton Drive refused carries a warning badge in **Files**; hover
|
|
||||||
it to see why.
|
|
||||||
|
|
||||||
## Photos
|
## Photos
|
||||||
|
|
||||||
@@ -250,6 +286,9 @@ Proton Drive keeps earlier versions of every file. **Details → Versions…** i
|
|||||||
**Restore to This Computer…** downloads its folders here.
|
**Restore to This Computer…** downloads its folders here.
|
||||||
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
- **Continue This Backup Here…** makes this computer take over another computer's backup, for
|
||||||
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
example after reinstalling or replacing a machine. See [RECOVERY.md](RECOVERY.md).
|
||||||
|
- **Move to This Computer…** moves another computer's folders into this computer's backup, for
|
||||||
|
example when you switch from Windows to Linux, and then offers them for syncing here. The other
|
||||||
|
computer stays in the list, without those folders.
|
||||||
|
|
||||||
## Search launcher
|
## Search launcher
|
||||||
|
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1791456040,
|
||||||
|
"narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
description = "Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app and tray";
|
||||||
|
|
||||||
|
# CI pushes every build of `main` and of each release tag here, so installing
|
||||||
|
# downloads the client instead of compiling it. Nix asks before it uses a
|
||||||
|
# flake's caches unless the user trusts them (docs/INSTALL.md).
|
||||||
|
nixConfig = {
|
||||||
|
extra-substituters = [ "https://proton-drive-linux.cachix.org" ];
|
||||||
|
extra-trusted-public-keys = [
|
||||||
|
"proton-drive-linux.cachix.org-1:w1+3pXVXyTlqkFGn3dWpUhlaJtyGhSm3X0mFwfa++AA="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
];
|
||||||
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (pkgs: rec {
|
||||||
|
proton-drive-linux = pkgs.callPackage ./packaging/nix/package.nix { };
|
||||||
|
default = proton-drive-linux;
|
||||||
|
});
|
||||||
|
|
||||||
|
overlays.default = final: _: {
|
||||||
|
proton-drive-linux = final.callPackage ./packaging/nix/package.nix { };
|
||||||
|
};
|
||||||
|
|
||||||
|
nixosModules.default = import ./packaging/nix/module.nix self;
|
||||||
|
|
||||||
|
devShells = forAllSystems (pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self.packages.${pkgs.stdenv.hostPlatform.system}.proton-drive-linux ];
|
||||||
|
packages = with pkgs; [
|
||||||
|
cargo
|
||||||
|
clippy
|
||||||
|
rustfmt
|
||||||
|
rust-analyzer
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
formatter = forAllSystems (pkgs: pkgs.nixfmt);
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 363 KiB |
@@ -0,0 +1,156 @@
|
|||||||
|
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1920" y2="1080" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#1A1240"/>
|
||||||
|
<stop offset="1" stop-color="#0A0618"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow1" cx="1390" cy="520" r="640" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#6D4AFF" stop-opacity="0.55"/>
|
||||||
|
<stop offset="0.5" stop-color="#6D4AFF" stop-opacity="0.15"/>
|
||||||
|
<stop offset="1" stop-color="#6D4AFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="glow2" cx="160" cy="1080" r="700" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#A855F7" stop-opacity="0.25"/>
|
||||||
|
<stop offset="1" stop-color="#A855F7" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="dots" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="2" cy="2" r="1.6" fill="#FFFFFF"/>
|
||||||
|
</pattern>
|
||||||
|
<radialGradient id="dotfade" cx="1390" cy="520" r="900" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="1"/>
|
||||||
|
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<mask id="dotmask" maskUnits="userSpaceOnUse" x="0" y="0" width="1920" height="1080">
|
||||||
|
<rect width="1920" height="1080" fill="url(#dotfade)"/>
|
||||||
|
</mask>
|
||||||
|
<linearGradient id="title2" x1="140" y1="0" x2="760" y2="0" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#C9BBFF"/>
|
||||||
|
<stop offset="1" stop-color="#8A6CFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- app logo -->
|
||||||
|
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#A48BFF"/>
|
||||||
|
<stop offset="0.55" stop-color="#7552FF"/>
|
||||||
|
<stop offset="1" stop-color="#5B36F2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#ECE6FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<circle cx="66" cy="57" r="23"/>
|
||||||
|
<circle cx="42" cy="68" r="16"/>
|
||||||
|
<circle cx="88" cy="69" r="15"/>
|
||||||
|
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||||
|
</g>
|
||||||
|
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||||
|
<path d="M64 49v26"/>
|
||||||
|
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||||
|
</g>
|
||||||
|
</mask>
|
||||||
|
<filter id="logoshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="6"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="chipshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="14"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- background -->
|
||||||
|
<rect width="1920" height="1080" fill="url(#bg)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#glow2)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#glow1)"/>
|
||||||
|
<rect width="1920" height="1080" fill="url(#dots)" opacity="0.07" mask="url(#dotmask)"/>
|
||||||
|
|
||||||
|
<!-- orbit rings -->
|
||||||
|
<g fill="none" stroke="#FFFFFF">
|
||||||
|
<circle cx="1390" cy="520" r="300" stroke-opacity="0.10" stroke-width="2"/>
|
||||||
|
<circle cx="1390" cy="520" r="420" stroke-opacity="0.06" stroke-width="2" stroke-dasharray="4 14" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- logo, 128 -> 384 -->
|
||||||
|
<g transform="translate(1198 328) scale(3)">
|
||||||
|
<rect x="16" y="24" width="96" height="92" rx="24" fill="#2A1080" opacity="0.8" filter="url(#logoshadow)"/>
|
||||||
|
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||||
|
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||||
|
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" fill="none" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||||
|
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||||
|
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- feature chips around the logo -->
|
||||||
|
<g font-family="Adwaita Sans" font-size="26" font-weight="600" fill="#FFFFFF">
|
||||||
|
<!-- folder, top left -->
|
||||||
|
<g transform="translate(1000 200)">
|
||||||
|
<rect x="0" y="10" width="236" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="236" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#6D4AFF"/>
|
||||||
|
<path d="M8 13a2 2 0 0 1 2-2h5l2.5 2.5H26a2 2 0 0 1 2 2V25a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2z" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">On demand</text>
|
||||||
|
</g>
|
||||||
|
<!-- sync, top right -->
|
||||||
|
<g transform="translate(1590 290)">
|
||||||
|
<rect x="0" y="10" width="164" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="164" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#1EA885"/>
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M26 15a8.5 8.5 0 0 0-15-2"/>
|
||||||
|
<path d="M10 9v4.5h4.5"/>
|
||||||
|
<path d="M10 21a8.5 8.5 0 0 0 15 2"/>
|
||||||
|
<path d="M26 27v-4.5h-4.5"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">Sync</text>
|
||||||
|
</g>
|
||||||
|
<!-- photos, bottom right -->
|
||||||
|
<g transform="translate(1560 690)">
|
||||||
|
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#E8618C"/>
|
||||||
|
<rect x="8" y="9" width="20" height="18" rx="3" fill="none" stroke="#FFFFFF" stroke-width="2.4"/>
|
||||||
|
<circle cx="14" cy="15" r="2" fill="#FFFFFF"/>
|
||||||
|
<path d="M9.5 25l6-6 4 4 3-3 4 4" fill="none" stroke="#FFFFFF" stroke-width="2.4" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="47">Photos</text>
|
||||||
|
</g>
|
||||||
|
<!-- terminal, bottom left -->
|
||||||
|
<g transform="translate(980 790)">
|
||||||
|
<rect x="0" y="10" width="206" height="76" rx="22" fill="#000000" opacity="0.35" filter="url(#chipshadow)"/>
|
||||||
|
<rect x="0" y="0" width="206" height="76" rx="22" fill="#2A2058" fill-opacity="0.85" stroke="#FFFFFF" stroke-opacity="0.14" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(22 20)">
|
||||||
|
<rect width="36" height="36" rx="10" fill="#3B3F5C"/>
|
||||||
|
<path d="M9 12l6 6-6 6" fill="none" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18 25h9" stroke="#FFFFFF" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<text x="74" y="48" font-family="JetBrainsMono NF" font-size="25" font-weight="700">pdfs</text>
|
||||||
|
<text x="150" y="48" font-weight="500" fill="#B9B0D9">CLI</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- text block -->
|
||||||
|
<g font-family="Adwaita Sans">
|
||||||
|
<g transform="translate(140 292)">
|
||||||
|
<rect width="410" height="48" rx="24" fill="#6D4AFF" fill-opacity="0.18" stroke="#8A6CFF" stroke-opacity="0.6" stroke-width="1.5"/>
|
||||||
|
<circle cx="28" cy="24" r="6" fill="#A48BFF"/>
|
||||||
|
<text x="46" y="32" font-size="21" font-weight="700" letter-spacing="2.4" fill="#D6CCFF">UNOFFICIAL · OPEN SOURCE</text>
|
||||||
|
</g>
|
||||||
|
<text x="134" y="478" font-size="124" font-weight="800" letter-spacing="-3" fill="#FFFFFF">Proton Drive</text>
|
||||||
|
<text x="134" y="610" font-size="124" font-weight="800" letter-spacing="-3" fill="url(#title2)">for Linux</text>
|
||||||
|
<text x="140" y="702" font-size="38" font-weight="400" fill="#B9B0D9">Your Drive as a folder, and a native GTK4 app.</text>
|
||||||
|
<text x="140" y="752" font-size="38" font-weight="400" fill="#B9B0D9">No Electron.</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<g font-family="JetBrainsMono NF" font-size="24" fill="#8C83B0">
|
||||||
|
<text x="140" y="968">github.com/narrrl/proton-drive-linux</text>
|
||||||
|
</g>
|
||||||
|
<g font-family="Adwaita Sans" font-size="22" fill="#6F6894">
|
||||||
|
<text x="1780" y="968" text-anchor="end">Not affiliated with Proton AG</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.3 KiB |
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user