mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-10 09:17:03 +01:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2336ad9e87 | ||
|
|
d988236bfa | ||
|
|
d78ddeab8e | ||
|
|
b04cce3a23 | ||
|
|
7e8403936b | ||
|
|
866a174c7a | ||
|
|
352117e806 | ||
|
|
4b5666eacb | ||
|
|
9bbd8eb982 | ||
|
|
77198ed261 | ||
|
|
0d79c46729 | ||
|
|
a8210ce8fd | ||
|
|
551e14b644 | ||
|
|
7de667b386 | ||
|
|
295bbeadac | ||
|
|
32817870a5 | ||
|
|
b93e41efc5 | ||
|
|
082ef8522a | ||
|
|
2e95655244 | ||
|
|
942cb9f583 | ||
|
|
8470662c54 | ||
|
|
a091c2f34f | ||
|
|
bca0fe34bc | ||
|
|
6f70eca78e | ||
|
|
d5165b36a2 | ||
|
|
e9009dd47c |
No files matched your search
@@ -13,6 +13,7 @@ jobs:
|
|||||||
build-release:
|
build-release:
|
||||||
name: Build and Release
|
name: Build and Release
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
environment: 'rpm signing'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -154,6 +155,26 @@ jobs:
|
|||||||
# Copy built RPM to root directory
|
# Copy built RPM to root directory
|
||||||
cp rpmbuild/RPMS/x86_64/*.rpm .
|
cp rpmbuild/RPMS/x86_64/*.rpm .
|
||||||
|
|
||||||
|
- name: Sign Fedora package (.rpm)
|
||||||
|
env:
|
||||||
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
|
GPG_KEY_NAME: ${{ secrets.GPG_KEY_NAME }}
|
||||||
|
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$GPG_PRIVATE_KEY" ] && [ -n "$GPG_KEY_NAME" ]; then
|
||||||
|
echo "$GPG_PRIVATE_KEY" | gpg --batch --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --import
|
||||||
|
|
||||||
|
cat <<EOF > ~/.rpmmacros
|
||||||
|
%_signature gpg
|
||||||
|
%_gpg_name ${GPG_KEY_NAME}
|
||||||
|
%__gpg_sign_cmd %{__gpg} gpg --batch --verbose --no-armor --pinentry-mode loopback --passphrase %{_gpg_passphrase} --no-secmem-warning -u "%{_gpg_name}" -sbo %{__signature_filename} --digest-algo sha256 %{__plaintext_filename}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rpm --addsign --define "_gpg_passphrase $GPG_PASSPHRASE" *.rpm
|
||||||
|
else
|
||||||
|
echo "Skipping RPM signing: GPG_PRIVATE_KEY or GPG_KEY_NAME secret is not set."
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
Generated
+55
-55
@@ -378,7 +378,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -663,9 +663,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.1"
|
version = "1.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -747,9 +747,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -856,9 +856,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.2"
|
version = "4.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
|
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -878,14 +878,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1479,9 +1479,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elliptic-curve"
|
name = "elliptic-curve"
|
||||||
@@ -1568,7 +1568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2359,9 +2359,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2559,9 +2559,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ignore"
|
name = "ignore"
|
||||||
version = "0.4.30"
|
version = "0.4.31"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7"
|
checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-deque",
|
"crossbeam-deque",
|
||||||
"globset",
|
"globset",
|
||||||
@@ -2916,9 +2916,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libdbus-sys"
|
name = "libdbus-sys"
|
||||||
@@ -3203,7 +3203,7 @@ version = "0.50.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3518,10 +3518,10 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-cli"
|
name = "pdfs-cli"
|
||||||
version = "1.0.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap 4.6.2",
|
"clap 4.6.4",
|
||||||
"pdfs-core",
|
"pdfs-core",
|
||||||
"pdfs-fuse",
|
"pdfs-fuse",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-core"
|
name = "pdfs-core"
|
||||||
version = "1.0.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"ignore",
|
"ignore",
|
||||||
@@ -3553,7 +3553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-fuse"
|
name = "pdfs-fuse"
|
||||||
version = "1.0.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fuser",
|
"fuser",
|
||||||
"image",
|
"image",
|
||||||
@@ -3569,7 +3569,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfs-gui"
|
name = "pdfs-gui"
|
||||||
version = "1.0.0"
|
version = "1.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -3830,9 +3830,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-drive-rs"
|
name = "proton-drive-rs"
|
||||||
version = "0.1.11"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a76197ffe7c9588c0bc1fa38a51e8aa8642b10e81ec16272d4962bd60525920"
|
checksum = "06f6b2b3aaaf90cd3eab823e3c13a4ff784693f183f7fde97f865378c4d3e0bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3857,9 +3857,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proton-sdk"
|
name = "proton-sdk"
|
||||||
version = "0.1.11"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "805df5ff2805e0e935ac7f5a237482231208a706d8c6ab3dd91157ae2e95c9f6"
|
checksum = "1037a17f8951cf3ade40a8d2a8230720caf6136383eecc7d61e89636e229793e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4214,7 +4214,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4401,7 +4401,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4432,9 +4432,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.0"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"web-time",
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -4458,7 +4458,7 @@ dependencies = [
|
|||||||
"security-framework",
|
"security-framework",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"webpki-root-certs",
|
"webpki-root-certs",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4599,14 +4599,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.151"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -4623,7 +4623,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4784,18 +4784,18 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snafu"
|
name = "snafu"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d1a012328be2e3f5d5f6f3218147ca02588cea4cb865e876849ab6debcf36522"
|
checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"snafu-derive",
|
"snafu-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snafu-derive"
|
name = "snafu-derive"
|
||||||
version = "0.9.1"
|
version = "0.9.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f103c50866b8743da9429b8a581d81a27c2d3a9c4ac7df8f8571c1dd7896eda"
|
checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -4898,9 +4898,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.0"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -4962,7 +4962,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5011,7 +5011,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.0",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5064,9 +5064,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.0"
|
version = "1.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -5101,9 +5101,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -5628,7 +5628,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6138,18 +6138,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+3
-3
@@ -3,15 +3,15 @@ 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 = "1.0.0"
|
version = "1.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.1" }
|
proton-sdk = { version = "0.3.2" }
|
||||||
proton-drive-rs = { version = "0.1" }
|
proton-drive-rs = { version = "0.3.2" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ A fast, unofficial Proton Drive client for Linux. This client features an advanc
|
|||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||||
|
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
|
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
||||||
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
- **Human Verification (CAPTCHA) Recovery**: Detects sign-in gates (VPN/new IP challenges) and launches an embedded `WebKitWebView` dialog to safely complete the challenge, transparently retrying authentication with the earned token.
|
||||||
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
- **Selective Sync (`.pdfsignore`)**: Keep build trees, dependency directories, and editor leftovers out of synced folders using gitignore-style rules.
|
||||||
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
- **Data-Safe Offline Writes**: Durable scratch/staging files and a transactional pending queue preserve acknowledged writes across network failures and restarts.
|
||||||
|
|||||||
+128
-21
@@ -13,7 +13,7 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::cache::ContentCache;
|
use pdfs_core::cache::ContentCache;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::Db;
|
use pdfs_core::db::Db;
|
||||||
@@ -28,7 +28,7 @@ struct Cli {
|
|||||||
/// Emit machine-readable JSON instead of formatted text.
|
/// Emit machine-readable JSON instead of formatted text.
|
||||||
///
|
///
|
||||||
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
/// Applies to the query commands (`status`, `ls`, `pins`, `sync list`,
|
||||||
/// `devices list`, `transfers`, `activity`, `cache inspect`). Commands that
|
/// `devices list`, `locations`, `transfers`, `activity`, `cache inspect`). Commands that
|
||||||
/// perform an action keep their human output — a script that needs to know
|
/// perform an action keep their human output — a script that needs to know
|
||||||
/// whether one succeeded has the exit code.
|
/// whether one succeeded has the exit code.
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
@@ -105,6 +105,8 @@ enum Command {
|
|||||||
Logout,
|
Logout,
|
||||||
/// Show account login state, and mount status if a daemon is running.
|
/// Show account login state, and mount status if a daemon is running.
|
||||||
Status,
|
Status,
|
||||||
|
/// List every local Proton Drive location.
|
||||||
|
Locations,
|
||||||
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
/// Mount Proton Drive at the given (or default) path. Blocks until unmounted.
|
||||||
Mount {
|
Mount {
|
||||||
/// Mountpoint; defaults to ~/ProtonDrive.
|
/// Mountpoint; defaults to ~/ProtonDrive.
|
||||||
@@ -143,6 +145,19 @@ enum Command {
|
|||||||
#[arg(long, default_value_t = 0)]
|
#[arg(long, default_value_t = 0)]
|
||||||
offset: usize,
|
offset: usize,
|
||||||
},
|
},
|
||||||
|
/// List the photo albums (including ones shared with this account).
|
||||||
|
Albums,
|
||||||
|
/// List the photos in one album (newest capture first).
|
||||||
|
Album {
|
||||||
|
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||||
|
uid: String,
|
||||||
|
/// Max photos to list.
|
||||||
|
#[arg(long, default_value_t = 50)]
|
||||||
|
limit: usize,
|
||||||
|
/// Skip this many photos from the start of the album.
|
||||||
|
#[arg(long, default_value_t = 0)]
|
||||||
|
offset: usize,
|
||||||
|
},
|
||||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||||
OpenPhoto {
|
OpenPhoto {
|
||||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||||
@@ -507,6 +522,7 @@ fn main() -> Result<()> {
|
|||||||
Command::Login { username } => cmd_login(username),
|
Command::Login { username } => cmd_login(username),
|
||||||
Command::Logout => cmd_logout(),
|
Command::Logout => cmd_logout(),
|
||||||
Command::Status => cmd_status(),
|
Command::Status => cmd_status(),
|
||||||
|
Command::Locations => cmd_locations(),
|
||||||
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
Command::Mount { mountpoint } => cmd_mount(mountpoint),
|
||||||
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
Command::Daemon { mountpoint } => cmd_daemon(mountpoint),
|
||||||
Command::Pin { path } => cmd_pin(path),
|
Command::Pin { path } => cmd_pin(path),
|
||||||
@@ -514,6 +530,8 @@ fn main() -> Result<()> {
|
|||||||
Command::Pins => cmd_pins(),
|
Command::Pins => cmd_pins(),
|
||||||
Command::Ls { path } => cmd_ls(path),
|
Command::Ls { path } => cmd_ls(path),
|
||||||
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
Command::Photos { limit, offset } => cmd_photos(limit, offset),
|
||||||
|
Command::Albums => cmd_albums(),
|
||||||
|
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||||
Command::Search { query, limit } => cmd_search(query, limit),
|
Command::Search { query, limit } => cmd_search(query, limit),
|
||||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||||
@@ -902,7 +920,15 @@ fn cmd_shared_with_me(uid: Option<String>) -> Result<()> {
|
|||||||
CtlResponse::Entries { entries } => {
|
CtlResponse::Entries { entries } => {
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let kind = if e.is_dir { "d" } else { "-" };
|
let kind = if e.is_dir { "d" } else { "-" };
|
||||||
println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid);
|
// My role decides whether this is writable at all, so it is
|
||||||
|
// printed whenever the API reported one; an empty role prints
|
||||||
|
// nothing rather than a guess.
|
||||||
|
let role = if e.role.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" ({})", e.role)
|
||||||
|
};
|
||||||
|
println!("{kind} {:>12} {}{role} [{}]", e.size, e.name, e.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
@@ -1002,6 +1028,7 @@ fn activity_verb(kind: pdfs_core::control::ActivityKind) -> &'static str {
|
|||||||
Share => "share",
|
Share => "share",
|
||||||
PublicLink => "link",
|
PublicLink => "link",
|
||||||
Unshare => "unshare",
|
Unshare => "unshare",
|
||||||
|
Conflict => "conflict",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1200,6 +1227,45 @@ fn cmd_status() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_locations() -> Result<()> {
|
||||||
|
let response = control_request(CtlRequest::ListLocations)?;
|
||||||
|
if emit_json(&response)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match response {
|
||||||
|
CtlResponse::Locations { items } if items.is_empty() => {
|
||||||
|
println!("No Proton Drive locations.");
|
||||||
|
}
|
||||||
|
CtlResponse::Locations { items } => {
|
||||||
|
for location in items {
|
||||||
|
let kind = match &location.kind {
|
||||||
|
MountKind::MyFiles => "My Files".to_string(),
|
||||||
|
MountKind::Device { sync_folder_id } => {
|
||||||
|
format!("Device folder {sync_folder_id}")
|
||||||
|
}
|
||||||
|
MountKind::Shared { .. } => "Shared folder".to_string(),
|
||||||
|
};
|
||||||
|
let mode = match location.pending_mode {
|
||||||
|
Some(pending) => format!("{} -> {pending}", location.mode),
|
||||||
|
None => location.mode.to_string(),
|
||||||
|
};
|
||||||
|
let mounted = if location.mounted {
|
||||||
|
"mounted"
|
||||||
|
} else {
|
||||||
|
"not mounted"
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{kind}: {} ({mode}, {}, {}, {mounted})",
|
||||||
|
location.local_path, location.access, location.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
fn cmd_mount(mountpoint: Option<PathBuf>) -> Result<()> {
|
||||||
mount_once(mountpoint)?;
|
mount_once(mountpoint)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1290,7 +1356,10 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
|||||||
cache,
|
cache,
|
||||||
&control_socket,
|
&control_socket,
|
||||||
db,
|
db,
|
||||||
username,
|
pdfs_fuse::MountOptions {
|
||||||
|
username,
|
||||||
|
sweep_mode: config.resolved_conflict_sweep(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean unmount check (as a best-effort final backup).
|
// Clean unmount check (as a best-effort final backup).
|
||||||
@@ -1431,24 +1500,52 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
println!("This account has no photos volume.")
|
println!("This account has no photos volume.")
|
||||||
}
|
}
|
||||||
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
CtlResponse::Photos { items, .. } => {
|
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
||||||
// The timeline reply only carries thumbnails already in the cache, so
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
// pull the rest of the page's in one batch to print a path per photo.
|
other => bail!("unexpected response: {other:?}"),
|
||||||
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
}
|
||||||
let thumbs: HashMap<String, Option<String>> =
|
Ok(())
|
||||||
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
}
|
||||||
CtlResponse::Thumbs { items } => {
|
|
||||||
items.into_iter().map(|t| (t.uid, t.path)).collect()
|
/// Print one page of photos as `capture-time uid thumbnail-path`.
|
||||||
}
|
///
|
||||||
_ => HashMap::new(),
|
/// A timeline or album reply only carries thumbnails already in the cache, so
|
||||||
|
/// the rest of the page's are pulled in one batch rather than left blank.
|
||||||
|
fn print_photo_page(items: Vec<pdfs_core::control::PhotoItem>) -> Result<()> {
|
||||||
|
let uids: Vec<String> = items.iter().map(|p| p.uid.clone()).collect();
|
||||||
|
let thumbs: HashMap<String, Option<String>> =
|
||||||
|
match control_request(CtlRequest::PhotoThumbs { uids })? {
|
||||||
|
CtlResponse::Thumbs { items } => items.into_iter().map(|t| (t.uid, t.path)).collect(),
|
||||||
|
_ => HashMap::new(),
|
||||||
|
};
|
||||||
|
for p in items {
|
||||||
|
let thumb = thumbs
|
||||||
|
.get(&p.uid)
|
||||||
|
.and_then(|p| p.as_deref())
|
||||||
|
.or(p.thumb_path.as_deref())
|
||||||
|
.unwrap_or("(no thumbnail)");
|
||||||
|
println!("{} {} {thumb}", p.capture_time, p.uid);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_albums() -> Result<()> {
|
||||||
|
match control_request(CtlRequest::PhotoAlbums)? {
|
||||||
|
CtlResponse::Albums {
|
||||||
|
available: false, ..
|
||||||
|
} => println!("This account has no photos volume."),
|
||||||
|
CtlResponse::Albums { items, .. } if items.is_empty() => println!("No albums."),
|
||||||
|
CtlResponse::Albums { items, .. } => {
|
||||||
|
for album in items {
|
||||||
|
let shared = if album.shared {
|
||||||
|
" (shared with me)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
};
|
};
|
||||||
for p in items {
|
println!(
|
||||||
let thumb = thumbs
|
"{:>6} {} {}{shared}",
|
||||||
.get(&p.uid)
|
album.photo_count, album.uid, album.name
|
||||||
.and_then(|p| p.as_deref())
|
);
|
||||||
.or(p.thumb_path.as_deref())
|
|
||||||
.unwrap_or("(no thumbnail)");
|
|
||||||
println!("{} {} {thumb}", p.capture_time, p.uid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
@@ -1457,6 +1554,16 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_album(uid: String, limit: usize, offset: usize) -> Result<()> {
|
||||||
|
match control_request(CtlRequest::AlbumPhotos { uid, offset, limit })? {
|
||||||
|
CtlResponse::Photos { items, .. } if items.is_empty() => println!("No photos."),
|
||||||
|
CtlResponse::Photos { items, .. } => print_photo_page(items)?,
|
||||||
|
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||||
|
other => bail!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_open_photo(uid: String) -> Result<()> {
|
fn cmd_open_photo(uid: String) -> Result<()> {
|
||||||
match control_request(CtlRequest::OpenPhoto { uid })? {
|
match control_request(CtlRequest::OpenPhoto { uid })? {
|
||||||
CtlResponse::FilePath { path } => println!("{path}"),
|
CtlResponse::FilePath { path } => println!("{path}"),
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! Effective access carried by mounted Drive nodes.
|
||||||
|
|
||||||
|
use proton_drive_rs::MemberRole;
|
||||||
|
|
||||||
|
/// Effective write access for a node in a mounted tree.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Access {
|
||||||
|
Owner,
|
||||||
|
Editor,
|
||||||
|
Viewer,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Access {
|
||||||
|
pub fn writable(self) -> bool {
|
||||||
|
matches!(self, Self::Owner | Self::Editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_db_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Owner => "owner",
|
||||||
|
Self::Editor => "editor",
|
||||||
|
Self::Viewer => "viewer",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_db_str(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"owner" => Some(Self::Owner),
|
||||||
|
"editor" => Some(Self::Editor),
|
||||||
|
"viewer" => Some(Self::Viewer),
|
||||||
|
"unknown" => Some(Self::Unknown),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a share root's effective access from its exact membership role.
|
||||||
|
///
|
||||||
|
/// Owned trees fail open. A share whose role is absent or unrecognised fails
|
||||||
|
/// closed. Descendants normally inherit directly at intern time; `Inherited`
|
||||||
|
/// exists for the explicit wire role and therefore returns `parent`.
|
||||||
|
pub fn access_for(role: Option<MemberRole>, parent: Access, under_share: bool) -> Access {
|
||||||
|
match role {
|
||||||
|
Some(MemberRole::Viewer) => Access::Viewer,
|
||||||
|
Some(MemberRole::Editor | MemberRole::Admin) => Access::Editor,
|
||||||
|
Some(MemberRole::Inherited) => parent,
|
||||||
|
None if under_share => Access::Viewer,
|
||||||
|
None => Access::Owner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POSIX permission bits advertised for a mounted node.
|
||||||
|
pub fn perm_bits(is_dir: bool, access: Access) -> u16 {
|
||||||
|
match (is_dir, access.writable()) {
|
||||||
|
(true, true) => 0o755,
|
||||||
|
(false, true) => 0o644,
|
||||||
|
(true, false) => 0o555,
|
||||||
|
(false, false) => 0o444,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_for_covers_the_fail_open_and_fail_closed_table() {
|
||||||
|
for parent in [
|
||||||
|
Access::Owner,
|
||||||
|
Access::Editor,
|
||||||
|
Access::Viewer,
|
||||||
|
Access::Unknown,
|
||||||
|
] {
|
||||||
|
assert_eq!(access_for(None, parent, false), Access::Owner);
|
||||||
|
assert_eq!(access_for(None, parent, true), Access::Viewer);
|
||||||
|
assert_eq!(
|
||||||
|
access_for(Some(MemberRole::Viewer), parent, true),
|
||||||
|
Access::Viewer
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
access_for(Some(MemberRole::Editor), parent, true),
|
||||||
|
Access::Editor
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
access_for(Some(MemberRole::Admin), parent, true),
|
||||||
|
Access::Editor
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
access_for(Some(MemberRole::Inherited), parent, true),
|
||||||
|
parent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_bits_follow_effective_access() {
|
||||||
|
for writable in [Access::Owner, Access::Editor] {
|
||||||
|
assert_eq!(perm_bits(true, writable), 0o755);
|
||||||
|
assert_eq!(perm_bits(false, writable), 0o644);
|
||||||
|
}
|
||||||
|
for read_only in [Access::Viewer, Access::Unknown] {
|
||||||
|
assert_eq!(perm_bits(true, read_only), 0o555);
|
||||||
|
assert_eq!(perm_bits(false, read_only), 0o444);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
-14
@@ -17,6 +17,7 @@
|
|||||||
//! [`Node`]: proton_drive_rs::Node
|
//! [`Node`]: proton_drive_rs::Node
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::fmt;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -28,10 +29,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::db::{CacheEntryInput, Db};
|
use crate::db::{CacheEntryInput, Db};
|
||||||
use crate::error::Result;
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// `cache_entries.kind` tag for whole-file content blobs.
|
/// `cache_entries.kind` tag for whole-file content blobs.
|
||||||
const KIND_BLOB: &str = "blob";
|
const KIND_BLOB: &str = "blob";
|
||||||
|
static STAGING_N: AtomicU64 = AtomicU64::new(0);
|
||||||
/// `cache_entries.kind` tag for on-demand block-cache chunks.
|
/// `cache_entries.kind` tag for on-demand block-cache chunks.
|
||||||
const KIND_BLOCK: &str = "block";
|
const KIND_BLOCK: &str = "block";
|
||||||
/// `cache_entries.kind` tag for cached thumbnails.
|
/// `cache_entries.kind` tag for cached thumbnails.
|
||||||
@@ -150,15 +152,52 @@ pub struct StagedWrite {
|
|||||||
pub based_on: Option<Baseline>,
|
pub based_on: Option<Baseline>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The identity of a remote revision, as far as we can observe it: a file whose
|
/// The identity of a remote revision, as far as we can observe it.
|
||||||
/// size and mtime both still match the ones a queued change was made against has
|
///
|
||||||
/// not been rewritten by anyone else in the meantime.
|
/// `revision_id` is the authoritative identity when present: the server assigns a
|
||||||
|
/// fresh id to every sealed revision, so an unchanged id proves no one rewrote
|
||||||
|
/// the file — even when the server re-stamped its `mtime` on the *same* revision,
|
||||||
|
/// which is the drift that used to cut spurious conflict copies (B16/B25).
|
||||||
|
/// `mtime`/`size` remain the fallback for a baseline recorded before the id was
|
||||||
|
/// observable, or a node read through a surface that does not carry it.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Baseline {
|
pub struct Baseline {
|
||||||
pub mtime: i64,
|
pub mtime: i64,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hash: Option<String>,
|
pub hash: Option<String>,
|
||||||
|
/// Server revision id this change was made against (`None` if unknown).
|
||||||
|
#[serde(default)]
|
||||||
|
pub revision_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A denied-write rescue that retained the bytes but could not fully publish
|
||||||
|
/// their recovery metadata.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PreserveWriteError {
|
||||||
|
/// The actual path that still contains the accepted bytes.
|
||||||
|
pub surviving_path: PathBuf,
|
||||||
|
/// The recovery metadata that still describes those bytes, when one could
|
||||||
|
/// be made durable. Callers must never clear this after an error.
|
||||||
|
pub marker_path: Option<PathBuf>,
|
||||||
|
error: Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PreserveWriteError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(
|
||||||
|
formatter,
|
||||||
|
"{} (bytes retained at {})",
|
||||||
|
self.error,
|
||||||
|
self.surviving_path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PreserveWriteError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
Some(&self.error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
/// Content cache rooted at a directory, with a sibling pin-registry file.
|
||||||
@@ -217,6 +256,18 @@ pub struct ContentCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ContentCache {
|
impl ContentCache {
|
||||||
|
fn staging_path(&self, meta: &StagedWrite) -> PathBuf {
|
||||||
|
// The uid goes in the name so a staged file can be tied back to its node
|
||||||
|
// without a database; `/` in a uid would otherwise open a path.
|
||||||
|
let safe_uid = meta.uid.replace(['/', '\\'], "_");
|
||||||
|
self.staging_dir.join(format!(
|
||||||
|
"{}-{}-{}",
|
||||||
|
safe_uid,
|
||||||
|
now_millis(),
|
||||||
|
STAGING_N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Open (and create) a cache under `content_dir`, with the pin registry at
|
/// Open (and create) a cache under `content_dir`, with the pin registry at
|
||||||
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
|
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
|
||||||
/// directories are created if missing. `db` is the shared metadata database
|
/// directories are created if missing. `db` is the shared metadata database
|
||||||
@@ -902,16 +953,7 @@ impl ContentCache {
|
|||||||
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
|
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
static N: AtomicU64 = AtomicU64::new(0);
|
let path = self.staging_path(meta);
|
||||||
// The uid goes in the name so a staged file can be tied back to its node
|
|
||||||
// without a database; `/` in a uid would otherwise open a path.
|
|
||||||
let safe_uid = meta.uid.replace(['/', '\\'], "_");
|
|
||||||
let path = self.staging_dir.join(format!(
|
|
||||||
"{}-{}-{}",
|
|
||||||
safe_uid,
|
|
||||||
now_millis(),
|
|
||||||
N.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
|
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
|
||||||
if e.kind() != std::io::ErrorKind::CrossesDevices {
|
if e.kind() != std::io::ErrorKind::CrossesDevices {
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
@@ -941,6 +983,154 @@ impl ContentCache {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Preserve accepted bytes which cannot be queued.
|
||||||
|
///
|
||||||
|
/// Scratch and staging normally share a filesystem, so the normal path is
|
||||||
|
/// an atomic rename. If that is impossible, the source first receives a
|
||||||
|
/// durable recovery marker and remains in place until a copied destination,
|
||||||
|
/// its sidecar, and the staging directory are all synced.
|
||||||
|
pub fn preserve_write(
|
||||||
|
&self,
|
||||||
|
meta: &StagedWrite,
|
||||||
|
scratch: &Path,
|
||||||
|
) -> std::result::Result<PathBuf, PreserveWriteError> {
|
||||||
|
let path = self.staging_path(meta);
|
||||||
|
self.preserve_write_at(meta, scratch, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preserve_write_at(
|
||||||
|
&self,
|
||||||
|
meta: &StagedWrite,
|
||||||
|
scratch: &Path,
|
||||||
|
path: &Path,
|
||||||
|
) -> std::result::Result<PathBuf, PreserveWriteError> {
|
||||||
|
use std::io::Write as _;
|
||||||
|
|
||||||
|
let retained = |surviving_path: &Path, error: Error| {
|
||||||
|
let adjacent = Self::scratch_sidecar(surviving_path);
|
||||||
|
let source = Self::scratch_sidecar(scratch);
|
||||||
|
let marker_path = [adjacent, source].into_iter().find(|path| path.is_file());
|
||||||
|
PreserveWriteError {
|
||||||
|
surviving_path: surviving_path.to_path_buf(),
|
||||||
|
marker_path,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// If rename cannot happen, this marker is what lets the next open move
|
||||||
|
// the still-authoritative source into recovery instead of deleting it.
|
||||||
|
let marker_result = self.mark_scratch_durable(scratch, meta);
|
||||||
|
let side = path.with_extension("json");
|
||||||
|
let side_tmp = path.with_extension("json.tmp");
|
||||||
|
let publish_sidecar = || -> Result<()> {
|
||||||
|
let mut side_file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&side_tmp)?;
|
||||||
|
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
|
||||||
|
side_file.sync_all()?;
|
||||||
|
drop(side_file);
|
||||||
|
std::fs::rename(&side_tmp, &side)?;
|
||||||
|
std::fs::File::open(&self.staging_dir)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
match std::fs::rename(scratch, path) {
|
||||||
|
Ok(()) => {
|
||||||
|
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
|
||||||
|
if std::fs::rename(path, scratch).is_ok() {
|
||||||
|
if !Self::scratch_sidecar(scratch).is_file() {
|
||||||
|
let _ = self.mark_scratch_durable(scratch, meta);
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
|
||||||
|
return Err(retained(scratch, error.into()));
|
||||||
|
}
|
||||||
|
return Err(retained(path, error.into()));
|
||||||
|
}
|
||||||
|
let scratch_side = Self::scratch_sidecar(scratch);
|
||||||
|
let side_result = if marker_result.is_ok() {
|
||||||
|
std::fs::rename(&scratch_side, &side)
|
||||||
|
.map_err(Error::from)
|
||||||
|
.and_then(|()| {
|
||||||
|
std::fs::File::open(&self.staging_dir)?
|
||||||
|
.sync_all()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
publish_sidecar()
|
||||||
|
};
|
||||||
|
if let Err(error) = side_result {
|
||||||
|
let _ = std::fs::remove_file(&side_tmp);
|
||||||
|
// Publication did not complete. Put the blob back beside
|
||||||
|
// the source marker so startup recovery still has a
|
||||||
|
// reconstructable pair, rather than an unexplained staging
|
||||||
|
// blob and a marker whose source vanished.
|
||||||
|
if std::fs::rename(path, scratch).is_ok() {
|
||||||
|
if !Self::scratch_sidecar(scratch).is_file() {
|
||||||
|
let _ = self.mark_scratch_durable(scratch, meta);
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
|
||||||
|
return Err(retained(scratch, error));
|
||||||
|
}
|
||||||
|
return Err(retained(path, error));
|
||||||
|
}
|
||||||
|
Ok(path.to_path_buf())
|
||||||
|
}
|
||||||
|
Err(rename_error) => {
|
||||||
|
let source_marked =
|
||||||
|
marker_result.is_ok() || Self::scratch_sidecar(scratch).exists();
|
||||||
|
if let Err(marker_error) = marker_result {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %marker_error,
|
||||||
|
path = %scratch.display(),
|
||||||
|
"could not mark rescue source durable before copy fallback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::copy(scratch, path) {
|
||||||
|
if !source_marked {
|
||||||
|
let recovery = self.recovery_dir.join(
|
||||||
|
path.file_name()
|
||||||
|
.unwrap_or_else(|| std::ffi::OsStr::new("denied-write")),
|
||||||
|
);
|
||||||
|
if std::fs::rename(scratch, &recovery).is_ok() {
|
||||||
|
let _ = std::fs::File::open(&self.recovery_dir)
|
||||||
|
.and_then(|dir| dir.sync_all());
|
||||||
|
return Err(retained(
|
||||||
|
&recovery,
|
||||||
|
Error::Other(format!(
|
||||||
|
"rename failed ({rename_error}); copy fallback failed ({error})"
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(retained(
|
||||||
|
scratch,
|
||||||
|
Error::Other(format!(
|
||||||
|
"rename failed ({rename_error}); copy fallback failed ({error})"
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
|
||||||
|
let surviving = if source_marked { scratch } else { path };
|
||||||
|
return Err(retained(surviving, error.into()));
|
||||||
|
}
|
||||||
|
if let Err(error) = publish_sidecar() {
|
||||||
|
let _ = std::fs::remove_file(&side_tmp);
|
||||||
|
let surviving = if source_marked { scratch } else { path };
|
||||||
|
return Err(retained(surviving, error));
|
||||||
|
}
|
||||||
|
// Deliberately last: fallback never removes its marked source
|
||||||
|
// until destination publication is complete.
|
||||||
|
if let Err(error) = std::fs::remove_file(scratch) {
|
||||||
|
return Err(retained(path, error.into()));
|
||||||
|
}
|
||||||
|
self.clear_scratch_durable(scratch);
|
||||||
|
Ok(path.to_path_buf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a staged blob and its sidecar, once its bytes are safely on the
|
/// Drop a staged blob and its sidecar, once its bytes are safely on the
|
||||||
/// remote (or the op that owned them has been superseded). Best effort:
|
/// remote (or the op that owned them has been superseded). Best effort:
|
||||||
/// leftovers cost disk, while failing here would strand a drained op.
|
/// leftovers cost disk, while failing here would strand a drained op.
|
||||||
@@ -1873,6 +2063,7 @@ mod tests {
|
|||||||
mtime: 100,
|
mtime: 100,
|
||||||
size: 40,
|
size: 40,
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: None,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
let staged = c.stage_write(&meta, &scratch).unwrap();
|
let staged = c.stage_write(&meta, &scratch).unwrap();
|
||||||
@@ -1897,6 +2088,92 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preservation_meta() -> StagedWrite {
|
||||||
|
StagedWrite {
|
||||||
|
uid: uid("preserved").to_string(),
|
||||||
|
len: 14,
|
||||||
|
base_size: 0,
|
||||||
|
base_mtime: 100,
|
||||||
|
authored: vec![(0, 14)],
|
||||||
|
complete: true,
|
||||||
|
based_on: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orphan_preservation_publishes_exact_bytes_then_removes_source() {
|
||||||
|
use std::io::Write as _;
|
||||||
|
|
||||||
|
let (c, _dir) = cache();
|
||||||
|
let (mut file, scratch) = c.create_scratch().unwrap();
|
||||||
|
file.write_all(b"accepted bytes").unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
|
||||||
|
|
||||||
|
assert!(!scratch.exists());
|
||||||
|
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
|
||||||
|
let sidecar: StagedWrite =
|
||||||
|
serde_json::from_slice(&std::fs::read(preserved.with_extension("json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let expected = preservation_meta();
|
||||||
|
assert_eq!(sidecar.uid, expected.uid);
|
||||||
|
assert_eq!(sidecar.len, expected.len);
|
||||||
|
assert_eq!(sidecar.authored, expected.authored);
|
||||||
|
assert_eq!(sidecar.complete, expected.complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orphan_preservation_sidecar_failure_reports_surviving_blob_across_reopen() {
|
||||||
|
use std::io::Write as _;
|
||||||
|
|
||||||
|
let (c, dir) = cache();
|
||||||
|
let (mut file, scratch) = c.create_scratch().unwrap();
|
||||||
|
file.write_all(b"only surviving copy").unwrap();
|
||||||
|
file.sync_all().unwrap();
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
let destination = c.staging_dir.join("forced-preservation-failure");
|
||||||
|
let side = destination.with_extension("json");
|
||||||
|
std::fs::create_dir(&side).unwrap();
|
||||||
|
|
||||||
|
let error = c
|
||||||
|
.preserve_write_at(&preservation_meta(), &scratch, &destination)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.surviving_path, scratch);
|
||||||
|
assert!(
|
||||||
|
scratch.exists(),
|
||||||
|
"failed sidecar publication must roll the blob back beside its marker"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&error.surviving_path).unwrap(),
|
||||||
|
b"only surviving copy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error.marker_path,
|
||||||
|
Some(ContentCache::scratch_sidecar(&scratch))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
side.is_dir(),
|
||||||
|
"the induced sidecar failure remains observable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = dir.path().join("content");
|
||||||
|
let pins = dir.path().join("pins-reopen.json");
|
||||||
|
drop(c);
|
||||||
|
let reopened =
|
||||||
|
ContentCache::open(content, pins, 0, Arc::new(Db::open_in_memory().unwrap())).unwrap();
|
||||||
|
let recovered = reopened.recovered_writes();
|
||||||
|
assert_eq!(recovered.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&recovered[0].0).unwrap(),
|
||||||
|
b"only surviving copy"
|
||||||
|
);
|
||||||
|
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
|
||||||
|
drop(reopened);
|
||||||
|
}
|
||||||
|
|
||||||
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
|
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
|
||||||
/// at `release`. A scratch file marked durable must therefore outlive the
|
/// at `release`. A scratch file marked durable must therefore outlive the
|
||||||
/// open that clears the scratch directory — and an unmarked one must not,
|
/// open that clears the scratch directory — and an unmarked one must not,
|
||||||
|
|||||||
@@ -20,6 +20,59 @@ pub const KEYRING_SERVICE: &str = "proton-drive-linux";
|
|||||||
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
/// blobs back under this; pinned files are exempt. See [`crate::cache`].
|
||||||
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 5 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Environment override for [`AppConfig::resolved_conflict_sweep`]. Exists so a
|
||||||
|
/// support case can disable the sweep without editing the config file or
|
||||||
|
/// reinstalling — it wins over the stored setting.
|
||||||
|
pub const CONFLICT_SWEEP_ENV: &str = "PDFS_CONFLICT_SWEEP";
|
||||||
|
|
||||||
|
/// What the background conflict sweep (`pdfs-fuse`'s `pdfs-conflict-sweep`
|
||||||
|
/// thread) is allowed to do about a `(sync-conflict …)` copy it believes is a
|
||||||
|
/// redundant duplicate.
|
||||||
|
///
|
||||||
|
/// Defaults to [`SweepMode::Report`]: the sweep's whole reason to exist is that
|
||||||
|
/// the *previous* revision-identity check was wrong (B69), so the replacement is
|
||||||
|
/// not trusted with deletion until it has a release cycle of field evidence
|
||||||
|
/// behind it. See `docs/BUGS.md` B71.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SweepMode {
|
||||||
|
/// Do not run the sweep at all — the thread is never spawned.
|
||||||
|
Off,
|
||||||
|
/// Run, and log what it *would* remove, but never trash anything. Divergent
|
||||||
|
/// and orphaned copies are still surfaced to the activity feed as usual.
|
||||||
|
#[default]
|
||||||
|
Report,
|
||||||
|
/// Trash copies proven identical to their live sibling.
|
||||||
|
Enforce,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SweepMode {
|
||||||
|
/// Parse an operator-supplied value ([`CONFLICT_SWEEP_ENV`] or a CLI flag).
|
||||||
|
/// `None` for anything unrecognised, so a typo falls back to the stored
|
||||||
|
/// setting rather than silently picking a mode the operator did not ask for.
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"off" | "no" | "false" | "0" | "disabled" => Some(Self::Off),
|
||||||
|
"report" | "dry-run" | "dryrun" | "observe" => Some(Self::Report),
|
||||||
|
"enforce" | "on" | "yes" | "true" | "1" => Some(Self::Enforce),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mode is allowed to trash a duplicate.
|
||||||
|
pub fn is_enforcing(self) -> bool {
|
||||||
|
matches!(self, Self::Enforce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Precedence for [`AppConfig::resolved_conflict_sweep`], split out from the
|
||||||
|
/// environment read so it can be unit-tested without mutating process state.
|
||||||
|
fn resolve_sweep_mode(env: Option<&str>, stored: Option<SweepMode>) -> SweepMode {
|
||||||
|
env.and_then(SweepMode::parse)
|
||||||
|
.or(stored)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration structure allowing the user to customize client identification headers.
|
/// Configuration structure allowing the user to customize client identification headers.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
@@ -48,6 +101,11 @@ pub struct AppConfig {
|
|||||||
/// by hostname". Defaulted for configs predating the field.
|
/// by hostname". Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub device_uid: Option<String>,
|
pub device_uid: Option<String>,
|
||||||
|
/// What the background conflict sweep may do. `None` means "use
|
||||||
|
/// [`SweepMode::default`]" (report-only). Defaulted for configs predating
|
||||||
|
/// the field, and overridable at runtime via [`CONFLICT_SWEEP_ENV`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub conflict_sweep: Option<SweepMode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -59,6 +117,7 @@ impl Default for AppConfig {
|
|||||||
mountpoint: None,
|
mountpoint: None,
|
||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
|
conflict_sweep: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +129,20 @@ impl AppConfig {
|
|||||||
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
self.cache_budget.unwrap_or(DEFAULT_CACHE_BUDGET_BYTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective conflict-sweep mode: the [`CONFLICT_SWEEP_ENV`] override if
|
||||||
|
/// it parses, then the user's stored choice, then [`SweepMode::default`].
|
||||||
|
///
|
||||||
|
/// The environment wins so a support case can stop a destructive background
|
||||||
|
/// loop without editing config or reinstalling (`docs/BUGS.md` B71). An
|
||||||
|
/// unparseable value is ignored rather than treated as "off", so a typo
|
||||||
|
/// cannot silently change behaviour in either direction.
|
||||||
|
pub fn resolved_conflict_sweep(&self) -> SweepMode {
|
||||||
|
resolve_sweep_mode(
|
||||||
|
std::env::var(CONFLICT_SWEEP_ENV).ok().as_deref(),
|
||||||
|
self.conflict_sweep,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The effective global sync ignore patterns: the user's explicit list, or
|
/// The effective global sync ignore patterns: the user's explicit list, or
|
||||||
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
/// [`DEFAULT_IGNORE_PATTERNS`] when unset. An explicit empty list is
|
||||||
/// honoured as "no global patterns", which is why this is not a
|
/// honoured as "no global patterns", which is why this is not a
|
||||||
@@ -374,6 +447,7 @@ mod tests {
|
|||||||
mountpoint: Some("/tmp/x".to_string()),
|
mountpoint: Some("/tmp/x".to_string()),
|
||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
};
|
};
|
||||||
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();
|
||||||
@@ -388,6 +462,62 @@ mod tests {
|
|||||||
assert_eq!(default_config.user_agent, USER_AGENT);
|
assert_eq!(default_config.user_agent, USER_AGENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_defaults_to_report_only() {
|
||||||
|
// The sweep trashes user files, so an installed config that has never
|
||||||
|
// heard of the setting must not get the enforcing behaviour.
|
||||||
|
assert_eq!(SweepMode::default(), SweepMode::Report);
|
||||||
|
assert!(!SweepMode::default().is_enforcing());
|
||||||
|
assert_eq!(resolve_sweep_mode(None, None), SweepMode::Report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_parses_operator_spellings() {
|
||||||
|
for s in ["off", "OFF", " no ", "false", "0", "disabled"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Off), "{s}");
|
||||||
|
}
|
||||||
|
for s in ["report", "dry-run", "dryrun", "observe"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Report), "{s}");
|
||||||
|
}
|
||||||
|
for s in ["enforce", "on", "yes", "true", "1"] {
|
||||||
|
assert_eq!(SweepMode::parse(s), Some(SweepMode::Enforce), "{s}");
|
||||||
|
}
|
||||||
|
// A typo must not resolve to anything, so it falls through to the config.
|
||||||
|
assert_eq!(SweepMode::parse("offf"), None);
|
||||||
|
assert_eq!(SweepMode::parse(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_env_overrides_config_but_a_typo_does_not() {
|
||||||
|
let stored = Some(SweepMode::Enforce);
|
||||||
|
// The point of the override: kill a destructive loop without editing config.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("off"), stored), SweepMode::Off);
|
||||||
|
// Unrecognised value falls back to the stored choice rather than guessing.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("nope"), stored), SweepMode::Enforce);
|
||||||
|
assert_eq!(resolve_sweep_mode(None, stored), SweepMode::Enforce);
|
||||||
|
// No env, no stored value: report-only.
|
||||||
|
assert_eq!(resolve_sweep_mode(Some("nope"), None), SweepMode::Report);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_predating_conflict_sweep_still_loads() {
|
||||||
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(decoded.conflict_sweep.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_mode_round_trips_through_the_config_file() {
|
||||||
|
let config = AppConfig {
|
||||||
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
assert!(json.contains(r#""conflict_sweep":"enforce""#), "{json}");
|
||||||
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(decoded.conflict_sweep, Some(SweepMode::Enforce));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_config_predating_ignore_patterns_still_loads() {
|
fn a_config_predating_ignore_patterns_still_loads() {
|
||||||
// Every installed config on disk looks like this; the field must not be
|
// Every installed config on disk looks like this; the field must not be
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::cache::Pin;
|
use crate::cache::Pin;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
pub use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|
||||||
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
|
/// Cap on the *write* half of a round-trip. A crashed daemon can leave its
|
||||||
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
|
/// listening socket in the kernel (e.g. as a `<defunct>` zombie): `connect`
|
||||||
@@ -70,6 +71,20 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
kind: Option<PhotoKind>,
|
kind: Option<PhotoKind>,
|
||||||
},
|
},
|
||||||
|
/// List the account's photo albums, newest activity first, including the
|
||||||
|
/// albums other people share with us. Metadata only — an album's cover
|
||||||
|
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||||
|
/// Replies with [`Response::Albums`].
|
||||||
|
PhotoAlbums,
|
||||||
|
/// Fetch a page of one album's photos, newest capture first. Same reply
|
||||||
|
/// shape as [`Request::PhotosTimeline`] ([`Response::Photos`]), so a
|
||||||
|
/// front-end paints an album with the gallery it already has. `uid` is the
|
||||||
|
/// album node's uid, as reported by [`Request::PhotoAlbums`].
|
||||||
|
AlbumPhotos {
|
||||||
|
uid: String,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||||
/// already cached (one batched round-trip) and replying with their on-disk
|
/// already cached (one batched round-trip) and replying with their on-disk
|
||||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||||
@@ -194,6 +209,11 @@ pub enum Request {
|
|||||||
/// it invalidates, it does not fetch.
|
/// it invalidates, it does not fetch.
|
||||||
Refresh { scope: RefreshScope },
|
Refresh { scope: RefreshScope },
|
||||||
|
|
||||||
|
// ---- locations --------------------------------------------------------
|
||||||
|
/// List every local Proton Drive location. Replies with
|
||||||
|
/// [`Response::Locations`].
|
||||||
|
ListLocations,
|
||||||
|
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
/// List the account's registered devices. Replies with [`Response::Devices`].
|
/// List the account's registered devices. Replies with [`Response::Devices`].
|
||||||
ListDevices,
|
ListDevices,
|
||||||
@@ -240,9 +260,20 @@ pub enum Request {
|
|||||||
role: String,
|
role: String,
|
||||||
message: Option<String>,
|
message: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// The by-uid twin of [`Request::ShareNode`], for nodes proven to belong to
|
||||||
|
/// an on-demand or mirror device location. This is a distinct variant so an
|
||||||
|
/// older daemon rejects it instead of ignoring `uid` and acting on `path`.
|
||||||
|
ShareNodeByUid {
|
||||||
|
uid: String,
|
||||||
|
emails: Vec<String>,
|
||||||
|
role: String,
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
/// List the members, pending invitations and public link of the node at
|
/// List the members, pending invitations and public link of the node at
|
||||||
/// mountpoint-relative `path`. Replies with [`Response::Share`].
|
/// mountpoint-relative `path`. Replies with [`Response::Share`].
|
||||||
ListShare { path: String },
|
ListShare { path: String },
|
||||||
|
/// The by-uid twin of [`Request::ListShare`].
|
||||||
|
ListShareByUid { uid: String },
|
||||||
/// Change the role of a share entry (member or pending invitation) on the node
|
/// Change the role of a share entry (member or pending invitation) on the node
|
||||||
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
|
/// at `path`. `id` and `kind` identify the entry (from [`Response::Share`]).
|
||||||
/// Replies with [`Response::Ok`].
|
/// Replies with [`Response::Ok`].
|
||||||
@@ -252,6 +283,13 @@ pub enum Request {
|
|||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
role: String,
|
role: String,
|
||||||
},
|
},
|
||||||
|
/// The by-uid twin of [`Request::UpdateShareRole`].
|
||||||
|
UpdateShareRoleByUid {
|
||||||
|
uid: String,
|
||||||
|
id: String,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
role: String,
|
||||||
|
},
|
||||||
/// Remove a share entry (member, pending Proton invite, or external invite)
|
/// Remove a share entry (member, pending Proton invite, or external invite)
|
||||||
/// from the node at `path`. Replies with [`Response::Ok`].
|
/// from the node at `path`. Replies with [`Response::Ok`].
|
||||||
RemoveShareEntry {
|
RemoveShareEntry {
|
||||||
@@ -259,6 +297,12 @@ pub enum Request {
|
|||||||
id: String,
|
id: String,
|
||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
},
|
},
|
||||||
|
/// The by-uid twin of [`Request::RemoveShareEntry`].
|
||||||
|
RemoveShareEntryByUid {
|
||||||
|
uid: String,
|
||||||
|
id: String,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
},
|
||||||
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
|
/// Create a public link on the node at `path`. `role` is "viewer" or "editor";
|
||||||
/// `password` optionally adds a custom password; `expires` is an optional Unix
|
/// `password` optionally adds a custom password; `expires` is an optional Unix
|
||||||
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
|
/// expiry. Replies with [`Response::PublicLink`] (carrying the shareable URL).
|
||||||
@@ -268,9 +312,18 @@ pub enum Request {
|
|||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
},
|
},
|
||||||
|
/// The by-uid twin of [`Request::CreatePublicLink`].
|
||||||
|
CreatePublicLinkByUid {
|
||||||
|
uid: String,
|
||||||
|
role: String,
|
||||||
|
password: Option<String>,
|
||||||
|
expires: Option<i64>,
|
||||||
|
},
|
||||||
/// Remove the public link `id` from the node at `path`. Replies with
|
/// Remove the public link `id` from the node at `path`. Replies with
|
||||||
/// [`Response::Ok`].
|
/// [`Response::Ok`].
|
||||||
RemovePublicLink { path: String, id: String },
|
RemovePublicLink { path: String, id: String },
|
||||||
|
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||||
|
RemovePublicLinkByUid { uid: String, id: String },
|
||||||
|
|
||||||
// ---- shared by me -----------------------------------------------------
|
// ---- shared by me -----------------------------------------------------
|
||||||
/// List the nodes I have shared with others — collaborative shares that still
|
/// List the nodes I have shared with others — collaborative shares that still
|
||||||
@@ -573,6 +626,11 @@ pub enum ActivityKind {
|
|||||||
Share,
|
Share,
|
||||||
PublicLink,
|
PublicLink,
|
||||||
Unshare,
|
Unshare,
|
||||||
|
/// A `(sync-conflict …)` copy that needs the user's attention: it diverges
|
||||||
|
/// from the live file (or has no sibling), so the auto-sweep left it in place
|
||||||
|
/// rather than removing it. An auto-*removed* identical copy is logged as a
|
||||||
|
/// [`ActivityKind::Trash`] instead, since that is what happened to it.
|
||||||
|
Conflict,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
||||||
@@ -662,6 +720,12 @@ pub struct DirEntry {
|
|||||||
/// browser.
|
/// browser.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
/// My role on this node when it is reached through a share I accepted —
|
||||||
|
/// `"viewer"`, `"editor"`, `"admin"`, or empty for content I own (where the
|
||||||
|
/// question does not arise) and for a share whose role the API did not
|
||||||
|
/// report. Defaulted for wire-compat with clients/daemons predating it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
|
||||||
@@ -902,6 +966,29 @@ pub struct PhotoMonth {
|
|||||||
pub count: usize,
|
pub count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One album in a [`Response::Albums`] listing.
|
||||||
|
///
|
||||||
|
/// An album is a folder node on the photos volume carrying album properties, so
|
||||||
|
/// it has an ordinary name; everything else here comes from those properties.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct AlbumInfo {
|
||||||
|
/// Album node uid in `volume~link` form — what [`Request::AlbumPhotos`]
|
||||||
|
/// takes.
|
||||||
|
pub uid: String,
|
||||||
|
pub name: String,
|
||||||
|
/// How many photos the server says the album holds.
|
||||||
|
pub photo_count: usize,
|
||||||
|
/// The photo shown as the album's cover, when it has one. Its thumbnail is
|
||||||
|
/// fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||||
|
pub cover_uid: Option<String>,
|
||||||
|
/// Epoch seconds of the last change to the album's contents, when the server
|
||||||
|
/// reports one. The listing is ordered by it, newest first.
|
||||||
|
pub last_activity: Option<i64>,
|
||||||
|
/// True when this album lives on someone else's photos volume — it is shared
|
||||||
|
/// with us rather than ours.
|
||||||
|
pub shared: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// One photo in a [`Request::PhotosTimeline`] page.
|
/// One photo in a [`Request::PhotosTimeline`] page.
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PhotoItem {
|
pub struct PhotoItem {
|
||||||
@@ -1057,6 +1144,13 @@ pub enum Response {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
counts: Option<(usize, usize, usize)>,
|
counts: Option<(usize, usize, usize)>,
|
||||||
},
|
},
|
||||||
|
/// The account's photo albums (reply to [`Request::PhotoAlbums`]).
|
||||||
|
/// `available` is false when the account has no photos volume, matching
|
||||||
|
/// [`Response::Photos`].
|
||||||
|
Albums {
|
||||||
|
available: bool,
|
||||||
|
items: Vec<AlbumInfo>,
|
||||||
|
},
|
||||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||||
/// newest first.
|
/// newest first.
|
||||||
PhotoMonths { months: Vec<PhotoMonth> },
|
PhotoMonths { months: Vec<PhotoMonth> },
|
||||||
@@ -1089,6 +1183,8 @@ pub enum Response {
|
|||||||
},
|
},
|
||||||
/// The account's devices (reply to [`Request::ListDevices`]).
|
/// The account's devices (reply to [`Request::ListDevices`]).
|
||||||
Devices { items: Vec<DeviceInfo> },
|
Devices { items: Vec<DeviceInfo> },
|
||||||
|
/// Every local Proton Drive location (reply to [`Request::ListLocations`]).
|
||||||
|
Locations { items: Vec<MountSpec> },
|
||||||
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
||||||
SyncFolders { items: Vec<SyncFolderInfo> },
|
SyncFolders { items: Vec<SyncFolderInfo> },
|
||||||
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
||||||
@@ -1536,6 +1632,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sharing_requests_roundtrip() {
|
fn sharing_requests_roundtrip() {
|
||||||
let reqs = [
|
let reqs = [
|
||||||
|
Request::ListLocations,
|
||||||
Request::ListDevices,
|
Request::ListDevices,
|
||||||
Request::RenameDevice {
|
Request::RenameDevice {
|
||||||
uid: "dev-1".into(),
|
uid: "dev-1".into(),
|
||||||
@@ -1575,28 +1672,58 @@ mod tests {
|
|||||||
role: "editor".into(),
|
role: "editor".into(),
|
||||||
message: Some("hi".into()),
|
message: Some("hi".into()),
|
||||||
},
|
},
|
||||||
|
Request::ShareNodeByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
||||||
|
role: "editor".into(),
|
||||||
|
message: Some("hi".into()),
|
||||||
|
},
|
||||||
Request::ListShare { path: "a/b".into() },
|
Request::ListShare { path: "a/b".into() },
|
||||||
|
Request::ListShareByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
},
|
||||||
Request::UpdateShareRole {
|
Request::UpdateShareRole {
|
||||||
path: "a/b".into(),
|
path: "a/b".into(),
|
||||||
id: "mid-1".into(),
|
id: "mid-1".into(),
|
||||||
kind: ShareEntryKind::Member,
|
kind: ShareEntryKind::Member,
|
||||||
role: "admin".into(),
|
role: "admin".into(),
|
||||||
},
|
},
|
||||||
|
Request::UpdateShareRoleByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "mid-1".into(),
|
||||||
|
kind: ShareEntryKind::Member,
|
||||||
|
role: "admin".into(),
|
||||||
|
},
|
||||||
Request::RemoveShareEntry {
|
Request::RemoveShareEntry {
|
||||||
path: "a/b".into(),
|
path: "a/b".into(),
|
||||||
id: "iid-1".into(),
|
id: "iid-1".into(),
|
||||||
kind: ShareEntryKind::ExternalInvite,
|
kind: ShareEntryKind::ExternalInvite,
|
||||||
},
|
},
|
||||||
|
Request::RemoveShareEntryByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "iid-1".into(),
|
||||||
|
kind: ShareEntryKind::ExternalInvite,
|
||||||
|
},
|
||||||
Request::CreatePublicLink {
|
Request::CreatePublicLink {
|
||||||
path: "a/b".into(),
|
path: "a/b".into(),
|
||||||
role: "viewer".into(),
|
role: "viewer".into(),
|
||||||
password: Some("pw".into()),
|
password: Some("pw".into()),
|
||||||
expires: Some(1_700_000_000),
|
expires: Some(1_700_000_000),
|
||||||
},
|
},
|
||||||
|
Request::CreatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
password: Some("pw".into()),
|
||||||
|
expires: Some(1_700_000_000),
|
||||||
|
},
|
||||||
Request::RemovePublicLink {
|
Request::RemovePublicLink {
|
||||||
path: "a/b".into(),
|
path: "a/b".into(),
|
||||||
id: "url-1".into(),
|
id: "url-1".into(),
|
||||||
},
|
},
|
||||||
|
Request::RemovePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "url-1".into(),
|
||||||
|
},
|
||||||
Request::ListSharedByMe,
|
Request::ListSharedByMe,
|
||||||
Request::ListActivity { limit: 100 },
|
Request::ListActivity { limit: 100 },
|
||||||
Request::ListSharedWithMe,
|
Request::ListSharedWithMe,
|
||||||
@@ -1630,6 +1757,323 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locations_response_roundtrips_typed_mounts() {
|
||||||
|
let response = Response::Locations {
|
||||||
|
items: vec![
|
||||||
|
MountSpec {
|
||||||
|
id: 1,
|
||||||
|
kind: MountKind::MyFiles,
|
||||||
|
local_path: "/home/me/ProtonDrive".into(),
|
||||||
|
root_uid: "vol~root".into(),
|
||||||
|
root_share_id: "share-main".into(),
|
||||||
|
mode: MountMode::OnDemand,
|
||||||
|
access: MountAccess::Rw,
|
||||||
|
state: "idle".into(),
|
||||||
|
last_sync: 0,
|
||||||
|
pending_mode: None,
|
||||||
|
mounted: true,
|
||||||
|
progress: None,
|
||||||
|
},
|
||||||
|
MountSpec {
|
||||||
|
id: 2,
|
||||||
|
kind: MountKind::Device { sync_folder_id: 7 },
|
||||||
|
local_path: "/home/me/Work".into(),
|
||||||
|
root_uid: "device-vol~work".into(),
|
||||||
|
root_share_id: "device-share".into(),
|
||||||
|
mode: MountMode::Mirror,
|
||||||
|
access: MountAccess::Rw,
|
||||||
|
state: "syncing".into(),
|
||||||
|
last_sync: 42,
|
||||||
|
pending_mode: Some(MountMode::OnDemand),
|
||||||
|
mounted: false,
|
||||||
|
progress: None,
|
||||||
|
},
|
||||||
|
MountSpec {
|
||||||
|
id: 3,
|
||||||
|
kind: MountKind::Shared {
|
||||||
|
share_root_uid: "shared-vol~root".into(),
|
||||||
|
},
|
||||||
|
local_path: "/home/me/Shared".into(),
|
||||||
|
root_uid: "shared-vol~root".into(),
|
||||||
|
root_share_id: "shared-share".into(),
|
||||||
|
mode: MountMode::Unknown,
|
||||||
|
access: MountAccess::Ro,
|
||||||
|
state: "error".into(),
|
||||||
|
last_sync: 0,
|
||||||
|
pending_mode: Some(MountMode::Unknown),
|
||||||
|
mounted: false,
|
||||||
|
progress: Some(SyncProgress {
|
||||||
|
phase: SyncPhase::Applying,
|
||||||
|
done: 2,
|
||||||
|
total: 5,
|
||||||
|
current: "report.pdf".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let wire = serde_json::to_string(&response).unwrap();
|
||||||
|
assert!(!wire.contains('\n'), "wire form must be a single line");
|
||||||
|
assert!(wire.contains(r#""mode":"ondemand""#));
|
||||||
|
assert!(wire.contains(r#""kind":"shared""#));
|
||||||
|
assert!(wire.contains(r#""access":"ro""#));
|
||||||
|
assert!(wire.contains(r#""mode":"unknown""#));
|
||||||
|
let back: Response = serde_json::from_str(&wire).unwrap();
|
||||||
|
match back {
|
||||||
|
Response::Locations { items } => {
|
||||||
|
assert_eq!(items.len(), 3);
|
||||||
|
assert!(matches!(
|
||||||
|
items[1].kind,
|
||||||
|
MountKind::Device { sync_folder_id: 7 }
|
||||||
|
));
|
||||||
|
assert_eq!(items[1].pending_mode, Some(MountMode::OnDemand));
|
||||||
|
assert!(matches!(
|
||||||
|
&items[2].kind,
|
||||||
|
MountKind::Shared { share_root_uid }
|
||||||
|
if share_root_uid == "shared-vol~root"
|
||||||
|
));
|
||||||
|
assert_eq!(items[2].access, MountAccess::Ro);
|
||||||
|
assert_eq!(items[2].mode, MountMode::Unknown);
|
||||||
|
assert_eq!(items[2].pending_mode, Some(MountMode::Unknown));
|
||||||
|
let progress = items[2].progress.as_ref().unwrap();
|
||||||
|
assert_eq!(progress.phase, SyncPhase::Applying);
|
||||||
|
assert_eq!((progress.done, progress.total), (2, 5));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected response: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_daemons_reject_list_locations_cleanly() {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
enum LegacyLocationRequest {
|
||||||
|
ListSyncFolders,
|
||||||
|
}
|
||||||
|
|
||||||
|
let wire = serde_json::to_string(&Request::ListLocations).unwrap();
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<LegacyLocationRequest>(&wire).is_err(),
|
||||||
|
"an old daemon must reject ListLocations instead of interpreting another request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_share_requests_keep_their_existing_wire_shape() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Request::ShareNode {
|
||||||
|
path: "a/b".into(),
|
||||||
|
emails: vec!["x@example.com".into()],
|
||||||
|
role: "viewer".into(),
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
r#"{"ShareNode":{"path":"a/b","emails":["x@example.com"],"role":"viewer","message":null}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::ListShare { path: "a/b".into() },
|
||||||
|
r#"{"ListShare":{"path":"a/b"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::UpdateShareRole {
|
||||||
|
path: "a/b".into(),
|
||||||
|
id: "member".into(),
|
||||||
|
kind: ShareEntryKind::Member,
|
||||||
|
role: "editor".into(),
|
||||||
|
},
|
||||||
|
r#"{"UpdateShareRole":{"path":"a/b","id":"member","kind":"Member","role":"editor"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RemoveShareEntry {
|
||||||
|
path: "a/b".into(),
|
||||||
|
id: "invite".into(),
|
||||||
|
kind: ShareEntryKind::ProtonInvite,
|
||||||
|
},
|
||||||
|
r#"{"RemoveShareEntry":{"path":"a/b","id":"invite","kind":"ProtonInvite"}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::CreatePublicLink {
|
||||||
|
path: "a/b".into(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
password: None,
|
||||||
|
expires: None,
|
||||||
|
},
|
||||||
|
r#"{"CreatePublicLink":{"path":"a/b","role":"viewer","password":null,"expires":null}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RemovePublicLink {
|
||||||
|
path: "a/b".into(),
|
||||||
|
id: "link".into(),
|
||||||
|
},
|
||||||
|
r#"{"RemovePublicLink":{"path":"a/b","id":"link"}}"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (request, expected) in cases {
|
||||||
|
assert_eq!(serde_json::to_string(&request).unwrap(), expected);
|
||||||
|
assert!(
|
||||||
|
!expected.contains("\"uid\""),
|
||||||
|
"path variants must never acquire an optional uid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn by_uid_share_requests_are_distinct_wire_variants() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
Request::ShareNodeByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
emails: vec!["x@example.com".into()],
|
||||||
|
role: "viewer".into(),
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
"ShareNodeByUid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::ListShareByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
},
|
||||||
|
"ListShareByUid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::UpdateShareRoleByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "member".into(),
|
||||||
|
kind: ShareEntryKind::Member,
|
||||||
|
role: "editor".into(),
|
||||||
|
},
|
||||||
|
"UpdateShareRoleByUid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RemoveShareEntryByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "invite".into(),
|
||||||
|
kind: ShareEntryKind::ProtonInvite,
|
||||||
|
},
|
||||||
|
"RemoveShareEntryByUid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::CreatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
password: None,
|
||||||
|
expires: None,
|
||||||
|
},
|
||||||
|
"CreatePublicLinkByUid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Request::RemovePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "link".into(),
|
||||||
|
},
|
||||||
|
"RemovePublicLinkByUid",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (request, variant) in cases {
|
||||||
|
let wire = serde_json::to_string(&request).unwrap();
|
||||||
|
let object = serde_json::from_str::<serde_json::Value>(&wire)
|
||||||
|
.unwrap()
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(object.len(), 1);
|
||||||
|
assert!(object.contains_key(variant));
|
||||||
|
let decoded = serde_json::from_str::<Request>(&wire).unwrap();
|
||||||
|
let decoded_uid = match &decoded {
|
||||||
|
Request::ShareNodeByUid { uid, .. }
|
||||||
|
| Request::ListShareByUid { uid }
|
||||||
|
| Request::UpdateShareRoleByUid { uid, .. }
|
||||||
|
| Request::RemoveShareEntryByUid { uid, .. }
|
||||||
|
| Request::CreatePublicLinkByUid { uid, .. }
|
||||||
|
| Request::RemovePublicLinkByUid { uid, .. } => uid,
|
||||||
|
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||||
|
};
|
||||||
|
assert_eq!(decoded_uid, "vol~link");
|
||||||
|
assert_eq!(serde_json::to_string(&decoded).unwrap(), wire);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_daemons_reject_new_by_uid_variants() {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
enum LegacyShareRequest {
|
||||||
|
ShareNode {
|
||||||
|
path: String,
|
||||||
|
emails: Vec<String>,
|
||||||
|
role: String,
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
ListShare {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
UpdateShareRole {
|
||||||
|
path: String,
|
||||||
|
id: String,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
role: String,
|
||||||
|
},
|
||||||
|
RemoveShareEntry {
|
||||||
|
path: String,
|
||||||
|
id: String,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
},
|
||||||
|
CreatePublicLink {
|
||||||
|
path: String,
|
||||||
|
role: String,
|
||||||
|
password: Option<String>,
|
||||||
|
expires: Option<i64>,
|
||||||
|
},
|
||||||
|
RemovePublicLink {
|
||||||
|
path: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = [
|
||||||
|
Request::ShareNodeByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
emails: Vec::new(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
message: None,
|
||||||
|
},
|
||||||
|
Request::ListShareByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
},
|
||||||
|
Request::UpdateShareRoleByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "member".into(),
|
||||||
|
kind: ShareEntryKind::Member,
|
||||||
|
role: "viewer".into(),
|
||||||
|
},
|
||||||
|
Request::RemoveShareEntryByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "member".into(),
|
||||||
|
kind: ShareEntryKind::Member,
|
||||||
|
},
|
||||||
|
Request::CreatePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
role: "viewer".into(),
|
||||||
|
password: None,
|
||||||
|
expires: None,
|
||||||
|
},
|
||||||
|
Request::RemovePublicLinkByUid {
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
id: "link".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for request in requests {
|
||||||
|
let wire = serde_json::to_string(&request).unwrap();
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<LegacyShareRequest>(&wire).is_err(),
|
||||||
|
"an old daemon must reject {wire}, never reinterpret it as a path request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A daemon built before `kind` existed sends `Error` without the field.
|
/// A daemon built before `kind` existed sends `Error` without the field.
|
||||||
/// It must still parse, and land on the class that promises the least.
|
/// It must still parse, and land on the class that promises the least.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
//! Photo albums: the album listing and each album's contents, persisted like
|
||||||
|
//! the timeline so the Albums view opens from disk instead of re-enumerating the
|
||||||
|
//! photos volume on every launch.
|
||||||
|
//!
|
||||||
|
//! An album's photos are stored per album rather than as a join against
|
||||||
|
//! `photos`: an album shared with us lives on the sharer's volume, so its photos
|
||||||
|
//! never appear in our own timeline and have nowhere else to record what a
|
||||||
|
//! thumbnail attempt learned about them.
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use super::{Db, StoredPhoto, THUMB_UNKNOWN};
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
/// One album of the persisted listing, ordered newest-activity-first in `seq`.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct StoredAlbum {
|
||||||
|
pub uid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub photo_count: usize,
|
||||||
|
/// The photo shown as the album's cover, when the server named one.
|
||||||
|
pub cover_uid: Option<String>,
|
||||||
|
/// Epoch seconds of the last change to the album's contents.
|
||||||
|
pub last_activity: Option<i64>,
|
||||||
|
/// True when the album lives on someone else's photos volume.
|
||||||
|
pub shared: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
/// Replace the album listing wholesale, in the given order.
|
||||||
|
pub fn albums_replace(&self, albums: &[StoredAlbum]) -> Result<()> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
// Contents of an album that no longer exists (unshared, deleted) go with
|
||||||
|
// it — otherwise they would linger forever, keyed by an album uid no
|
||||||
|
// listing mentions again.
|
||||||
|
{
|
||||||
|
let kept: std::collections::HashSet<&str> =
|
||||||
|
albums.iter().map(|a| a.uid.as_str()).collect();
|
||||||
|
let stale: Vec<String> = {
|
||||||
|
let mut stmt = tx.prepare("SELECT DISTINCT album_uid FROM album_photos")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
rows.filter_map(|r| r.ok())
|
||||||
|
.filter(|uid| !kept.contains(uid.as_str()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let mut stmt = tx.prepare("DELETE FROM album_photos WHERE album_uid = ?1")?;
|
||||||
|
for uid in stale {
|
||||||
|
stmt.execute([uid])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.execute("DELETE FROM albums", [])?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"INSERT INTO albums
|
||||||
|
(uid, name, photo_count, cover_uid, last_activity, shared, seq)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
)?;
|
||||||
|
for (seq, album) in albums.iter().enumerate() {
|
||||||
|
stmt.execute(params![
|
||||||
|
album.uid,
|
||||||
|
album.name,
|
||||||
|
album.photo_count as i64,
|
||||||
|
album.cover_uid,
|
||||||
|
album.last_activity,
|
||||||
|
album.shared as i64,
|
||||||
|
seq as i64,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted album listing, in stored order.
|
||||||
|
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||||
|
FROM albums ORDER BY seq",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok(StoredAlbum {
|
||||||
|
uid: r.get(0)?,
|
||||||
|
name: r.get(1)?,
|
||||||
|
photo_count: r.get::<_, i64>(2)?.max(0) as usize,
|
||||||
|
cover_uid: r.get(3)?,
|
||||||
|
last_activity: r.get(4)?,
|
||||||
|
shared: r.get::<_, i64>(5)? != 0,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of albums in the persisted listing.
|
||||||
|
pub fn albums_count(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||||
|
Ok(n.max(0) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace one album's contents, in the given (newest-capture-first) order.
|
||||||
|
/// Learned aspect ratios and thumbnail verdicts survive the replacement, the
|
||||||
|
/// same way [`Db::photos_replace`] keeps them for the timeline.
|
||||||
|
pub fn album_photos_replace(
|
||||||
|
&self,
|
||||||
|
album_uid: &str,
|
||||||
|
items: &[(String, i64, Option<String>, Option<String>)],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT uid, ratio, thumb_state, media_type FROM album_photos
|
||||||
|
WHERE album_uid = ?1",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([album_uid], |r| {
|
||||||
|
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||||
|
})?;
|
||||||
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
|
||||||
|
tx.execute("DELETE FROM album_photos WHERE album_uid = ?1", [album_uid])?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"INSERT INTO album_photos
|
||||||
|
(album_uid, uid, capture_time, name, media_type, kind, ratio, thumb_state, seq)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
)?;
|
||||||
|
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||||
|
let (ratio, thumb_state, learned_media) =
|
||||||
|
learned
|
||||||
|
.get(uid)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||||
|
let media_type = media_type.clone().or(learned_media);
|
||||||
|
let kind =
|
||||||
|
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
||||||
|
stmt.execute(params![
|
||||||
|
album_uid,
|
||||||
|
uid,
|
||||||
|
capture_time,
|
||||||
|
name,
|
||||||
|
media_type,
|
||||||
|
kind.as_i64(),
|
||||||
|
ratio,
|
||||||
|
thumb_state,
|
||||||
|
seq as i64,
|
||||||
|
])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of an album's photos, in stored order.
|
||||||
|
pub fn album_photos_page(
|
||||||
|
&self,
|
||||||
|
album_uid: &str,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
|
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![album_uid, limit as i64, offset as i64], |r| {
|
||||||
|
Ok(StoredPhoto {
|
||||||
|
uid: r.get(0)?,
|
||||||
|
capture_time: r.get(1)?,
|
||||||
|
name: r.get(2)?,
|
||||||
|
ratio: r.get(3)?,
|
||||||
|
thumb_state: r.get(4)?,
|
||||||
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many photos of `album_uid` are persisted.
|
||||||
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let n: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||||
|
[album_uid],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(n.max(0) as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album-held photos among `uids`, one row per uid (a photo in two albums
|
||||||
|
/// is returned once). Backs the thumbnail path for photos that are in an
|
||||||
|
/// album but not in our own timeline — everything in a shared album, and an
|
||||||
|
/// album cover whose photo the current timeline page hasn't reached.
|
||||||
|
pub fn album_photos_by_uid(&self, uids: &[String]) -> Result<Vec<StoredPhoto>> {
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
|
let mut stmt = conn.prepare(&format!(
|
||||||
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
|
WHERE uid IN ({placeholders}) GROUP BY uid"
|
||||||
|
))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||||
|
Ok(StoredPhoto {
|
||||||
|
uid: r.get(0)?,
|
||||||
|
capture_time: r.get(1)?,
|
||||||
|
name: r.get(2)?,
|
||||||
|
ratio: r.get(3)?,
|
||||||
|
thumb_state: r.get(4)?,
|
||||||
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a thumbnail verdict against every album row for `uid`, so what one
|
||||||
|
/// attempt learned is not re-learned per album the photo sits in. The
|
||||||
|
/// timeline's own copy is updated separately by [`Db::photo_set_thumb`].
|
||||||
|
pub fn album_photo_set_thumb(&self, uid: &str, state: i64, ratio: Option<f64>) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE album_photos SET thumb_state = ?2, ratio = COALESCE(?3, ratio) WHERE uid = ?1",
|
||||||
|
params![uid, state, ratio],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,10 +77,12 @@ const REPORTED_TABLES: &[&str] = &[
|
|||||||
"trash",
|
"trash",
|
||||||
"device",
|
"device",
|
||||||
"sync_folder",
|
"sync_folder",
|
||||||
|
"mount",
|
||||||
"sync_entry",
|
"sync_entry",
|
||||||
"pending_op",
|
"pending_op",
|
||||||
"activity",
|
"activity",
|
||||||
"local_files",
|
"local_files",
|
||||||
|
"share_access",
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 16;
|
pub(super) const SCHEMA_VERSION: i64 = 19;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -89,6 +89,15 @@ impl Db {
|
|||||||
if current < 16 {
|
if current < 16 {
|
||||||
tx.execute_batch(MIGRATION_V16)?;
|
tx.execute_batch(MIGRATION_V16)?;
|
||||||
}
|
}
|
||||||
|
if current < 17 {
|
||||||
|
tx.execute_batch(MIGRATION_V17)?;
|
||||||
|
}
|
||||||
|
if current < 18 {
|
||||||
|
tx.execute_batch(MIGRATION_V18)?;
|
||||||
|
}
|
||||||
|
if current < 19 {
|
||||||
|
tx.execute_batch(MIGRATION_V19)?;
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -411,3 +420,94 @@ CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
|
|||||||
ON local_files(name COLLATE NOCASE);
|
ON local_files(name COLLATE NOCASE);
|
||||||
INSERT INTO local_fts(local_fts) VALUES('rebuild');
|
INSERT INTO local_fts(local_fts) VALUES('rebuild');
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v17: effective access for roots shared with this account. The role is
|
||||||
|
/// persisted separately from `nodes.node_json` so a cold/offline mount can
|
||||||
|
/// enforce the last known permission even when the SDK cannot refresh metadata.
|
||||||
|
const MIGRATION_V17: &str = "
|
||||||
|
CREATE TABLE share_access (
|
||||||
|
root_uid TEXT PRIMARY KEY,
|
||||||
|
access TEXT NOT NULL
|
||||||
|
CHECK (access IN ('owner', 'editor', 'viewer', 'unknown'))
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v18: unified location presentation. Device rows carry only their
|
||||||
|
/// `sync_folder` identity; reads join all device state from that authoritative
|
||||||
|
/// table. The trigger projects future folders, while the foreign key removes a
|
||||||
|
/// presentation row with its sync folder.
|
||||||
|
const MIGRATION_V18: &str = "
|
||||||
|
CREATE TABLE mount (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL
|
||||||
|
CHECK (kind IN ('myfiles', 'device', 'shared')),
|
||||||
|
sync_folder_id INTEGER REFERENCES sync_folder(id) ON DELETE CASCADE,
|
||||||
|
share_root_uid TEXT,
|
||||||
|
local_path TEXT NOT NULL DEFAULT '',
|
||||||
|
root_uid TEXT NOT NULL DEFAULT '',
|
||||||
|
root_share_id TEXT NOT NULL DEFAULT '',
|
||||||
|
mode TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
access TEXT NOT NULL DEFAULT 'rw'
|
||||||
|
CHECK (access IN ('rw', 'ro')),
|
||||||
|
CHECK (
|
||||||
|
(kind = 'myfiles' AND sync_folder_id IS NULL AND share_root_uid IS NULL)
|
||||||
|
OR
|
||||||
|
(kind = 'device' AND sync_folder_id IS NOT NULL AND share_root_uid IS NULL)
|
||||||
|
OR
|
||||||
|
(kind = 'shared' AND sync_folder_id IS NULL AND share_root_uid IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX mount_myfiles
|
||||||
|
ON mount(kind) WHERE kind = 'myfiles';
|
||||||
|
CREATE UNIQUE INDEX mount_device
|
||||||
|
ON mount(sync_folder_id) WHERE kind = 'device';
|
||||||
|
CREATE UNIQUE INDEX mount_shared
|
||||||
|
ON mount(share_root_uid) WHERE kind = 'shared';
|
||||||
|
|
||||||
|
INSERT INTO mount (kind, sync_folder_id)
|
||||||
|
SELECT 'device', id FROM sync_folder;
|
||||||
|
|
||||||
|
CREATE TRIGGER mount_sync_folder_insert
|
||||||
|
AFTER INSERT ON sync_folder
|
||||||
|
BEGIN
|
||||||
|
INSERT OR IGNORE INTO mount (kind, sync_folder_id)
|
||||||
|
VALUES ('device', NEW.id);
|
||||||
|
END;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v19: photo albums and their contents. Both are projections of the
|
||||||
|
/// server's listings — like `photos`, they are replaced wholesale on refresh —
|
||||||
|
/// so the Albums view opens from disk instead of re-enumerating every launch.
|
||||||
|
///
|
||||||
|
/// `album_photos` carries its own `ratio` / `thumb_state` rather than joining
|
||||||
|
/// `photos`: an album shared with us lives on the sharer's volume, so its
|
||||||
|
/// photos are not in our timeline at all and would otherwise have nowhere to
|
||||||
|
/// remember what a thumbnail attempt learned.
|
||||||
|
const MIGRATION_V19: &str = "
|
||||||
|
CREATE TABLE albums (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
photo_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cover_uid TEXT,
|
||||||
|
last_activity INTEGER,
|
||||||
|
shared INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seq INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE album_photos (
|
||||||
|
album_uid TEXT NOT NULL,
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
capture_time INTEGER NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
media_type TEXT,
|
||||||
|
kind INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ratio REAL,
|
||||||
|
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seq INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (album_uid, uid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||||
|
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||||
|
";
|
||||||
@@ -22,28 +22,32 @@ use crate::Result;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
|
mod albums;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod devices;
|
mod devices;
|
||||||
mod local;
|
mod local;
|
||||||
mod maintenance;
|
mod maintenance;
|
||||||
mod migrations;
|
mod migrations;
|
||||||
|
mod mounts;
|
||||||
mod nodes;
|
mod nodes;
|
||||||
mod ops;
|
mod ops;
|
||||||
mod photos;
|
mod photos;
|
||||||
mod pins;
|
mod pins;
|
||||||
|
mod share_access;
|
||||||
mod state;
|
mod state;
|
||||||
mod sync;
|
mod sync;
|
||||||
mod trash;
|
mod trash;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
pub use albums::StoredAlbum;
|
||||||
pub use cache::CacheEntryInput;
|
pub use cache::CacheEntryInput;
|
||||||
pub use devices::StoredDevice;
|
pub use devices::StoredDevice;
|
||||||
pub use local::LocalFileHit;
|
pub use local::LocalFileHit;
|
||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{SearchHit, StoredNode};
|
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
|
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||||
PendingCounts, PendingOp, op_supersedes,
|
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
@@ -109,6 +113,10 @@ impl Db {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub fn open_in_memory() -> Result<Self> {
|
pub fn open_in_memory() -> Result<Self> {
|
||||||
let conn = Connection::open_in_memory()?;
|
let conn = Connection::open_in_memory()?;
|
||||||
|
// Keep relational behavior identical to `open`: V18 relies on the
|
||||||
|
// `mount.sync_folder_id` cascade, and SQLite disables foreign keys per
|
||||||
|
// connection unless explicitly enabled.
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
//! Unified mount/location presentation rows.
|
||||||
|
//!
|
||||||
|
//! A device row stores only its `sync_folder` identity. Its path, remote root,
|
||||||
|
//! mode, state, pending mode, and last-sync timestamp are selected from
|
||||||
|
//! `sync_folder`, which remains the authoritative sync-engine table.
|
||||||
|
|
||||||
|
use rusqlite::params;
|
||||||
|
|
||||||
|
use super::Db;
|
||||||
|
use crate::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
/// Project the configured primary mount into the presentation table.
|
||||||
|
///
|
||||||
|
/// `root_share_id` is optional because an offline startup may only have the
|
||||||
|
/// cached root node. A previously learned share id is retained only when it
|
||||||
|
/// belongs to the same root uid.
|
||||||
|
pub fn mount_upsert_my_files(
|
||||||
|
&self,
|
||||||
|
local_path: &str,
|
||||||
|
root_uid: &str,
|
||||||
|
root_share_id: Option<&str>,
|
||||||
|
) -> Result<i64> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let updated = tx.execute(
|
||||||
|
"UPDATE mount
|
||||||
|
SET local_path = ?1,
|
||||||
|
root_share_id = CASE
|
||||||
|
WHEN root_uid = ?2
|
||||||
|
THEN COALESCE(NULLIF(?3, ''), root_share_id)
|
||||||
|
ELSE COALESCE(?3, '')
|
||||||
|
END,
|
||||||
|
root_uid = ?2,
|
||||||
|
mode = 'ondemand',
|
||||||
|
access = 'rw'
|
||||||
|
WHERE kind = 'myfiles'",
|
||||||
|
params![local_path, root_uid, root_share_id],
|
||||||
|
)?;
|
||||||
|
let id = if updated == 0 {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO mount
|
||||||
|
(kind, local_path, root_uid, root_share_id, mode, access)
|
||||||
|
VALUES ('myfiles', ?1, ?2, COALESCE(?3, ''), 'ondemand', 'rw')",
|
||||||
|
params![local_path, root_uid, root_share_id],
|
||||||
|
)?;
|
||||||
|
tx.last_insert_rowid()
|
||||||
|
} else {
|
||||||
|
tx.query_row("SELECT id FROM mount WHERE kind = 'myfiles'", [], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a share id learned asynchronously, but only if the projection still
|
||||||
|
/// names the root it was resolved for.
|
||||||
|
pub fn mount_repair_my_files_share_id(
|
||||||
|
&self,
|
||||||
|
root_uid: &str,
|
||||||
|
root_share_id: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE mount
|
||||||
|
SET root_share_id = ?2
|
||||||
|
WHERE kind = 'myfiles'
|
||||||
|
AND root_uid = ?1",
|
||||||
|
params![root_uid, root_share_id],
|
||||||
|
)? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every local location, with device fields joined from `sync_folder`.
|
||||||
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT
|
||||||
|
m.id,
|
||||||
|
m.kind,
|
||||||
|
m.sync_folder_id,
|
||||||
|
m.share_root_uid,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.local_path ELSE m.local_path END,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.remote_uid ELSE m.root_uid END,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.remote_share_id ELSE m.root_share_id END,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.mode ELSE m.mode END,
|
||||||
|
m.access,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.state ELSE 'idle' END,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.last_sync ELSE 0 END,
|
||||||
|
CASE WHEN m.kind = 'device' THEN sf.pending_mode ELSE NULL END
|
||||||
|
FROM mount AS m
|
||||||
|
LEFT JOIN sync_folder AS sf ON sf.id = m.sync_folder_id
|
||||||
|
ORDER BY CASE m.kind
|
||||||
|
WHEN 'myfiles' THEN 0
|
||||||
|
WHEN 'device' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END,
|
||||||
|
m.id",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<i64>>(2)?,
|
||||||
|
row.get::<_, Option<String>>(3)?,
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
row.get::<_, String>(5)?,
|
||||||
|
row.get::<_, String>(6)?,
|
||||||
|
row.get::<_, String>(7)?,
|
||||||
|
row.get::<_, String>(8)?,
|
||||||
|
row.get::<_, String>(9)?,
|
||||||
|
row.get::<_, i64>(10)?,
|
||||||
|
row.get::<_, Option<String>>(11)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut mounts = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
sync_folder_id,
|
||||||
|
share_root_uid,
|
||||||
|
local_path,
|
||||||
|
root_uid,
|
||||||
|
root_share_id,
|
||||||
|
mode,
|
||||||
|
access,
|
||||||
|
state,
|
||||||
|
last_sync,
|
||||||
|
pending_mode,
|
||||||
|
) = row?;
|
||||||
|
let kind = match kind.as_str() {
|
||||||
|
"myfiles" => MountKind::MyFiles,
|
||||||
|
"device" => MountKind::Device {
|
||||||
|
sync_folder_id: sync_folder_id.ok_or_else(|| {
|
||||||
|
Error::Other(format!("mount row {id} has no sync_folder_id"))
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
"shared" => MountKind::Shared {
|
||||||
|
share_root_uid: share_root_uid.ok_or_else(|| {
|
||||||
|
Error::Other(format!("mount row {id} has no share_root_uid"))
|
||||||
|
})?,
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
return Err(Error::Other(format!(
|
||||||
|
"mount row {id} has unknown kind {other:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let access = match access.as_str() {
|
||||||
|
"rw" => MountAccess::Rw,
|
||||||
|
"ro" => MountAccess::Ro,
|
||||||
|
other => {
|
||||||
|
return Err(Error::Other(format!(
|
||||||
|
"mount row {id} has unknown access {other:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mounts.push(MountSpec {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
local_path,
|
||||||
|
root_uid,
|
||||||
|
root_share_id,
|
||||||
|
mode: MountMode::from(mode.as_str()),
|
||||||
|
access,
|
||||||
|
state,
|
||||||
|
last_sync,
|
||||||
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
|
mounted: false,
|
||||||
|
progress: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(mounts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
//! Node rows: the persisted mirror of the remote tree, plus the trigram
|
//! Node rows: the persisted mirror of the remote tree, plus the trigram
|
||||||
//! full-text index over names and paths that backs `Request::Search`.
|
//! full-text index over names and paths that backs `Request::Search`.
|
||||||
|
|
||||||
use rusqlite::{OptionalExtension, params};
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use rusqlite::{OptionalExtension, Transaction, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::{Access, Result};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{Node, NodeKind};
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
|
|
||||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
||||||
@@ -22,6 +24,11 @@ pub struct SearchHit {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PublishedSharedRoot {
|
||||||
|
pub node: Node,
|
||||||
|
pub access: Access,
|
||||||
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
pub fn upsert_node(&self, node: &Node) -> Result<()> {
|
||||||
self.upsert_nodes(std::slice::from_ref(node))
|
self.upsert_nodes(std::slice::from_ref(node))
|
||||||
@@ -42,87 +49,188 @@ impl Db {
|
|||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
let json = serde_json::to_string(node)?;
|
upsert_node_tx(&tx, node)?;
|
||||||
let uid = node.uid.to_string();
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO nodes
|
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
|
||||||
parent_uid = excluded.parent_uid,
|
|
||||||
name = excluded.name,
|
|
||||||
is_dir = excluded.is_dir,
|
|
||||||
size = excluded.size,
|
|
||||||
mtime = excluded.mtime,
|
|
||||||
trashed = excluded.trashed,
|
|
||||||
node_json = excluded.node_json",
|
|
||||||
params![
|
|
||||||
uid,
|
|
||||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
|
||||||
node.name,
|
|
||||||
node.is_folder() as i64,
|
|
||||||
node_size(node),
|
|
||||||
node.modification_time,
|
|
||||||
node.trashed as i64,
|
|
||||||
json,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
// FTS5 has no UPSERT, so refresh the row by delete-then-insert.
|
|
||||||
// Trashed nodes are kept out of the index entirely so they never
|
|
||||||
// surface in search results.
|
|
||||||
//
|
|
||||||
// Keyed by the node's rowid, never by its uid: the uid is not an
|
|
||||||
// indexed FTS column, so deleting by it scans the entire index once
|
|
||||||
// per node written (B12). The rowid is stable across the upsert
|
|
||||||
// above — `ON CONFLICT DO UPDATE` keeps the existing row — so it is
|
|
||||||
// the same key before and after.
|
|
||||||
let rowid: i64 = tx.query_row(
|
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
|
||||||
params![uid],
|
|
||||||
|row| row.get(0),
|
|
||||||
)?;
|
|
||||||
// A folder rename/move changes every descendant's searchable path.
|
|
||||||
// Refresh the affected subtree, while retaining efficient rowid
|
|
||||||
// deletes for the overwhelmingly common one-file case.
|
|
||||||
let mut affected = vec![rowid];
|
|
||||||
if node.is_folder() {
|
|
||||||
let mut stmt = tx.prepare(
|
|
||||||
"WITH RECURSIVE descendants(rowid, uid) AS (
|
|
||||||
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
|
||||||
UNION ALL
|
|
||||||
SELECT n.rowid, n.uid FROM nodes n
|
|
||||||
JOIN descendants d ON n.parent_uid = d.uid
|
|
||||||
) SELECT rowid FROM descendants",
|
|
||||||
)?;
|
|
||||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
|
||||||
affected.push(descendant?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for affected_rowid in affected {
|
|
||||||
tx.execute(
|
|
||||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
|
||||||
params![affected_rowid],
|
|
||||||
)?;
|
|
||||||
let indexed: Option<(String, String)> = tx
|
|
||||||
.query_row(
|
|
||||||
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
|
|
||||||
params![affected_rowid],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.optional()?;
|
|
||||||
if let Some((name, indexed_uid)) = indexed {
|
|
||||||
let path = path_of(&tx, &indexed_uid)?;
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
|
||||||
params![affected_rowid, name, path],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return visible direct children without consulting the parent's `listed`
|
||||||
|
/// flag. Synthetic shared listings use this to serve the last completed
|
||||||
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT node_json FROM nodes
|
||||||
|
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||||
|
ORDER BY name, uid",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([parent.to_string()], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
nodes.push(serde_json::from_str(&row?)?);
|
||||||
|
}
|
||||||
|
Ok(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically publish one completed `Shared with me` root listing.
|
||||||
|
///
|
||||||
|
/// Roots absent from `accepted` and every persisted descendant are
|
||||||
|
/// tombstoned, not deleted. Accepted roots omitted from materialization keep
|
||||||
|
/// their visible snapshot but are downgraded to Viewer until membership is
|
||||||
|
/// verified again. Both cases preserve queued operations and staged writes.
|
||||||
|
pub fn publish_shared_roots(
|
||||||
|
&self,
|
||||||
|
parent: &NodeUid,
|
||||||
|
accepted: &[NodeUid],
|
||||||
|
roots: &[PublishedSharedRoot],
|
||||||
|
) -> Result<Vec<NodeUid>> {
|
||||||
|
let parent = parent.to_string();
|
||||||
|
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||||
|
let materialized: HashSet<String> = roots
|
||||||
|
.iter()
|
||||||
|
.map(|published| published.node.uid.to_string())
|
||||||
|
.collect();
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
|
||||||
|
let existing = direct_child_uids_tx(&tx, &parent)?;
|
||||||
|
let removed: Vec<String> = existing
|
||||||
|
.into_iter()
|
||||||
|
.filter(|uid| !present.contains(uid))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for published in roots {
|
||||||
|
if !present.contains(&published.node.uid.to_string()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
upsert_node_tx(&tx, &published.node)?;
|
||||||
|
upsert_share_access_tx(&tx, &published.node.uid.to_string(), published.access)?;
|
||||||
|
}
|
||||||
|
for uid in present.difference(&materialized) {
|
||||||
|
upsert_share_access_tx(&tx, uid, Access::Viewer)?;
|
||||||
|
}
|
||||||
|
for root in &removed {
|
||||||
|
tombstone_subtree_tx(&tx, root)?;
|
||||||
|
upsert_share_access_tx(&tx, root, Access::Viewer)?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||||
|
params![parent],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
|
||||||
|
Ok(removed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|uid| parse_node_uid(&uid))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically withdraw a deleted foreign subtree and deny stale handles.
|
||||||
|
///
|
||||||
|
/// Node rows and queued operations remain so retries and staged writes keep
|
||||||
|
/// their references. FTS and completed-listing state are withdrawn for the
|
||||||
|
/// whole subtree, and the deleted UID becomes a fail-closed authority.
|
||||||
|
pub fn tombstone_foreign_subtree(&self, uid: &NodeUid) -> Result<()> {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
tombstone_subtree_tx(&tx, &uid)?;
|
||||||
|
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a completed foreign-folder listing from its authoritative UID
|
||||||
|
/// list and the subset that materialized successfully. Accepted-but-omitted
|
||||||
|
/// children retain their previous snapshot; only absent UIDs are tombstoned.
|
||||||
|
pub fn publish_foreign_children(
|
||||||
|
&self,
|
||||||
|
parent: &NodeUid,
|
||||||
|
accepted: &[NodeUid],
|
||||||
|
materialized: &[Node],
|
||||||
|
) -> Result<Vec<NodeUid>> {
|
||||||
|
let parent = parent.to_string();
|
||||||
|
let present: HashSet<String> = accepted.iter().map(NodeUid::to_string).collect();
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let removed: Vec<String> = direct_child_uids_tx(&tx, &parent)?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|uid| !present.contains(uid))
|
||||||
|
.collect();
|
||||||
|
for node in materialized {
|
||||||
|
if present.contains(&node.uid.to_string()) {
|
||||||
|
upsert_node_tx(&tx, node)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for uid in &removed {
|
||||||
|
tombstone_subtree_tx(&tx, uid)?;
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET listed = 1 WHERE uid = ?1",
|
||||||
|
params![parent],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(removed
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|uid| parse_node_uid(&uid))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically publish the synthetic root's pinned name, node/FTS state, and
|
||||||
|
/// Viewer authority.
|
||||||
|
///
|
||||||
|
/// The pin is inserted last so any failure there rolls back the node and
|
||||||
|
/// its descendant FTS updates as well as the access row. Returning `false`
|
||||||
|
/// guarantees no write occurred, which keeps cached root lookups O(1).
|
||||||
|
pub fn publish_virtual_root(&self, pinned_name_key: &str, node: &Node) -> Result<bool> {
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
let desired_parent = node.parent_uid.as_ref().map(NodeUid::to_string);
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let pinned_name: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
|
params![pinned_name_key],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let stored: Option<(Option<String>, String, i64, i64)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT parent_uid, name, is_dir, trashed FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let access: Option<String> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let unchanged = stored.is_some_and(|(parent, name, is_dir, trashed)| {
|
||||||
|
parent == desired_parent
|
||||||
|
&& name == node.name
|
||||||
|
&& is_dir == 1
|
||||||
|
&& trashed == node.trashed as i64
|
||||||
|
}) && access.as_deref() == Some(Access::Viewer.as_db_str())
|
||||||
|
&& pinned_name.is_some();
|
||||||
|
if unchanged {
|
||||||
|
tx.commit()?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
upsert_share_access_tx(&tx, &uid, Access::Viewer)?;
|
||||||
|
upsert_node_tx(&tx, node)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO sync_state (key, value) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(key) DO NOTHING",
|
||||||
|
params![pinned_name_key, node.name],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a node row (delete or trash from the hot cache). Children rows are
|
/// Drop a node row (delete or trash from the hot cache). Children rows are
|
||||||
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
|
/// not cascaded here; the daemon forgets a whole subtree node-by-node.
|
||||||
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
|
pub fn delete_node(&self, uid: &NodeUid) -> Result<()> {
|
||||||
@@ -148,6 +256,32 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically retire a landed trash operation and its retained authority.
|
||||||
|
///
|
||||||
|
/// Queued trash keeps the node row so drain-time permission checks can
|
||||||
|
/// resolve its shared-tree access. Removing the op first could resurrect
|
||||||
|
/// that row after a crash; removing the row first could strand a now
|
||||||
|
/// unauthorizable op. One transaction closes both windows.
|
||||||
|
pub fn complete_trash_op(&self, op_id: i64, uid: &NodeUid) -> Result<()> {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let rowid: Option<i64> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
tx.execute("DELETE FROM nodes WHERE uid = ?1", params![uid])?;
|
||||||
|
if let Some(rowid) = rowid {
|
||||||
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
|
}
|
||||||
|
tx.execute("DELETE FROM pending_op WHERE id = ?1", params![op_id])?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a folder node has any non-trashed children in the database.
|
/// Check if a folder node has any non-trashed children in the database.
|
||||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||||
let uid_str = parent_uid.to_string();
|
let uid_str = parent_uid.to_string();
|
||||||
@@ -387,6 +521,168 @@ impl Db {
|
|||||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
|
let json = serde_json::to_string(node)?;
|
||||||
|
let uid = node.uid.to_string();
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO nodes
|
||||||
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
parent_uid = excluded.parent_uid,
|
||||||
|
name = excluded.name,
|
||||||
|
is_dir = excluded.is_dir,
|
||||||
|
size = excluded.size,
|
||||||
|
mtime = excluded.mtime,
|
||||||
|
trashed = excluded.trashed,
|
||||||
|
node_json = excluded.node_json",
|
||||||
|
params![
|
||||||
|
uid,
|
||||||
|
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||||
|
node.name,
|
||||||
|
node.is_folder() as i64,
|
||||||
|
node_size(node),
|
||||||
|
node.modification_time,
|
||||||
|
node.trashed as i64,
|
||||||
|
json,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
|
||||||
|
// refresh the whole affected subtree while keeping rowid deletes indexed.
|
||||||
|
let rowid: i64 = tx.query_row(
|
||||||
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
let mut affected = vec![rowid];
|
||||||
|
if node.is_folder() {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"WITH RECURSIVE descendants(rowid, uid) AS (
|
||||||
|
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.rowid, n.uid FROM nodes n
|
||||||
|
JOIN descendants d ON n.parent_uid = d.uid
|
||||||
|
) SELECT rowid FROM descendants",
|
||||||
|
)?;
|
||||||
|
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
||||||
|
affected.push(descendant?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for affected_rowid in affected {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
||||||
|
params![affected_rowid],
|
||||||
|
)?;
|
||||||
|
let indexed: Option<(String, String)> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
||||||
|
params![affected_rowid],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some((name, indexed_uid)) = indexed
|
||||||
|
&& node_is_indexable_tx(tx, &indexed_uid)?
|
||||||
|
{
|
||||||
|
let path = path_of(tx, &indexed_uid)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||||
|
params![affected_rowid, name, path],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String>> {
|
||||||
|
let mut stmt = tx.prepare("SELECT uid FROM nodes WHERE parent_uid = ?1")?;
|
||||||
|
let rows = stmt.query_map([parent], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
uids.push(row?);
|
||||||
|
}
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||||
|
let indexable: i64 = tx.query_row(
|
||||||
|
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
|
||||||
|
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
|
||||||
|
FROM nodes WHERE uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.uid, n.parent_uid, n.trashed,
|
||||||
|
a.path || n.uid || char(31)
|
||||||
|
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||||
|
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||||
|
)
|
||||||
|
SELECT CASE
|
||||||
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
|
WHEN MAX(trashed) != 0 THEN 0
|
||||||
|
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
|
||||||
|
ELSE 1
|
||||||
|
END
|
||||||
|
FROM ancestors",
|
||||||
|
[uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(indexable == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tombstone_subtree_tx(tx: &Transaction<'_>, root_uid: &str) -> Result<()> {
|
||||||
|
let rows = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"WITH RECURSIVE subtree(rowid, uid, node_json) AS (
|
||||||
|
SELECT rowid, uid, node_json FROM nodes WHERE uid = ?1
|
||||||
|
UNION
|
||||||
|
SELECT n.rowid, n.uid, n.node_json FROM nodes n
|
||||||
|
JOIN subtree s ON n.parent_uid = s.uid
|
||||||
|
)
|
||||||
|
SELECT rowid, uid, node_json FROM subtree",
|
||||||
|
)?;
|
||||||
|
let mapped = stmt.query_map([root_uid], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for row in mapped {
|
||||||
|
rows.push(row?);
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
for (rowid, uid, json) in rows {
|
||||||
|
let json = match json {
|
||||||
|
Some(json) => {
|
||||||
|
let mut node: Node = serde_json::from_str(&json)?;
|
||||||
|
node.trashed = true;
|
||||||
|
Some(serde_json::to_string(&node)?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE nodes SET trashed = 1, listed = 0, node_json = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, json],
|
||||||
|
)?;
|
||||||
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", [rowid])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upsert_share_access_tx(tx: &Transaction<'_>, uid: &str, access: Access) -> Result<()> {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||||
|
params![uid, access.as_db_str()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_node_uid(value: &str) -> Option<NodeUid> {
|
||||||
|
let (volume, link) = value.split_once('~')?;
|
||||||
|
Some(NodeUid::new(VolumeId::from(volume), LinkId::from(link)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
|
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
|
||||||
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
|
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
|
||||||
let chars: Vec<char> = query.to_lowercase().chars().collect();
|
let chars: Vec<char> = query.to_lowercase().chars().collect();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! order, so a child never drains before the parent that gives it a real uid.
|
//! order, so a child never drains before the parent that gives it a real uid.
|
||||||
|
|
||||||
use rusqlite::{OptionalExtension, params};
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -35,6 +36,16 @@ pub const OP_MKDIR: &str = "mkdir";
|
|||||||
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
/// that folder's own drain, exactly as for [`OP_CREATE`].
|
||||||
pub const OP_RENAME: &str = "rename";
|
pub const OP_RENAME: &str = "rename";
|
||||||
|
|
||||||
|
/// Drain-time authority retained by a queued rename.
|
||||||
|
///
|
||||||
|
/// The optimistic local move rewrites the node's persisted ancestry to the
|
||||||
|
/// destination immediately. Keeping the original parent here prevents that
|
||||||
|
/// rewrite from erasing the shared tree that admitted the move.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RenameMeta {
|
||||||
|
pub original_parent_uid: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
/// The `kind` of a [`PendingOp`] that trashes a node the server knows about
|
||||||
/// (offline.md Phase 3b).
|
/// (offline.md Phase 3b).
|
||||||
///
|
///
|
||||||
@@ -63,6 +74,16 @@ pub fn op_supersedes(kind: &str) -> bool {
|
|||||||
/// drain replaces it with the uid the server assigns.
|
/// drain replaces it with the uid the server assigns.
|
||||||
pub const LOCAL_VOLUME: &str = "local";
|
pub const LOCAL_VOLUME: &str = "local";
|
||||||
|
|
||||||
|
/// A `next_attempt_at` far enough in the future that [`Db::next_due_op`] never
|
||||||
|
/// selects the op (ms since epoch, ≈ year 2223). Used to *park* a queued create
|
||||||
|
/// for a transient file — a browser's in-flight `*.crdownload`/`*.part`, an
|
||||||
|
/// editor's `*.swp` — so its bytes never upload while it wears that name. A
|
||||||
|
/// rename to the finished name un-parks it (see [`Db::set_create_hold`]), which
|
||||||
|
/// is the only moment the completed file is meant to reach Drive. Without the
|
||||||
|
/// park, every partial and every abandoned temp would upload and, on a
|
||||||
|
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||||
|
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// 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 (offline.md Phase 3).
|
||||||
///
|
///
|
||||||
@@ -117,6 +138,32 @@ pub struct AttachedBlob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
|
/// Whether a specific desired-state operation is still queued for a node.
|
||||||
|
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||||
|
)",
|
||||||
|
params![uid, kind],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read an existing operation's metadata before a superseding enqueue.
|
||||||
|
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||||
|
params![uid, kind],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map(|value| value.flatten())
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
pub fn enqueue_op(&self, op: &PendingOp) -> Result<(i64, Option<String>)> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
@@ -157,6 +204,50 @@ impl Db {
|
|||||||
Ok((id, superseded))
|
Ok((id, superseded))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically replace every queued operation for `uid` and its queued
|
||||||
|
/// descendants with one remote trash intent.
|
||||||
|
///
|
||||||
|
/// Staged blob paths are returned only after the transaction commits. A
|
||||||
|
/// failed trash insert therefore rolls the deletions back and leaves every
|
||||||
|
/// prior operation owning its bytes.
|
||||||
|
pub fn replace_ops_with_trash(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
name: &str,
|
||||||
|
created_at: i64,
|
||||||
|
) -> Result<(i64, Vec<String>)> {
|
||||||
|
const SUBTREE: &str = "
|
||||||
|
WITH RECURSIVE doomed(uid) AS (
|
||||||
|
SELECT ?1
|
||||||
|
UNION
|
||||||
|
SELECT p.uid FROM pending_op p JOIN doomed d ON p.parent_uid = d.uid
|
||||||
|
)";
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let blobs: Vec<String> = {
|
||||||
|
let mut stmt = tx.prepare(&format!(
|
||||||
|
"{SUBTREE}
|
||||||
|
SELECT blob_path FROM pending_op
|
||||||
|
WHERE uid IN (SELECT uid FROM doomed) AND blob_path IS NOT NULL"
|
||||||
|
))?;
|
||||||
|
let rows = stmt.query_map(params![uid], |row| row.get::<_, String>(0))?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
&format!("{SUBTREE} DELETE FROM pending_op WHERE uid IN (SELECT uid FROM doomed)"),
|
||||||
|
params![uid],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO pending_op
|
||||||
|
(kind, uid, parent_uid, name, blob_path, meta_json, created_at, next_attempt_at)
|
||||||
|
VALUES (?1, ?2, NULL, ?3, NULL, NULL, ?4, 0)",
|
||||||
|
params![OP_TRASH, uid, name, created_at],
|
||||||
|
)?;
|
||||||
|
let id = tx.last_insert_rowid();
|
||||||
|
tx.commit()?;
|
||||||
|
Ok((id, blobs))
|
||||||
|
}
|
||||||
|
|
||||||
/// Point a queued create at the bytes that were just written to it, returning
|
/// Point a queued create at the bytes that were just written to it, returning
|
||||||
/// any blob it previously held so the caller can discard it.
|
/// any blob it previously held so the caller can discard it.
|
||||||
///
|
///
|
||||||
@@ -183,11 +274,18 @@ impl Db {
|
|||||||
let Some((id, superseded)) = existing else {
|
let Some((id, superseded)) = existing else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
// Clearing the backoff lets the fresh bytes be tried promptly — except
|
||||||
|
// when the create is *parked* (a transient file, next_attempt_at at the
|
||||||
|
// PARK_UNTIL sentinel). Attaching a new revision to a still-growing
|
||||||
|
// download must not wake it; only the finalize rename does. So preserve a
|
||||||
|
// park and reset an ordinary backoff.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pending_op
|
"UPDATE pending_op
|
||||||
SET blob_path = ?2, meta_json = ?3, attempts = 0, next_attempt_at = 0
|
SET blob_path = ?2, meta_json = ?3, attempts = 0,
|
||||||
|
next_attempt_at = CASE WHEN next_attempt_at >= ?4
|
||||||
|
THEN next_attempt_at ELSE 0 END
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![id, blob_path, meta_json],
|
params![id, blob_path, meta_json, PARK_UNTIL],
|
||||||
)?;
|
)?;
|
||||||
Ok(Some(AttachedBlob { id, superseded }))
|
Ok(Some(AttachedBlob { id, superseded }))
|
||||||
}
|
}
|
||||||
@@ -233,6 +331,25 @@ impl Db {
|
|||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Park or un-park a queued create/mkdir for `uid`, returning whether a row
|
||||||
|
/// was touched.
|
||||||
|
///
|
||||||
|
/// Parking sets `next_attempt_at` to [`PARK_UNTIL`] so [`Db::next_due_op`]
|
||||||
|
/// skips it indefinitely; un-parking sets it due now. Used to keep a
|
||||||
|
/// transient file's bytes off Drive until it is renamed to its finished name
|
||||||
|
/// (docs/BUGS.md B70). Un-parking a create that was never parked is harmless:
|
||||||
|
/// it just makes the create due immediately, which for a normal local node it
|
||||||
|
/// already is.
|
||||||
|
pub fn set_create_hold(&self, uid: &str, held: bool) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE uid = ?1 AND kind IN (?3, ?4)",
|
||||||
|
params![uid, if held { PARK_UNTIL } else { 0 }, OP_CREATE, OP_MKDIR],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if a queued create or mkdir op exists for `uid`.
|
/// Check if a queued create or mkdir op exists for `uid`.
|
||||||
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -244,6 +361,22 @@ impl Db {
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether *any* op of any kind is queued against `uid`.
|
||||||
|
///
|
||||||
|
/// Used as a safety interlock by callers that are about to remove a node
|
||||||
|
/// they only know about from a stale snapshot: a queued op means the user's
|
||||||
|
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||||
|
/// blob that holds them (`docs/BUGS.md` B71).
|
||||||
|
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop every op targeting a node **or anything queued beneath it**,
|
/// Drop every op targeting a node **or anything queued beneath it**,
|
||||||
/// returning the staged blobs they held so the caller can delete them.
|
/// returning the staged blobs they held so the caller can delete them.
|
||||||
///
|
///
|
||||||
@@ -457,4 +590,23 @@ impl Db {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defer a due operation without recording a failed remote attempt.
|
||||||
|
///
|
||||||
|
/// Access checks happen before the drain calls Proton, so a locally denied
|
||||||
|
/// operation has not failed remotely and must not consume a retry. Preserve
|
||||||
|
/// the long-lived transient-create sentinel if this is ever called for one.
|
||||||
|
pub fn defer_op_without_attempt(&self, id: i64, next_attempt_at: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op
|
||||||
|
SET next_attempt_at = CASE
|
||||||
|
WHEN next_attempt_at >= ?3 THEN next_attempt_at
|
||||||
|
ELSE ?2
|
||||||
|
END
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, next_attempt_at, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Persisted effective access for roots shared with this account.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
|
use super::Db;
|
||||||
|
use crate::{Access, Error, Result};
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
/// Load the complete, deliberately small shared-root authority map.
|
||||||
|
///
|
||||||
|
/// Mount state keeps this resident so ordinary node interning and subtree
|
||||||
|
/// propagation never turn into one SQLite query per inode. The database
|
||||||
|
/// remains the restart/offline authority and every explicit role change is
|
||||||
|
/// written through before this cache is updated.
|
||||||
|
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?;
|
||||||
|
let mut access = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, value) = row?;
|
||||||
|
let (volume, link) = uid.split_once('~').ok_or_else(|| {
|
||||||
|
Error::Other(format!(
|
||||||
|
"invalid shared-root uid {uid:?} stored in database"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let access_value = Access::from_db_str(&value).ok_or_else(|| {
|
||||||
|
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||||
|
})?;
|
||||||
|
access.insert(
|
||||||
|
NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||||
|
access_value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the effective access recorded for a shared-tree root.
|
||||||
|
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let value = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||||
|
[uid.to_string()],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
value
|
||||||
|
.map(|value| {
|
||||||
|
Access::from_db_str(&value).ok_or_else(|| {
|
||||||
|
Error::Other(format!("invalid share access {value:?} stored for {uid}"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace the effective access for a shared-tree root.
|
||||||
|
pub fn set_share_access(&self, uid: &NodeUid, access: Access) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO share_access (root_uid, access) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(root_uid) DO UPDATE SET access = excluded.access",
|
||||||
|
params![uid.to_string(), access.as_db_str()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a shared-tree root's effective access.
|
||||||
|
pub fn delete_share_access(&self, uid: &NodeUid) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM share_access WHERE root_uid = ?1",
|
||||||
|
[uid.to_string()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail closed every persisted shared-tree authority.
|
||||||
|
///
|
||||||
|
/// Owned and device trees have no row in `share_access`, so this only
|
||||||
|
/// affects roots already identified as shared with this account.
|
||||||
|
pub fn downgrade_all_share_access(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE share_access SET access = 'viewer' WHERE access != 'viewer'",
|
||||||
|
[],
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a persisted node's effective access through its nearest recorded
|
||||||
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
|
/// must not treat as owned: stale handles need to fail closed.
|
||||||
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let exists: bool = conn.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||||
|
[uid.to_string()],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if !exists {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let value = conn
|
||||||
|
.query_row(
|
||||||
|
"WITH RECURSIVE ancestors(uid, parent_uid, depth, path) AS (
|
||||||
|
SELECT uid, parent_uid, 0, char(31) || uid || char(31)
|
||||||
|
FROM nodes WHERE uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.parent_uid, n.parent_uid, a.depth + 1,
|
||||||
|
a.path || a.parent_uid || char(31)
|
||||||
|
FROM ancestors a
|
||||||
|
LEFT JOIN nodes n ON n.uid = a.parent_uid
|
||||||
|
WHERE a.parent_uid IS NOT NULL
|
||||||
|
AND instr(
|
||||||
|
a.path,
|
||||||
|
char(31) || a.parent_uid || char(31)
|
||||||
|
) = 0
|
||||||
|
)
|
||||||
|
SELECT sa.access
|
||||||
|
FROM ancestors a JOIN share_access sa ON sa.root_uid = a.uid
|
||||||
|
ORDER BY a.depth ASC
|
||||||
|
LIMIT 1",
|
||||||
|
[uid.to_string()],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
value
|
||||||
|
.map(|value| {
|
||||||
|
Access::from_db_str(&value).ok_or_else(|| {
|
||||||
|
Error::Other(format!(
|
||||||
|
"invalid effective share access {value:?} for {uid}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|access| Some(access.unwrap_or(Access::Owner)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,4 +94,26 @@ impl Db {
|
|||||||
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop every `sync_state` key starting with `prefix` — the per-entity
|
||||||
|
/// stamps (one album, one shared folder) that are keyed by uid and so have
|
||||||
|
/// no fixed name to clear individually.
|
||||||
|
pub fn clear_state_prefix(&self, prefix: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
// `\` escapes the LIKE wildcards, so a prefix containing `%` or `_`
|
||||||
|
// (a uid never does today, but this is not the place to rely on that)
|
||||||
|
// matches literally instead of sweeping unrelated keys.
|
||||||
|
let pattern = format!(
|
||||||
|
"{}%",
|
||||||
|
prefix
|
||||||
|
.replace('\\', "\\\\")
|
||||||
|
.replace('%', "\\%")
|
||||||
|
.replace('_', "\\_")
|
||||||
|
);
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM sync_state WHERE key LIKE ?1 ESCAPE '\\'",
|
||||||
|
params![pattern],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -115,14 +115,17 @@ impl Db {
|
|||||||
Ok(n > 0)
|
Ok(n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a synced folder's mode (`mirror`/`ondemand`). The folder has
|
/// Update a synced folder's mode (`mirror`/`ondemand`). A queued request for
|
||||||
/// reached the mode it was asked for, so any queued request is satisfied and
|
/// that mode is satisfied and cleared in the same write. An opposite pending
|
||||||
/// cleared in the same write — a `pending_mode` outliving the switch it asked
|
/// mode arrived after this transition started, so preserve it as the newer
|
||||||
/// for would have the engine try to apply it again on the next pass.
|
/// last-request intent.
|
||||||
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
pub fn sync_folder_set_mode(&self, id: i64, mode: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sync_folder SET mode = ?2, pending_mode = NULL WHERE id = ?1",
|
"UPDATE sync_folder
|
||||||
|
SET mode = ?2,
|
||||||
|
pending_mode = CASE WHEN pending_mode = ?2 THEN NULL ELSE pending_mode END
|
||||||
|
WHERE id = ?1",
|
||||||
params![id, mode],
|
params![id, mode],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -139,6 +142,18 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Withdraw a queued mode only if it is still the intent the caller
|
||||||
|
/// inspected. A newer request must survive an older settlement attempt.
|
||||||
|
pub fn sync_folder_clear_pending_mode_if(&self, id: i64, expected: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE sync_folder SET pending_mode = NULL
|
||||||
|
WHERE id = ?1 AND pending_mode = ?2",
|
||||||
|
params![id, expected],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Update a synced folder's state and stamp `last_sync` to now.
|
/// Update a synced folder's state and stamp `last_sync` to now.
|
||||||
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
pub fn sync_folder_set_state(&self, id: i64, state: &str, last_sync: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -174,6 +189,28 @@ impl Db {
|
|||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `uid` is represented by a mirror location.
|
||||||
|
///
|
||||||
|
/// An on-demand location proves residency through its live inode state.
|
||||||
|
/// Mirror locations have no FUSE state, so their folder root comes from
|
||||||
|
/// `sync_folder` and descendants come from the last successful
|
||||||
|
/// `sync_entry` baseline.
|
||||||
|
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM sync_folder AS folder
|
||||||
|
LEFT JOIN sync_entry AS entry ON entry.folder_id = folder.id
|
||||||
|
WHERE folder.mode = 'mirror'
|
||||||
|
AND (folder.remote_uid = ?1 OR entry.remote_uid = ?1)
|
||||||
|
)",
|
||||||
|
params![uid],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// Insert or replace one baseline row.
|
/// Insert or replace one baseline row.
|
||||||
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
pub fn sync_entry_upsert(&self, folder_id: i64, e: &StoredSyncEntry) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@
|
|||||||
//! identity, XDG paths, and the login/session/keyring layer that every
|
//! identity, XDG paths, and the login/session/keyring layer that every
|
||||||
//! front-end (CLI today, daemon + GUI later) shares.
|
//! front-end (CLI today, daemon + GUI later) shares.
|
||||||
|
|
||||||
|
pub mod access;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -9,12 +10,14 @@ pub mod control;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod localindex;
|
pub mod localindex;
|
||||||
|
pub mod mounts;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
|
||||||
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|
||||||
// Re-export the SDK surface downstream crates need, so they depend on
|
// Re-export the SDK surface downstream crates need, so they depend on
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! Unified presentation model for every local Proton Drive location.
|
||||||
|
//!
|
||||||
|
//! Device sync state remains owned by `sync_folder`; these types are the stable
|
||||||
|
//! shape exposed to front-ends after that state is joined with the `mount`
|
||||||
|
//! presentation table.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::control::SyncProgress;
|
||||||
|
|
||||||
|
/// How a location makes remote content available locally.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MountMode {
|
||||||
|
/// A full local copy reconciled in both directions.
|
||||||
|
Mirror,
|
||||||
|
/// A FUSE session that fetches content as it is accessed.
|
||||||
|
OnDemand,
|
||||||
|
/// A mode written by a newer client.
|
||||||
|
#[default]
|
||||||
|
#[serde(other)]
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MountMode {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mirror => "mirror",
|
||||||
|
Self::OnDemand => "ondemand",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for MountMode {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
match value {
|
||||||
|
"mirror" => Self::Mirror,
|
||||||
|
"ondemand" => Self::OnDemand,
|
||||||
|
_ => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MountMode {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the location itself was declared writable.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MountAccess {
|
||||||
|
Rw,
|
||||||
|
Ro,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MountAccess {
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Rw => "rw",
|
||||||
|
Self::Ro => "ro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MountAccess {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What remote root a local location presents.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
|
pub enum MountKind {
|
||||||
|
/// The account's primary My Files mount.
|
||||||
|
MyFiles,
|
||||||
|
/// A folder owned by this machine's Proton Drive device.
|
||||||
|
Device { sync_folder_id: i64 },
|
||||||
|
/// A future standalone mount rooted at a node shared with this account.
|
||||||
|
Shared { share_root_uid: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One local Proton Drive location as exposed over the control protocol.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
|
pub struct MountSpec {
|
||||||
|
/// Presentation-row id. Device actions use the `sync_folder_id` carried by
|
||||||
|
/// [`MountKind::Device`], not this id.
|
||||||
|
pub id: i64,
|
||||||
|
pub kind: MountKind,
|
||||||
|
pub local_path: String,
|
||||||
|
pub root_uid: String,
|
||||||
|
pub root_share_id: String,
|
||||||
|
pub mode: MountMode,
|
||||||
|
pub access: MountAccess,
|
||||||
|
/// `idle` | `syncing` | `error` | `conflict` for device locations.
|
||||||
|
pub state: String,
|
||||||
|
/// Last successful device sync, epoch seconds; `0` when not applicable.
|
||||||
|
pub last_sync: i64,
|
||||||
|
/// A device mode switch accepted but not yet applied.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_mode: Option<MountMode>,
|
||||||
|
/// Whether a FUSE session currently owns `local_path`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mounted: bool,
|
||||||
|
/// Live device sync progress, absent when no pass is running.
|
||||||
|
#[serde(default)]
|
||||||
|
pub progress: Option<SyncProgress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mount_mode_keeps_legacy_wire_strings() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&MountMode::Mirror).unwrap(),
|
||||||
|
r#""mirror""#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&MountMode::OnDemand).unwrap(),
|
||||||
|
r#""ondemand""#
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<MountMode>(r#""mirror""#).unwrap(),
|
||||||
|
MountMode::Mirror
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<MountMode>(r#""ondemand""#).unwrap(),
|
||||||
|
MountMode::OnDemand
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mount_mode_tolerates_unknown_values() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<MountMode>(r#""from-the-future""#).unwrap(),
|
||||||
|
MountMode::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,20 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// File name of the profile inside the device root folder.
|
use crate::mounts::MountMode;
|
||||||
|
|
||||||
|
/// File name of the profile.
|
||||||
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
pub const PROFILE_FILE_NAME: &str = "profile.json";
|
||||||
|
|
||||||
|
/// Folder, directly under the device root, that holds the profile.
|
||||||
|
///
|
||||||
|
/// The API refuses `Cannot create file at the root of a device` (HTTP 422), so
|
||||||
|
/// the profile cannot live at the device root itself even though that is where
|
||||||
|
/// it logically belongs (docs/BUGS.md B68/B78). A dotted name keeps it out of
|
||||||
|
/// the way in the web UI, and the restore ignores it when listing the device's
|
||||||
|
/// restorable folders.
|
||||||
|
pub const PROFILE_DIR_NAME: &str = ".proton-drive-linux";
|
||||||
|
|
||||||
/// Format version of the profile document.
|
/// Format version of the profile document.
|
||||||
///
|
///
|
||||||
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
/// Bumped only for changes a *reader* cannot survive. Adding fields does not
|
||||||
@@ -39,8 +50,9 @@ pub struct ProfileFolder {
|
|||||||
/// Absolute local path it was synced to. A *suggestion* on restore: another
|
/// Absolute local path it was synced to. A *suggestion* on restore: another
|
||||||
/// machine may have a different username, home, or mount layout.
|
/// machine may have a different username, home, or mount layout.
|
||||||
pub local_path: String,
|
pub local_path: String,
|
||||||
/// `mirror` or `ondemand`.
|
/// `mirror` or `ondemand`. Unknown values written by a newer client do not
|
||||||
pub mode: String,
|
/// invalidate the rest of the profile.
|
||||||
|
pub mode: MountMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One pinned node (P5 pins), as `pins` stores it.
|
/// One pinned node (P5 pins), as `pins` stores it.
|
||||||
@@ -114,7 +126,7 @@ mod tests {
|
|||||||
folders: vec![ProfileFolder {
|
folders: vec![ProfileFolder {
|
||||||
remote_uid: "vol~link".to_string(),
|
remote_uid: "vol~link".to_string(),
|
||||||
local_path: "/home/nils/Documents".to_string(),
|
local_path: "/home/nils/Documents".to_string(),
|
||||||
mode: "mirror".to_string(),
|
mode: MountMode::Mirror,
|
||||||
}],
|
}],
|
||||||
pins: vec![ProfilePin {
|
pins: vec![ProfilePin {
|
||||||
uid: "vol~pin".to_string(),
|
uid: "vol~pin".to_string(),
|
||||||
@@ -133,6 +145,52 @@ mod tests {
|
|||||||
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
assert_eq!(Profile::parse(&p.to_bytes().unwrap()).unwrap(), p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_keeps_legacy_mode_bytes_and_parses_old_documents() {
|
||||||
|
let bytes = sample().to_bytes().unwrap();
|
||||||
|
let json = String::from_utf8(bytes).unwrap();
|
||||||
|
assert!(json.contains(r#""mode": "mirror""#));
|
||||||
|
|
||||||
|
let old = br#"{
|
||||||
|
"version": 1,
|
||||||
|
"device_uid": "dev-1",
|
||||||
|
"hostname": "old-client",
|
||||||
|
"saved_at": 1,
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"remote_uid": "vol~mirror",
|
||||||
|
"local_path": "/home/me/Mirror",
|
||||||
|
"mode": "mirror"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"remote_uid": "vol~ondemand",
|
||||||
|
"local_path": "/home/me/Cloud",
|
||||||
|
"mode": "ondemand"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let profile = Profile::parse(old).unwrap();
|
||||||
|
assert_eq!(profile.folders[0].mode, MountMode::Mirror);
|
||||||
|
assert_eq!(profile.folders[1].mode, MountMode::OnDemand);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_folder_mode_does_not_reject_profile() {
|
||||||
|
let json = br#"{
|
||||||
|
"version": 1,
|
||||||
|
"device_uid": "dev-1",
|
||||||
|
"hostname": "future-client",
|
||||||
|
"saved_at": 1,
|
||||||
|
"folders": [{
|
||||||
|
"remote_uid": "vol~future",
|
||||||
|
"local_path": "/home/me/Future",
|
||||||
|
"mode": "streaming"
|
||||||
|
}]
|
||||||
|
}"#;
|
||||||
|
let profile = Profile::parse(json).unwrap();
|
||||||
|
assert_eq!(profile.folders[0].mode, MountMode::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
/// A field added by a later client must not break this one's restore: the
|
/// A field added by a later client must not break this one's restore: the
|
||||||
/// common subset is still usable, which is the whole reason `version` is
|
/// common subset is still usable, which is the whole reason `version` is
|
||||||
/// reserved for breaking changes only.
|
/// reserved for breaking changes only.
|
||||||
|
|||||||
@@ -45,6 +45,45 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|||||||
"Thumbs.db",
|
"Thumbs.db",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
||||||
|
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
||||||
|
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
||||||
|
/// revision — the app renames the finished file to its real name, and only that
|
||||||
|
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
||||||
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||||
|
".crdownload", // Chromium/Brave/Edge
|
||||||
|
".part", // Firefox, wget, aria2c
|
||||||
|
".partial", // some downloaders / older IE
|
||||||
|
".download", // Safari / macOS
|
||||||
|
".tmp", // generic scratch
|
||||||
|
".temp", // generic scratch
|
||||||
|
".swp", // vim swap
|
||||||
|
".swx", // vim swap (secondary)
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Whether `name` is a transient scratch/in-flight file whose bytes should not
|
||||||
|
/// be uploaded on an on-demand mount until it is renamed to its finished name.
|
||||||
|
///
|
||||||
|
/// Recognises browser download temps, editor swap/backup files, and lock files.
|
||||||
|
/// Case-insensitive on the suffix so `FOO.CRDOWNLOAD` counts too. This is the
|
||||||
|
/// on-demand-write counterpart to [`DEFAULT_IGNORE_PATTERNS`] (which only covers
|
||||||
|
/// mirror-folder reconcile); keep the two conceptually in step.
|
||||||
|
pub fn is_transient_name(name: &str) -> bool {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
if TRANSIENT_SUFFIXES.iter().any(|s| lower.ends_with(s)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Emacs/gedit/kate backup copies.
|
||||||
|
if name.ends_with('~') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// LibreOffice lock files (`.~lock.<doc>#`) and MS Office temps (`~$doc`).
|
||||||
|
if name.starts_with(".~lock.") || name.starts_with("~$") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Filenames accepted as a folder's ignore file, in precedence order.
|
/// Filenames accepted as a folder's ignore file, in precedence order.
|
||||||
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
const IGNORE_FILE_NAMES: &[&str] = &[".pdfsignore", ".protonignore"];
|
||||||
|
|
||||||
@@ -272,4 +311,37 @@ mod tests {
|
|||||||
// have nothing to reconcile against.
|
// have nothing to reconcile against.
|
||||||
assert!(!rules.is_ignored("", true));
|
assert!(!rules.is_ignored("", true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_download_and_editor_names_are_recognised() {
|
||||||
|
for name in [
|
||||||
|
"Unconfirmed 540477.crdownload",
|
||||||
|
"movie.mkv.part",
|
||||||
|
"big.iso.partial",
|
||||||
|
"photo.jpg.download",
|
||||||
|
"export.xml.tmp",
|
||||||
|
"notes.TEMP",
|
||||||
|
".notes.txt.swp",
|
||||||
|
"doc.txt~",
|
||||||
|
".~lock.report.odt#",
|
||||||
|
"~$budget.xlsx",
|
||||||
|
"FOO.CRDOWNLOAD",
|
||||||
|
] {
|
||||||
|
assert!(is_transient_name(name), "{name} should be transient");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finished_names_are_not_transient() {
|
||||||
|
for name in [
|
||||||
|
"teamspeak.tar.gz",
|
||||||
|
"nils.zip",
|
||||||
|
"report.xml",
|
||||||
|
"archive.partly.zip", // ".partly" is not ".part"
|
||||||
|
"template.dotx",
|
||||||
|
"readme",
|
||||||
|
] {
|
||||||
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Photo albums: the album listing, and one album's contents.
|
||||||
|
//!
|
||||||
|
//! Both are persisted projections of what the photos volume reports, refreshed
|
||||||
|
//! on the same stale-while-serving rule as the timeline
|
||||||
|
//! ([`crate::photos`]): a listing already on disk is served immediately and
|
||||||
|
//! refreshed behind the caller, and only an empty one waits on the network.
|
||||||
|
//!
|
||||||
|
//! An album is a folder node carrying album properties, so it has a name like
|
||||||
|
//! any other node; the properties supply the photo count and the cover. Albums
|
||||||
|
//! other people share with us come from a second listing and live on *their*
|
||||||
|
//! photos volume — which is why an album's photos are persisted per album rather
|
||||||
|
//! than looked up in our own timeline, where a shared album's photos never
|
||||||
|
//! appear.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
||||||
|
use pdfs_core::db::StoredAlbum;
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ALBUM_SYNCED_PREFIX, ALBUMS_SYNCED_MS, Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS,
|
||||||
|
TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, now_ms, parse_uid,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// The album listing, served from disk and refreshed behind the caller when
|
||||||
|
/// stale. `None` means the account has no photos volume — the same signal
|
||||||
|
/// [`Core::photos_timeline`] gives.
|
||||||
|
pub(crate) fn albums(&self) -> CoreResult<Option<Vec<AlbumInfo>>> {
|
||||||
|
let count = self.db.albums_count().map_err(CoreError::from)?;
|
||||||
|
if count == 0 {
|
||||||
|
// Nothing on disk, so this one request waits for the fetch — unless
|
||||||
|
// the account is already known to have no photos volume.
|
||||||
|
let known_empty = self.db.state_i64(PHOTOS_AVAILABLE).ok().flatten() == Some(0);
|
||||||
|
if known_empty && !self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !self.rt.block_on(self.refresh_albums())? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
} else if self.listing_stale(ALBUMS_SYNCED_MS, TIMELINE_TTL) {
|
||||||
|
self.spawn_albums_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||||
|
Ok(Some(albums.into_iter().map(album_info).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-enumerate the albums — ours and the ones shared with us — and replace
|
||||||
|
/// the persisted listing. Returns false when the account has no photos
|
||||||
|
/// volume.
|
||||||
|
pub(crate) async fn refresh_albums(&self) -> CoreResult<bool> {
|
||||||
|
let photos = self.photos();
|
||||||
|
if photos
|
||||||
|
.get_photos_root()
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "photos root"))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0);
|
||||||
|
let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms());
|
||||||
|
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uids = photos
|
||||||
|
.enumerate_album_node_uids()
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "albums"))?;
|
||||||
|
|
||||||
|
// Shared-with-me albums are a separate listing on the sharer's volume.
|
||||||
|
// Best-effort: an account whose sharing listing fails still gets its own
|
||||||
|
// albums rather than an error page.
|
||||||
|
let shared: HashSet<String> = match photos.enumerate_shared_with_me_album_uids().await {
|
||||||
|
Ok(shared) => {
|
||||||
|
let keys = shared.iter().map(|u| u.to_string()).collect();
|
||||||
|
uids.extend(shared);
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "enumerating shared-with-me albums failed");
|
||||||
|
HashSet::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The listings carry uids only; the name, photo count and cover come from
|
||||||
|
// the album nodes themselves. A chunk that fails to resolve drops those
|
||||||
|
// albums from this refresh rather than failing the whole listing.
|
||||||
|
let mut albums: Vec<StoredAlbum> = Vec::with_capacity(uids.len());
|
||||||
|
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||||
|
match photos.enumerate_nodes(chunk).await {
|
||||||
|
Ok(nodes) => {
|
||||||
|
for node in nodes {
|
||||||
|
let key = node.uid.to_string();
|
||||||
|
let props = node.album.as_ref();
|
||||||
|
albums.push(StoredAlbum {
|
||||||
|
shared: shared.contains(&key),
|
||||||
|
uid: key,
|
||||||
|
name: node.name,
|
||||||
|
photo_count: props.map_or(0, |a| a.photo_count.max(0) as usize),
|
||||||
|
cover_uid: props
|
||||||
|
.and_then(|a| a.cover_photo_uid.as_ref())
|
||||||
|
.map(|u| u.to_string()),
|
||||||
|
last_activity: props.and_then(|a| a.last_activity_time),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(error = %e, "resolving an album chunk failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest activity first, and albums the server gave no activity time for
|
||||||
|
// last — an album nobody has touched belongs below the ones in use. Ties
|
||||||
|
// fall back to the name so the order is stable between refreshes.
|
||||||
|
albums.sort_by(|a, b| {
|
||||||
|
b.last_activity
|
||||||
|
.cmp(&a.last_activity)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
|
||||||
|
self.db.albums_replace(&albums).map_err(CoreError::from)?;
|
||||||
|
let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1);
|
||||||
|
let _ = self.db.set_state_i64(ALBUMS_SYNCED_MS, now_ms());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the album listing off the request path. At most one runs at a time.
|
||||||
|
pub(crate) fn spawn_albums_refresh(&self) {
|
||||||
|
if self.albums_refreshing.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let core = self.clone();
|
||||||
|
self.rt.spawn(async move {
|
||||||
|
if let Err(e) = core.refresh_albums().await {
|
||||||
|
warn!(error = %e, "background album refresh failed");
|
||||||
|
}
|
||||||
|
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page of one album's photos, newest capture first. The album's contents
|
||||||
|
/// are fetched on first open and refreshed behind the caller afterwards, so
|
||||||
|
/// paging never waits on the network twice.
|
||||||
|
pub(crate) fn album_photos(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> CoreResult<Vec<PhotoItem>> {
|
||||||
|
let key = album.to_string();
|
||||||
|
let stamp = format!("{ALBUM_SYNCED_PREFIX}{key}");
|
||||||
|
let count = self.db.album_photos_count(&key).map_err(CoreError::from)?;
|
||||||
|
if count == 0 {
|
||||||
|
self.rt.block_on(self.refresh_album(album))?;
|
||||||
|
} else if self.listing_stale(&stamp, TIMELINE_TTL) {
|
||||||
|
let core = self.clone();
|
||||||
|
let album = album.clone();
|
||||||
|
self.rt.spawn(async move {
|
||||||
|
if let Err(e) = core.refresh_album(&album).await {
|
||||||
|
warn!(%album, error = %e, "background album refresh failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = self
|
||||||
|
.db
|
||||||
|
.album_photos_page(&key, offset, limit)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||||
|
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let items = photos
|
||||||
|
.enumerate_album(album)
|
||||||
|
.await
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "album"))?;
|
||||||
|
|
||||||
|
// Same enrichment as the timeline: the listing carries a uid and a
|
||||||
|
// capture time, and the gallery needs a name and media type to classify
|
||||||
|
// each entry. Best-effort per chunk.
|
||||||
|
let uids: Vec<NodeUid> = items.iter().map(|it| it.uid.clone()).collect();
|
||||||
|
let mut meta: HashMap<String, (Option<String>, Option<String>)> = HashMap::new();
|
||||||
|
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||||
|
match photos.enumerate_nodes(chunk).await {
|
||||||
|
Ok(nodes) => {
|
||||||
|
for node in nodes {
|
||||||
|
let media_type = match &node.kind {
|
||||||
|
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||||
|
NodeKind::Folder => None,
|
||||||
|
};
|
||||||
|
meta.insert(node.uid.to_string(), (Some(node.name), media_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(error = %e, "resolving album photo metadata failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<(String, i64, Option<String>, Option<String>)> = items
|
||||||
|
.iter()
|
||||||
|
.map(|it| {
|
||||||
|
let key = it.uid.to_string();
|
||||||
|
let (name, media_type) = meta.get(&key).cloned().unwrap_or((None, None));
|
||||||
|
(key, it.capture_time, name, media_type)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let key = album.to_string();
|
||||||
|
self.db
|
||||||
|
.album_photos_replace(&key, &rows)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.set_state_i64(&format!("{ALBUM_SYNCED_PREFIX}{key}"), now_ms());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
|
/// to the timeline can just as easily be a change to an album.
|
||||||
|
pub(crate) fn invalidate_albums(&self) {
|
||||||
|
let _ = self.db.clear_state(ALBUMS_SYNCED_MS);
|
||||||
|
let _ = self.db.clear_state_prefix(ALBUM_SYNCED_PREFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
|
AlbumInfo {
|
||||||
|
uid: album.uid,
|
||||||
|
name: album.name,
|
||||||
|
photo_count: album.photo_count,
|
||||||
|
// A cover uid the daemon cannot parse is no cover: the front-end would
|
||||||
|
// only ask for a thumbnail that can never be resolved.
|
||||||
|
cover_uid: album.cover_uid.filter(|uid| parse_uid(uid).is_some()),
|
||||||
|
last_activity: album.last_activity,
|
||||||
|
shared: album.shared,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,151 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||||
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
|
/// which event carried them.
|
||||||
|
///
|
||||||
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
|
/// its content blob once if *any* mount did.
|
||||||
|
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
||||||
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
|
let child = st.by_uid.get(uid).copied();
|
||||||
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
match child {
|
||||||
|
Some(child) => {
|
||||||
|
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
||||||
|
let Some(notifier) = notifier else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for &ino in changed {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
|
||||||
|
let child = st.by_uid.get(uid).copied();
|
||||||
|
let dentry = child.and_then(|ino| {
|
||||||
|
st.entries
|
||||||
|
.get(&ino)
|
||||||
|
.map(|entry| (entry.parent, entry.node.name.clone()))
|
||||||
|
});
|
||||||
|
let changed = st.downgrade_shared_subtree(uid);
|
||||||
|
st.hide_foreign_subtree(uid);
|
||||||
|
invalidate_access_changes(notifier, &changed);
|
||||||
|
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
||||||
|
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the fail-closed part of an access event and install the same denial
|
||||||
|
/// in resident state even when SQLite rejects the write. The caller must not
|
||||||
|
/// acknowledge the event when this returns an error.
|
||||||
|
fn apply_access_downgrade(
|
||||||
|
db: &Db,
|
||||||
|
event: &DriveEvent,
|
||||||
|
mut deny: impl FnMut(Option<&NodeUid>),
|
||||||
|
) -> pdfs_core::Result<()> {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
|
let result = db.set_share_access(node_uid, Access::Viewer);
|
||||||
|
deny(Some(node_uid));
|
||||||
|
result
|
||||||
|
}
|
||||||
|
DriveEvent::ContinuityLost { .. }
|
||||||
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
|
let result = db.downgrade_all_share_access().map(|_| ());
|
||||||
|
deny(None);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_foreign_delete(
|
||||||
|
db: &Db,
|
||||||
|
uid: &NodeUid,
|
||||||
|
mut deny_and_hide: impl FnMut(&NodeUid),
|
||||||
|
) -> pdfs_core::Result<()> {
|
||||||
|
let result = db.tombstone_foreign_subtree(uid);
|
||||||
|
deny_and_hide(uid);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_foreign_node_delete(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
|
!is_own_or_virtual_uid(node_uid, own_volume) && !is_local_uid(node_uid)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_serializes_shared_publication(event: &DriveEvent, own_volume: &VolumeId) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
DriveEvent::ContinuityLost { .. }
|
||||||
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
|
| DriveEvent::SharedWithMeUpdated { .. }
|
||||||
|
) || is_foreign_node_delete(event, own_volume)
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply one remote event to the local cache and notify the kernel so it drops
|
/// Apply one remote event to the local cache and notify the kernel so it drops
|
||||||
/// any stale cached metadata/data for the affected inodes.
|
/// any stale cached metadata/data for the affected inodes.
|
||||||
///
|
///
|
||||||
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
/// The cache is authoritative-by-absence: dropping a directory's `children`
|
||||||
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
/// entry forces the next `lookup`/`readdir` to re-enumerate from the remote, so
|
||||||
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
/// most events only need to invalidate listings rather than re-fetch eagerly.
|
||||||
fn apply_event(
|
///
|
||||||
state: &Mutex<State>,
|
/// Applied to **every** mounted inode space, not just the primary one. This task
|
||||||
content: &ContentCache,
|
/// is per-daemon while node state is per-mount, and a `DriveEvent` names a uid,
|
||||||
pending: &Mutex<HashMap<NodeUid, PendingRevision>>,
|
/// not a mount — so a file trashed from another device has to be withdrawn from
|
||||||
notifier: &Notifier,
|
/// whichever of our sessions is showing it, which for anything under a sync
|
||||||
event: &DriveEvent,
|
/// folder is a [`Core::fork_state`] fork rather than `core.state`. Reaching for
|
||||||
) {
|
/// the primary state alone left forks serving a deleted file indefinitely, the
|
||||||
|
/// read-side twin of `docs/BUGS.md` B74. Inode numbers are per-mount, so each
|
||||||
|
/// mount must be notified through its **own** channel; that pairing is what
|
||||||
|
/// [`Core::for_each_mount`] exists to preserve.
|
||||||
|
fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdfs_core::Result<()> {
|
||||||
|
let foreign_delete = is_foreign_node_delete(event, &core.primary_root_uid.volume_id);
|
||||||
|
let serializes_shared_publication =
|
||||||
|
event_serializes_shared_publication(event, &core.primary_root_uid.volume_id);
|
||||||
|
let _publication = serializes_shared_publication.then(|| core.shared_publication.lock());
|
||||||
|
if serializes_shared_publication {
|
||||||
|
core.invalidate_shared_refreshes();
|
||||||
|
}
|
||||||
|
let mut apply_result = if let DriveEvent::NodeDeleted { node_uid, .. } = event
|
||||||
|
&& foreign_delete
|
||||||
|
{
|
||||||
|
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
hide_foreign_deleted_and_notify(st, notifier, uid);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
apply_access_downgrade(&core.db, event, |root| {
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
let changed = match root {
|
||||||
|
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||||
|
None => st.downgrade_known_shared_access(),
|
||||||
|
};
|
||||||
|
invalidate_access_changes(notifier, &changed);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
match event {
|
match event {
|
||||||
DriveEvent::NodeUpdated {
|
DriveEvent::NodeUpdated {
|
||||||
node_uid,
|
node_uid,
|
||||||
@@ -22,79 +154,183 @@ fn apply_event(
|
|||||||
is_trashed,
|
is_trashed,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut st = state.lock();
|
// A node we owe an upload for is *ahead* of the remote, not behind
|
||||||
if *is_trashed {
|
// it: this event is almost always the echo of our own empty-file
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// create, and re-fetching would replace the size and mtime of the
|
||||||
let child = st.by_uid.get(node_uid).copied();
|
// write we just accepted with the stale revision's — making a file
|
||||||
if let Some((parent, name)) = st.forget(node_uid) {
|
// that was copied in seconds ago read as empty until its upload
|
||||||
content.evict(node_uid);
|
// lands (offline.md Phase 3).
|
||||||
match child {
|
//
|
||||||
Some(child) => {
|
// A write that has already drained is the same story one step later:
|
||||||
let _ =
|
// the drain brought the tree level with the revision it sealed, so
|
||||||
notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
// the feed's report of that revision has nothing to add, and acting
|
||||||
}
|
// on it evicts the very bytes we uploaded. Claimed unconditionally,
|
||||||
None => {
|
// even for a trash, so an echo we no longer care about does not
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
// linger to suppress a later foreign change.
|
||||||
}
|
let echo = core.take_self_change(node_uid);
|
||||||
|
let ours = !*is_trashed && (echo || core.pending.lock().contains_key(node_uid));
|
||||||
|
if ours {
|
||||||
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
|
}
|
||||||
|
let mut had_node = false;
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
if *is_trashed {
|
||||||
|
// Trashing makes a node vanish from its parent listing.
|
||||||
|
had_node |= forget_and_notify(st, notifier, node_uid);
|
||||||
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
|
// Known node changed: drop its cached attrs/data (and
|
||||||
|
// listing if it is a directory) so the next access
|
||||||
|
// re-fetches. Its content blob may now be stale too.
|
||||||
|
had_node = true;
|
||||||
|
st.invalidate_listing(ino);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if pending.lock().contains_key(node_uid) {
|
});
|
||||||
// A node we owe an upload for is *ahead* of the remote, not
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
// behind it: this event is almost always the echo of our own
|
if had_node {
|
||||||
// empty-file create, and re-fetching would replace the size and
|
core.cache.evict(node_uid);
|
||||||
// mtime of the write we just accepted with the stale revision's
|
|
||||||
// — making a file that was copied in seconds ago read as empty
|
|
||||||
// until its upload lands (offline.md Phase 3).
|
|
||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
|
||||||
} else if let Some(&ino) = st.by_uid.get(node_uid) {
|
|
||||||
// Known node changed: drop its cached attrs/data (and listing if
|
|
||||||
// it is a directory) so the next access re-fetches. Its content
|
|
||||||
// blob may now be stale, so evict it too.
|
|
||||||
st.invalidate_listing(ino);
|
|
||||||
content.evict(node_uid);
|
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
}
|
||||||
// A create (or move-in) shows up as a change to the parent listing;
|
// A create, rename or move-in shows up as a change to the parent
|
||||||
// drop it so the new child is picked up on the next readdir.
|
// listing too, which is recorded for the end of the batch rather
|
||||||
if let Some(parent_uid) = parent_node_uid
|
// than acted on here — see [`DirtyParents`].
|
||||||
&& let Some(&parent) = st.by_uid.get(parent_uid)
|
if let Some(parent_uid) = parent_node_uid {
|
||||||
{
|
match ours {
|
||||||
st.invalidate_listing(parent);
|
true => dirty
|
||||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
.ours
|
||||||
|
.entry(parent_uid.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(node_uid.clone()),
|
||||||
|
false => dirty.foreign.insert(parent_uid.clone()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
let mut st = state.lock();
|
core.cache.evict(node_uid);
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
if !foreign_delete {
|
||||||
let child = st.by_uid.get(node_uid).copied();
|
core.for_each_mount(|st, notifier| {
|
||||||
content.evict(node_uid);
|
forget_and_notify(st, notifier, node_uid);
|
||||||
if let Some((parent, name)) = st.forget(node_uid) {
|
});
|
||||||
match child {
|
|
||||||
Some(child) => {
|
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Continuity or scope was lost: our cached listings may be arbitrarily
|
// Losing event continuity or any shared-access signal makes persisted
|
||||||
// stale, so drop every listing and tell the kernel to forget all
|
// shared roots untrustworthy. Keep owned/device trees unchanged, but
|
||||||
// metadata. Inodes stay stable; dirs simply re-enumerate on next access.
|
// fail every known shared subtree closed until a root refresh carries a
|
||||||
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
// current membership role.
|
||||||
warn!("event continuity lost; dropping all cached listings, resyncing lazily");
|
DriveEvent::ContinuityLost { .. }
|
||||||
let mut st = state.lock();
|
| DriveEvent::ScopeAccessLost { .. }
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
for &ino in &dirs {
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
st.invalidate_listing(ino);
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
core.for_each_mount(|st, notifier| {
|
||||||
}
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
|
for &ino in &dirs {
|
||||||
|
st.invalidate_listing(ino);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// No substantive local change; the cursor advance is handled by the
|
// No substantive local change; the cursor advance is handled by the
|
||||||
// caller persisting the event id.
|
// caller persisting the event id.
|
||||||
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
DriveEvent::CursorAdvanced { .. } => {}
|
||||||
}
|
}
|
||||||
|
apply_result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acknowledge_applied_event(
|
||||||
|
db: &Db,
|
||||||
|
event: &DriveEvent,
|
||||||
|
applied: pdfs_core::Result<()>,
|
||||||
|
) -> pdfs_core::Result<DriveEventId> {
|
||||||
|
applied?;
|
||||||
|
let id = event.id().clone();
|
||||||
|
db.set_event_cursor(id.as_str())?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The folders one batch of events changed, collected so the listings are
|
||||||
|
/// dropped once at the end of the batch rather than once per event.
|
||||||
|
///
|
||||||
|
/// Both halves of that matter. A burst of uploads produces one event per file
|
||||||
|
/// all naming the *same* folder, and dropping the listing per event made every
|
||||||
|
/// interleaved lookup re-enumerate that folder from the API — quadratic in its
|
||||||
|
/// size, and slow enough to stall a busy mount. Splitting foreign changes from
|
||||||
|
/// our own then removes the re-enumeration entirely for the common case: a file
|
||||||
|
/// this daemon created is already in the tree under the right parent, so there
|
||||||
|
/// is nothing for a re-enumeration to discover.
|
||||||
|
///
|
||||||
|
/// Neither is a weakening. The state after the batch is what it always was.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DirtyParents {
|
||||||
|
/// Folders changed by someone else. Their listings must go: an event names
|
||||||
|
/// no name, so a rename inside a folder is indistinguishable from a write to
|
||||||
|
/// a file in it, and only re-enumerating can tell us which happened.
|
||||||
|
foreign: HashSet<NodeUid>,
|
||||||
|
/// Folders changed by this daemon, and the children the changes were about.
|
||||||
|
/// Checked per mount — a mount that already lists every one of those
|
||||||
|
/// children under that parent keeps its listing.
|
||||||
|
ours: HashMap<NodeUid, HashSet<NodeUid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this mount's cached listing of `parent_uid` already accounts for
|
||||||
|
/// `child_uid`, so a change we made to that child leaves nothing to re-read.
|
||||||
|
///
|
||||||
|
/// "Nothing cached" counts as current: an absent listing is re-enumerated on the
|
||||||
|
/// next `readdir` regardless, so there is nothing to drop.
|
||||||
|
fn listing_accounts_for(st: &State, parent_uid: &NodeUid, child_uid: &NodeUid) -> bool {
|
||||||
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(children) = st.children.get(&parent) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let Some(&child) = st.by_uid.get(child_uid) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
children.contains(&child) && st.entries.get(&child).is_some_and(|e| e.parent == parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the cached listing of every folder a batch of events changed, in every
|
||||||
|
/// mount that holds one, so the next `readdir`/`lookup` re-enumerates it.
|
||||||
|
///
|
||||||
|
/// Runs strictly after the whole batch is applied: doing it per event would
|
||||||
|
/// leave a window where a concurrent `readdir` re-populates a listing that a
|
||||||
|
/// later event in the same batch then has to drop again.
|
||||||
|
fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||||
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
core.for_each_mount(|st, notifier| {
|
||||||
|
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
||||||
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
st.invalidate_listing(parent);
|
||||||
|
if let Some(notifier) = notifier {
|
||||||
|
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for parent_uid in &dirty.foreign {
|
||||||
|
drop_listing(st, parent_uid);
|
||||||
|
}
|
||||||
|
for (parent_uid, children) in &dirty.ours {
|
||||||
|
if dirty.foreign.contains(parent_uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// One child this mount has not placed is enough: the listing it is
|
||||||
|
// serving is missing something the user just made.
|
||||||
|
if children
|
||||||
|
.iter()
|
||||||
|
.all(|child| listing_accounts_for(st, parent_uid, child))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drop_listing(st, parent_uid);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll the remote event cursor forever, applying each batch to the shared
|
/// Poll the remote event cursor forever, applying each batch to the shared
|
||||||
@@ -102,15 +338,16 @@ fn apply_event(
|
|||||||
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
/// unmounted are applied; only a first-ever mount seeds from the server head.
|
||||||
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
/// The cursor is persisted after every batch. Runs as a Tokio task; returns
|
||||||
/// only on fatal error.
|
/// only on fatal error.
|
||||||
|
///
|
||||||
|
/// Takes the whole `Core` rather than the primary mount's pieces: an event has
|
||||||
|
/// to be applied to every mounted inode space, and the registry that enumerates
|
||||||
|
/// them lives on the `Core` (see [`apply_event`]).
|
||||||
pub(super) async fn run_event_sync(
|
pub(super) async fn run_event_sync(
|
||||||
client: ProtonDriveClient,
|
client: ProtonDriveClient,
|
||||||
scope: DriveEventScopeId,
|
scope: DriveEventScopeId,
|
||||||
state: Arc<Mutex<State>>,
|
core: Core,
|
||||||
content: Arc<ContentCache>,
|
|
||||||
db: Arc<Db>,
|
|
||||||
pending: Arc<Mutex<HashMap<NodeUid, PendingRevision>>>,
|
|
||||||
notifier: Notifier,
|
|
||||||
) {
|
) {
|
||||||
|
let db = core.db.clone();
|
||||||
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
let mut cursor: Option<DriveEventId> = match db.get_event_cursor() {
|
||||||
// Resume: pick up exactly where the last run left off.
|
// Resume: pick up exactly where the last run left off.
|
||||||
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
Ok(Some(saved)) => Some(DriveEventId::from(saved)),
|
||||||
@@ -165,6 +402,11 @@ pub(super) async fn run_event_sync(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
debug!(count = events.len(), "applying remote events");
|
debug!(count = events.len(), "applying remote events");
|
||||||
|
// Folders whose listing this batch invalidates, applied once at the end
|
||||||
|
// rather than per event — see [`DirtyParents`]. Every `break` below falls
|
||||||
|
// through to that flush, so an interrupted batch still publishes what it
|
||||||
|
// did apply.
|
||||||
|
let mut dirty = DirtyParents::default();
|
||||||
for event in &events {
|
for event in &events {
|
||||||
// Converge the SDK's own caches (folder keys, entity cache) on the
|
// Converge the SDK's own caches (folder keys, entity cache) on the
|
||||||
// server before applying the event to our tree. Without this, a node
|
// server before applying the event to our tree. Without this, a node
|
||||||
@@ -175,17 +417,23 @@ pub(super) async fn run_event_sync(
|
|||||||
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
warn!(error = %e, "sdk cache invalidation for event failed; retaining cursor for retry");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
apply_event(&state, &content, &pending, ¬ifier, event);
|
let applied = match acknowledge_applied_event(
|
||||||
let applied = event.id().clone();
|
&db,
|
||||||
if let Err(e) = db.set_event_cursor(applied.as_str()) {
|
event,
|
||||||
warn!(error = %e, "persist event cursor failed; retaining prior cursor for retry");
|
apply_event(&core, event, &mut dirty),
|
||||||
break;
|
) {
|
||||||
}
|
Ok(applied) => applied,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "event application or acknowledgment failed; retaining prior cursor for retry");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
// Advance only after this exact event is durably acknowledged. If a
|
// Advance only after this exact event is durably acknowledged. If a
|
||||||
// later event fails, polling resumes from here and replays nothing
|
// later event fails, polling resumes from here and replays nothing
|
||||||
// that was not already made visible locally.
|
// that was not already made visible locally.
|
||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
|
flush_dirty_parents(&core, &dirty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,3 +522,276 @@ fn scan_local_once(
|
|||||||
}
|
}
|
||||||
indexing.store(false, Ordering::Relaxed);
|
indexing.store(false, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
fn uid(link: &str) -> NodeUid {
|
||||||
|
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder(volume: &str, link: &str, parent: Option<NodeUid>, name: &str) -> Node {
|
||||||
|
Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from(volume), LinkId::from(link)),
|
||||||
|
parent_uid: parent,
|
||||||
|
kind: NodeKind::Folder,
|
||||||
|
name: name.to_string(),
|
||||||
|
creation_time: 1,
|
||||||
|
modification_time: 1,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_delete_invalidates_an_earlier_refresh_generation() {
|
||||||
|
let own_volume = VolumeId::from("own");
|
||||||
|
let event = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("foreign-delete"),
|
||||||
|
node_uid: NodeUid::new(VolumeId::from("foreign"), LinkId::from("child")),
|
||||||
|
parent_node_uid: None,
|
||||||
|
};
|
||||||
|
let generation = AtomicU64::new(7);
|
||||||
|
let captured = generation.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
assert!(event_serializes_shared_publication(&event, &own_volume));
|
||||||
|
generation.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
||||||
|
assert!(!refresh_generation_is_current(
|
||||||
|
captured,
|
||||||
|
generation.load(Ordering::SeqCst)
|
||||||
|
));
|
||||||
|
let own_delete = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("own-delete"),
|
||||||
|
node_uid: NodeUid::new(own_volume.clone(), LinkId::from("child")),
|
||||||
|
parent_node_uid: None,
|
||||||
|
};
|
||||||
|
assert!(!event_serializes_shared_publication(
|
||||||
|
&own_delete,
|
||||||
|
&own_volume
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_delete_tombstones_subtree_and_advances_cursor_after_success() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
let root = folder("own", "root", None, "My Files");
|
||||||
|
let deleted = folder(
|
||||||
|
"foreign",
|
||||||
|
"deleted",
|
||||||
|
Some(root.uid.clone()),
|
||||||
|
"Deleted foreign",
|
||||||
|
);
|
||||||
|
let deep = folder(
|
||||||
|
"foreign",
|
||||||
|
"deep",
|
||||||
|
Some(deleted.uid.clone()),
|
||||||
|
"Searchable descendant",
|
||||||
|
);
|
||||||
|
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||||
|
.unwrap();
|
||||||
|
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||||
|
db.enqueue_op(&PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: OP_RENAME.to_string(),
|
||||||
|
uid: deep.uid.to_string(),
|
||||||
|
parent_uid: Some(deleted.uid.to_string()),
|
||||||
|
name: Some("pending".to_string()),
|
||||||
|
blob_path: None,
|
||||||
|
meta_json: Some("{}".to_string()),
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("foreign-delete-success"),
|
||||||
|
node_uid: deleted.uid.clone(),
|
||||||
|
parent_node_uid: deleted.parent_uid.clone(),
|
||||||
|
};
|
||||||
|
let denied = Cell::new(false);
|
||||||
|
|
||||||
|
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||||
|
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||||
|
|
||||||
|
assert!(denied.get());
|
||||||
|
assert_eq!(acknowledged.as_str(), "foreign-delete-success");
|
||||||
|
assert_eq!(
|
||||||
|
db.get_event_cursor().unwrap().as_deref(),
|
||||||
|
Some("foreign-delete-success")
|
||||||
|
);
|
||||||
|
for uid in [&deleted.uid, &deep.uid] {
|
||||||
|
assert!(db.node_by_uid(&uid.to_string()).unwrap().unwrap().trashed);
|
||||||
|
}
|
||||||
|
assert!(db.search("Searchable", 10).unwrap().is_empty());
|
||||||
|
assert_eq!(db.pending_ops().unwrap().len(), 1);
|
||||||
|
assert_eq!(db.share_access(&deleted.uid).unwrap(), Some(Access::Viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_delete_db_failure_denies_memory_and_retains_prior_cursor() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
let root = folder("own", "root", None, "My Files");
|
||||||
|
let deleted = folder(
|
||||||
|
"foreign",
|
||||||
|
"deleted",
|
||||||
|
Some(root.uid.clone()),
|
||||||
|
"Deleted foreign",
|
||||||
|
);
|
||||||
|
let deep = folder(
|
||||||
|
"foreign",
|
||||||
|
"deep",
|
||||||
|
Some(deleted.uid.clone()),
|
||||||
|
"Still searchable",
|
||||||
|
);
|
||||||
|
db.upsert_nodes(&[root, deleted.clone(), deep.clone()])
|
||||||
|
.unwrap();
|
||||||
|
db.set_share_access(&deleted.uid, Access::Editor).unwrap();
|
||||||
|
db.set_event_cursor("prior-event").unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TRIGGER reject_foreign_tombstone
|
||||||
|
BEFORE UPDATE OF trashed ON nodes
|
||||||
|
WHEN OLD.uid = 'foreign~deleted'
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'forced tombstone failure'); END;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("foreign-delete-failed"),
|
||||||
|
node_uid: deleted.uid.clone(),
|
||||||
|
parent_node_uid: deleted.parent_uid.clone(),
|
||||||
|
};
|
||||||
|
let denied = Cell::new(false);
|
||||||
|
|
||||||
|
let applied = apply_foreign_delete(&db, &deleted.uid, |_| denied.set(true));
|
||||||
|
|
||||||
|
assert!(applied.is_err());
|
||||||
|
assert!(
|
||||||
|
denied.get(),
|
||||||
|
"resident state must fail closed on DB failure"
|
||||||
|
);
|
||||||
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
db.get_event_cursor().unwrap().as_deref(),
|
||||||
|
Some("prior-event")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!db.node_by_uid(&deleted.uid.to_string())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.trashed
|
||||||
|
);
|
||||||
|
assert_eq!(db.search("searchable", 10).unwrap().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
db.share_access(&deleted.uid).unwrap(),
|
||||||
|
Some(Access::Editor),
|
||||||
|
"the failed atomic transaction must not partially publish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_share_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
let root = uid("shared");
|
||||||
|
db.set_share_access(&root, Access::Editor).unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TRIGGER reject_share_update
|
||||||
|
BEFORE UPDATE ON share_access
|
||||||
|
BEGIN SELECT RAISE(FAIL, 'forced downgrade failure'); END;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("event-1"),
|
||||||
|
node_uid: root,
|
||||||
|
parent_node_uid: None,
|
||||||
|
};
|
||||||
|
let denied = Cell::new(false);
|
||||||
|
|
||||||
|
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||||
|
denied.set(root.is_some());
|
||||||
|
});
|
||||||
|
assert!(applied.is_err());
|
||||||
|
assert!(denied.get(), "resident state must fail closed immediately");
|
||||||
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
|
assert!(
|
||||||
|
db.get_event_cursor().unwrap().is_none(),
|
||||||
|
"failed downgrade must leave the event unacknowledged"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleted_resident_root_without_share_row_is_denied_and_tombstoned() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
let root = uid("resident-without-row");
|
||||||
|
assert!(db.share_access(&root).unwrap().is_none());
|
||||||
|
let resident = std::cell::RefCell::new(std::collections::HashMap::from([(
|
||||||
|
root.clone(),
|
||||||
|
Access::Editor,
|
||||||
|
)]));
|
||||||
|
let event = DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("event-missing-row"),
|
||||||
|
node_uid: root.clone(),
|
||||||
|
parent_node_uid: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let applied = apply_access_downgrade(&db, &event, |target| {
|
||||||
|
assert_eq!(target, Some(&root));
|
||||||
|
resident.borrow_mut().insert(root.clone(), Access::Viewer);
|
||||||
|
});
|
||||||
|
assert!(applied.is_ok());
|
||||||
|
assert_eq!(resident.borrow().get(&root), Some(&Access::Viewer));
|
||||||
|
assert_eq!(db.share_access(&root).unwrap(), Some(Access::Viewer));
|
||||||
|
|
||||||
|
let acknowledged = acknowledge_applied_event(&db, &event, applied).unwrap();
|
||||||
|
assert_eq!(acknowledged.as_str(), "event-missing-row");
|
||||||
|
assert_eq!(
|
||||||
|
db.get_event_cursor().unwrap().as_deref(),
|
||||||
|
Some("event-missing-row")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuity_downgrade_failure_denies_memory_and_retains_cursor() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
let root = uid("shared");
|
||||||
|
db.set_share_access(&root, Access::Editor).unwrap();
|
||||||
|
db.with_conn(|conn| {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TRIGGER reject_all_share_updates
|
||||||
|
BEFORE UPDATE ON share_access
|
||||||
|
BEGIN SELECT RAISE(FAIL, 'forced global downgrade failure'); END;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let event = DriveEvent::ContinuityLost {
|
||||||
|
id: DriveEventId::from("event-2"),
|
||||||
|
};
|
||||||
|
let denied_all = Cell::new(false);
|
||||||
|
|
||||||
|
let applied = apply_access_downgrade(&db, &event, |root| {
|
||||||
|
denied_all.set(root.is_none());
|
||||||
|
});
|
||||||
|
assert!(applied.is_err());
|
||||||
|
assert!(
|
||||||
|
denied_all.get(),
|
||||||
|
"all resident shared roots must fail closed immediately"
|
||||||
|
);
|
||||||
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
|
assert!(db.get_event_cursor().unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
RefreshScope::Photos => {
|
RefreshScope::Photos => {
|
||||||
core.invalidate_photos();
|
core.invalidate_photos();
|
||||||
|
// Whatever changed the timeline can just as easily have
|
||||||
|
// changed an album; one scope covers the whole photos view.
|
||||||
|
core.invalidate_albums();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -213,6 +216,31 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoAlbums) => match core.albums() {
|
||||||
|
Ok(Some(items)) => CtlResponse::Albums {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
Ok(None) => CtlResponse::Albums {
|
||||||
|
available: false,
|
||||||
|
items: Vec::new(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AlbumPhotos { uid, offset, limit }) => match parse_uid(&uid) {
|
||||||
|
// An album page is a timeline page as far as the front-end is
|
||||||
|
// concerned, so it comes back in the same reply — with `counts`
|
||||||
|
// unset, which describe the whole timeline rather than this album.
|
||||||
|
Some(album) => match core.album_photos(&album, offset, limit) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
@@ -561,6 +589,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Ok(items) => CtlResponse::Devices { items },
|
Ok(items) => CtlResponse::Devices { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::ListLocations) => match core.list_locations() {
|
||||||
|
Ok(items) => CtlResponse::Locations { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
|
Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: format!("renamed device to {name}"),
|
message: format!("renamed device to {name}"),
|
||||||
@@ -692,6 +724,28 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::ShareNodeByUid {
|
||||||
|
uid,
|
||||||
|
emails,
|
||||||
|
role,
|
||||||
|
message,
|
||||||
|
}) => match core.share_node_by_uid(&uid, &emails, &role, message.as_deref()) {
|
||||||
|
Ok((proton, external)) => {
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Share,
|
||||||
|
&uid,
|
||||||
|
format!("{} recipient(s) as {role}", proton + external),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: format!("invited {proton} Proton and {external} external user(s)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
core.log_activity(ActivityKind::Share, &uid, &e, false);
|
||||||
|
CtlResponse::error(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_share(&rel) {
|
Ok(rel) => match core.list_share(&rel) {
|
||||||
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||||
@@ -699,6 +753,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::ListShareByUid { uid }) => match core.list_share_by_uid(&uid) {
|
||||||
|
Ok((entries, link)) => CtlResponse::Share { entries, link },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::UpdateShareRole {
|
Ok(CtlRequest::UpdateShareRole {
|
||||||
path,
|
path,
|
||||||
id,
|
id,
|
||||||
@@ -713,6 +771,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::UpdateShareRoleByUid {
|
||||||
|
uid,
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
}) => match core.update_share_role_by_uid(&uid, &id, kind, &role) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("role updated to {role}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
|
Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => {
|
||||||
match rel_to_mount(mountpoint, &path) {
|
match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
|
Ok(rel) => match core.remove_share_entry(&rel, &id, kind) {
|
||||||
@@ -727,6 +796,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::RemoveShareEntryByUid { uid, id, kind }) => {
|
||||||
|
match core.remove_share_entry_by_uid(&uid, &id, kind) {
|
||||||
|
Ok(()) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, &uid, "access removed", true);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "removed".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::CreatePublicLink {
|
Ok(CtlRequest::CreatePublicLink {
|
||||||
path,
|
path,
|
||||||
role,
|
role,
|
||||||
@@ -745,6 +825,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreatePublicLinkByUid {
|
||||||
|
uid,
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires,
|
||||||
|
}) => match core.create_public_link_by_uid(&uid, &role, password.as_deref(), expires) {
|
||||||
|
Ok(link) => {
|
||||||
|
core.log_activity(ActivityKind::PublicLink, &uid, "link created", true);
|
||||||
|
CtlResponse::PublicLink { link }
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
core.log_activity(ActivityKind::PublicLink, &uid, &e, false);
|
||||||
|
CtlResponse::error(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
Ok(rel) => match core.remove_public_link(&rel, &id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -757,6 +852,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::RemovePublicLinkByUid { uid, id }) => {
|
||||||
|
match core.remove_public_link_by_uid(&uid, &id) {
|
||||||
|
Ok(()) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, &uid, "link removed", true);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "public link removed".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
+433
-117
@@ -11,12 +11,13 @@ use std::collections::HashMap;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use fuser::{Config, MountOption, Session};
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress,
|
ActivityKind, DeviceInfo, JobItem, MountKind, MountSpec, SyncFolderInfo, SyncPhase,
|
||||||
|
SyncProgress,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
use pdfs_core::db::{StoredDevice, StoredSyncFolder};
|
||||||
|
use pdfs_core::mounts::MountMode;
|
||||||
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};
|
||||||
@@ -25,11 +26,69 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use super::sync::{self, base_name};
|
use super::sync::{self, base_name};
|
||||||
use super::{
|
use super::{
|
||||||
BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str,
|
Core, SecondaryInsertRejection, SecondaryMount, State, SwitchBlocked, clear_stale_mount,
|
||||||
dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount, now_secs, parse_uid,
|
device_type_str, dir_is_empty, evict_dir_contents, fuse_connection_id, is_stale_mount,
|
||||||
sync_folder_info, this_hostname, umount_session_unblocked,
|
now_secs, parse_uid, spawn_session, sync_folder_info, this_hostname,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A mode that this daemon is allowed to apply.
|
||||||
|
///
|
||||||
|
/// Persisted and control-protocol values first pass through [`MountMode`] so
|
||||||
|
/// forward-compatible unknown values never reach either destructive transition.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ApplicableMountMode {
|
||||||
|
Mirror,
|
||||||
|
OnDemand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicableMountMode {
|
||||||
|
fn parse(value: &str) -> Result<Self, String> {
|
||||||
|
match MountMode::from(value) {
|
||||||
|
MountMode::Mirror => Ok(Self::Mirror),
|
||||||
|
MountMode::OnDemand => Ok(Self::OnDemand),
|
||||||
|
MountMode::Unknown => Err(format!("unknown mode {value:?} (want mirror|ondemand)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Mirror => "mirror",
|
||||||
|
Self::OnDemand => "ondemand",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_mode_transition(
|
||||||
|
current: &str,
|
||||||
|
target: &str,
|
||||||
|
) -> Result<Option<ApplicableMountMode>, String> {
|
||||||
|
let current = ApplicableMountMode::parse(current)
|
||||||
|
.map_err(|error| format!("unsupported current mode: {error}"))?;
|
||||||
|
let target = ApplicableMountMode::parse(target)
|
||||||
|
.map_err(|error| format!("unsupported target mode: {error}"))?;
|
||||||
|
Ok((current != target).then_some(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_snapshot_matches(snapshot: &StoredSyncFolder, current: &StoredSyncFolder) -> bool {
|
||||||
|
current.id == snapshot.id
|
||||||
|
&& current.local_path == snapshot.local_path
|
||||||
|
&& current.remote_uid == snapshot.remote_uid
|
||||||
|
&& current.remote_share_id == snapshot.remote_share_id
|
||||||
|
&& current.mode == "ondemand"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_intent_matches(pending: Option<&str>, expected: Option<&str>) -> bool {
|
||||||
|
expected.is_none_or(|expected| pending == Some(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_after_teardown<T, E>(
|
||||||
|
teardown: Result<(), E>,
|
||||||
|
commit: impl FnOnce() -> T,
|
||||||
|
) -> Result<T, E> {
|
||||||
|
teardown?;
|
||||||
|
Ok(commit())
|
||||||
|
}
|
||||||
|
|
||||||
/// The device uid this machine has been adopted into, from `config.json`.
|
/// The device uid this machine has been adopted into, from `config.json`.
|
||||||
///
|
///
|
||||||
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
|
/// Read fresh each time rather than cached on [`Core`]: adoption rewrites the
|
||||||
@@ -57,6 +116,22 @@ pub(crate) fn set_adopted_device_uid(uid: Option<&str>) -> CoreResult<()> {
|
|||||||
impl Core {
|
impl Core {
|
||||||
// ---- devices ----------------------------------------------------------
|
// ---- devices ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Unified local-location listing for the control protocol.
|
||||||
|
pub(crate) fn list_locations(&self) -> CoreResult<Vec<MountSpec>> {
|
||||||
|
let progress = self.sync_progress.lock();
|
||||||
|
let mut locations = self
|
||||||
|
.db
|
||||||
|
.mount_list()
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
for location in &mut locations {
|
||||||
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
|
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||||
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(locations)
|
||||||
|
}
|
||||||
|
|
||||||
/// List the account's registered devices, flagging the one *this* machine
|
/// List the account's registered devices, flagging the one *this* machine
|
||||||
/// syncs to so a front-end can treat it as more than another computer in the
|
/// syncs to so a front-end can treat it as more than another computer in the
|
||||||
/// list — deleting it takes this machine's synced folders down with it.
|
/// list — deleting it takes this machine's synced folders down with it.
|
||||||
@@ -308,6 +383,15 @@ impl Core {
|
|||||||
})?
|
})?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
// Syncing a local `.proton-drive-linux` would land on the folder holding
|
||||||
|
// this device's own profile (`find_device_child_folder` reuses by name),
|
||||||
|
// and the sync engine would then delete or overwrite it.
|
||||||
|
if name == pdfs_core::profile::PROFILE_DIR_NAME {
|
||||||
|
return Err(CoreError::invalid(format!(
|
||||||
|
"{name} is reserved for this device's profile backup"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
let device = self.ensure_device()?;
|
let device = self.ensure_device()?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
@@ -439,6 +523,8 @@ impl Core {
|
|||||||
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
|
/// Remove a synced folder from the sync set. `delete_remote` also deletes its
|
||||||
/// folder under the device root; otherwise the cloud copy is left in place.
|
/// folder under the device root; otherwise the cloud copy is left in place.
|
||||||
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
|
pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> {
|
||||||
|
let lock = self.sync_lock(id);
|
||||||
|
let _guard = lock.lock();
|
||||||
let folder = self
|
let folder = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
@@ -449,19 +535,16 @@ impl Core {
|
|||||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||||
// Tear it down first — including before trashing the remote tree it
|
// Tear it down first — including before trashing the remote tree it
|
||||||
// serves, which would otherwise leave it answering for deleted nodes.
|
// serves, which would otherwise leave it answering for deleted nodes.
|
||||||
if let Some((session, conn)) = self.mounts.lock().remove(&id) {
|
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||||
if let Err(e) = umount_session_unblocked(session, conn) {
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
|
||||||
} else {
|
|
||||||
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if delete_remote
|
|
||||||
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
|
||||||
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
|
|
||||||
{
|
{
|
||||||
warn!(id, error = %e, "delete remote device folder failed");
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"cannot remove synced folder: unmount {} failed: {error}",
|
||||||
|
folder.local_path
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
info!(id, path = %folder.local_path, "unmounted on-demand folder");
|
||||||
if !self
|
if !self
|
||||||
.db
|
.db
|
||||||
.sync_folder_remove(id)
|
.sync_folder_remove(id)
|
||||||
@@ -469,8 +552,15 @@ impl Core {
|
|||||||
{
|
{
|
||||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||||
}
|
}
|
||||||
|
drop(_guard);
|
||||||
// Stop watching the folder we just dropped.
|
// Stop watching the folder we just dropped.
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
if delete_remote
|
||||||
|
&& let Some(uid) = parse_uid(&folder.remote_uid)
|
||||||
|
&& let Err(e) = self.rt.block_on(self.client.trash_nodes(&[uid]))
|
||||||
|
{
|
||||||
|
warn!(id, error = %e, "delete remote device folder failed");
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +579,8 @@ impl Core {
|
|||||||
/// (devices.md Phase 3).
|
/// (devices.md Phase 3).
|
||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
let mut fork = self.clone();
|
let mut fork = self.clone();
|
||||||
|
fork.primary = false;
|
||||||
|
let share_access = self.state.lock().share_access.clone();
|
||||||
fork.state = Arc::new(Mutex::new(State {
|
fork.state = Arc::new(Mutex::new(State {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
by_uid: HashMap::new(),
|
by_uid: HashMap::new(),
|
||||||
@@ -497,12 +589,15 @@ impl Core {
|
|||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
|
access_changes: std::collections::HashSet::new(),
|
||||||
|
share_access,
|
||||||
db: self.db.clone(),
|
db: self.db.clone(),
|
||||||
}));
|
}));
|
||||||
// A fresh inode space needs a fresh notification channel: this fork's
|
// A fresh inode space needs a fresh notification channel: this fork's
|
||||||
// session is the only one that knows these inodes, so it must be the one
|
// session is the only one that knows these inodes, so it must be the one
|
||||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||||
fork.notifier = Arc::new(OnceLock::new());
|
fork.notifier = Arc::new(OnceLock::new());
|
||||||
|
fork.session_live = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
// Size upgrades are keyed by inode, which is per-fork too.
|
// Size upgrades are keyed by inode, which is per-fork too.
|
||||||
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
fork.size_upgrades = Arc::new(Mutex::new(HashMap::new()));
|
||||||
fork
|
fork
|
||||||
@@ -520,49 +615,84 @@ impl Core {
|
|||||||
/// running — which, seeing a queued `ondemand`, also stops pulling down files
|
/// running — which, seeing a queued `ondemand`, also stops pulling down files
|
||||||
/// it is about to evict ([`Core::push_pass`]).
|
/// it is about to evict ([`Core::push_pass`]).
|
||||||
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
|
pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult<String> {
|
||||||
if mode != "mirror" && mode != "ondemand" {
|
// Preserve the existing control behavior: reject an unsupported request
|
||||||
return Err(CoreError::invalid(format!(
|
// before looking up the folder, then validate the persisted source too.
|
||||||
"unknown mode {mode:?} (want mirror|ondemand)"
|
let target_mode = ApplicableMountMode::parse(mode).map_err(CoreError::invalid)?;
|
||||||
)));
|
|
||||||
}
|
|
||||||
let folder = self
|
let folder = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
if folder.mode == mode {
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
// Already there. A queued switch the other way is the user changing
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
// their mind back before it landed, so withdraw it.
|
if current_mode == target_mode {
|
||||||
if folder.pending_mode.is_some() {
|
let mode_str = target_mode.as_str();
|
||||||
self.db
|
let lock = self.sync_lock(id);
|
||||||
.sync_folder_set_pending_mode(id, None)
|
match lock.try_lock() {
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
Some(_guard) => {
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let current = self
|
||||||
return Ok(format!("staying {mode}"));
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CoreError::not_found(format!("no synced folder with id {id}"))
|
||||||
|
})?;
|
||||||
|
let locked_mode =
|
||||||
|
ApplicableMountMode::parse(¤t.mode).map_err(|error| {
|
||||||
|
CoreError::invalid(format!("unsupported current mode: {error}"))
|
||||||
|
})?;
|
||||||
|
if locked_mode == target_mode {
|
||||||
|
self.db
|
||||||
|
.sync_folder_set_pending_mode(id, None)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
|
return Ok(if current.pending_mode.is_some() {
|
||||||
|
format!("staying {mode_str}")
|
||||||
|
} else {
|
||||||
|
format!("already {mode_str}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// A transition or reconcile owns the folder lock. Record
|
||||||
|
// this request instead of clearing the old target: if a
|
||||||
|
// queued switch already passed its recheck, its mode write
|
||||||
|
// preserves this opposite marker and the engine switches
|
||||||
|
// back next.
|
||||||
|
self.db
|
||||||
|
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
|
return Ok(format!("staying {mode_str}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(format!("already {mode}"));
|
|
||||||
}
|
}
|
||||||
|
let mode = target_mode;
|
||||||
|
let mode_str = target_mode.as_str();
|
||||||
|
|
||||||
match self.apply_sync_folder_mode(id, mode) {
|
match self.apply_sync_folder_mode(id, mode, None) {
|
||||||
Ok(message) => Ok(message),
|
Ok(message) => Ok(message),
|
||||||
// Not switchable this instant — remember the intent instead of making
|
// Not switchable this instant — remember the intent instead of making
|
||||||
// the user retry, and kick a pass to clear whatever is in the way.
|
// the user retry, and kick a pass to clear whatever is in the way.
|
||||||
Err(SwitchBlocked::NotNow) => {
|
Err(SwitchBlocked::NotNow) => {
|
||||||
self.db
|
self.db
|
||||||
.sync_folder_set_pending_mode(id, Some(mode))
|
.sync_folder_set_pending_mode(id, Some(mode_str))
|
||||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?;
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
Ok(match mode {
|
Ok(match mode {
|
||||||
"ondemand" => format!(
|
ApplicableMountMode::OnDemand => format!(
|
||||||
"{} will go on-demand once its local changes are uploaded",
|
"{} will go on-demand once its local changes are uploaded",
|
||||||
base_name(&folder.local_path)
|
base_name(&folder.local_path)
|
||||||
),
|
),
|
||||||
_ => format!(
|
ApplicableMountMode::Mirror => format!(
|
||||||
"{} will start mirroring once the current sync finishes",
|
"{} will start mirroring once the current sync finishes",
|
||||||
base_name(&folder.local_path)
|
base_name(&folder.local_path)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Err(SwitchBlocked::Superseded) => Err(CoreError::internal(
|
||||||
|
"direct mode request was unexpectedly superseded",
|
||||||
|
)),
|
||||||
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
|
Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,27 +706,45 @@ impl Core {
|
|||||||
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
|
let Ok(Some(folder)) = self.db.sync_folder_get(id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(mode) = folder.pending_mode.as_deref() else {
|
let Some(expected_mode) = folder.pending_mode.as_deref() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if folder.mode == mode {
|
let mode = match select_mode_transition(&folder.mode, expected_mode) {
|
||||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
Ok(Some(mode)) => mode,
|
||||||
return;
|
Ok(None) => {
|
||||||
}
|
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||||
match self.apply_sync_folder_mode(id, mode) {
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
id,
|
||||||
|
current_mode = %folder.mode,
|
||||||
|
pending_mode = %expected_mode,
|
||||||
|
%error,
|
||||||
|
"withdrawing unsafe queued mode transition"
|
||||||
|
);
|
||||||
|
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mode_str = mode.as_str();
|
||||||
|
match self.apply_sync_folder_mode(id, mode, Some(expected_mode)) {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
info!(id, mode, "applied queued mode switch");
|
info!(id, mode = mode_str, "applied queued mode switch");
|
||||||
self.log_activity(ActivityKind::Sync, &message, "", true);
|
self.log_activity(ActivityKind::Sync, &message, "", true);
|
||||||
}
|
}
|
||||||
// Still blocked: the pass could not get everything up, so the local copy
|
// Still blocked: the pass could not get everything up, so the local copy
|
||||||
// is not safe to evict yet. Leave the request standing — the next pass
|
// is not safe to evict yet. Leave the request standing — the next pass
|
||||||
// (poll, or the retry the engine schedules) tries again.
|
// (poll, or the retry the engine schedules) tries again.
|
||||||
Err(SwitchBlocked::NotNow) => {
|
Err(SwitchBlocked::NotNow) => {
|
||||||
info!(id, mode, "queued mode switch still waiting");
|
info!(id, mode = mode_str, "queued mode switch still waiting");
|
||||||
|
}
|
||||||
|
Err(SwitchBlocked::Superseded) => {
|
||||||
|
info!(id, mode = mode_str, "queued mode switch was replaced");
|
||||||
}
|
}
|
||||||
Err(SwitchBlocked::Failed(e)) => {
|
Err(SwitchBlocked::Failed(e)) => {
|
||||||
warn!(id, mode, error = %e, "queued mode switch failed; withdrawing");
|
warn!(id, mode = mode_str, error = %e, "queued mode switch failed; withdrawing");
|
||||||
let _ = self.db.sync_folder_set_pending_mode(id, None);
|
let _ = self.db.sync_folder_clear_pending_mode_if(id, expected_mode);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Sync,
|
ActivityKind::Sync,
|
||||||
format!("couldn't switch {}", base_name(&folder.local_path)),
|
format!("couldn't switch {}", base_name(&folder.local_path)),
|
||||||
@@ -622,8 +770,10 @@ impl Core {
|
|||||||
pub(crate) fn apply_sync_folder_mode(
|
pub(crate) fn apply_sync_folder_mode(
|
||||||
&self,
|
&self,
|
||||||
id: i64,
|
id: i64,
|
||||||
mode: &str,
|
mode: ApplicableMountMode,
|
||||||
|
expected_pending: Option<&str>,
|
||||||
) -> Result<String, SwitchBlocked> {
|
) -> Result<String, SwitchBlocked> {
|
||||||
|
let mode_str = mode.as_str();
|
||||||
// Hold the folder's lock for the whole switch so no reconcile pass can be
|
// Hold the folder's lock for the whole switch so no reconcile pass can be
|
||||||
// running over the tree we are about to evict (or start while we mount over
|
// running over the tree we are about to evict (or start while we mount over
|
||||||
// it). A pass in flight holds the lock for its full duration, so `try_lock`
|
// it). A pass in flight holds the lock for its full duration, so `try_lock`
|
||||||
@@ -641,13 +791,18 @@ impl Core {
|
|||||||
.sync_folder_get(id)
|
.sync_folder_get(id)
|
||||||
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
.map_err(|e| SwitchBlocked::Failed(format!("db: {e:?}")))?
|
||||||
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| SwitchBlocked::Failed(format!("no synced folder with id {id}")))?;
|
||||||
if folder.mode == mode {
|
if !pending_intent_matches(folder.pending_mode.as_deref(), expected_pending) {
|
||||||
return Ok(format!("already {mode}"));
|
return Err(SwitchBlocked::Superseded);
|
||||||
|
}
|
||||||
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
|
.map_err(|e| SwitchBlocked::Failed(format!("unsupported current mode: {e}")))?;
|
||||||
|
if current_mode == mode {
|
||||||
|
return Ok(format!("already {mode_str}"));
|
||||||
}
|
}
|
||||||
let local = PathBuf::from(&folder.local_path);
|
let local = PathBuf::from(&folder.local_path);
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
"ondemand" => {
|
ApplicableMountMode::OnDemand => {
|
||||||
// Only flip a folder that is fully in sync — a failed reconcile means
|
// Only flip a folder that is fully in sync — a failed reconcile means
|
||||||
// local edits could still be un-uploaded, and we are about to delete
|
// local edits could still be un-uploaded, and we are about to delete
|
||||||
// the local copy. Not an error: a pass makes this true, and the queued
|
// the local copy. Not an error: a pass makes this true, and the queued
|
||||||
@@ -694,25 +849,28 @@ impl Core {
|
|||||||
if let Err(e) = evict_dir_contents(&local) {
|
if let Err(e) = evict_dir_contents(&local) {
|
||||||
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
|
warn!(id, path = %local.display(), error = %e, "evict local dir contents failed");
|
||||||
}
|
}
|
||||||
let session = self.spawn_ondemand_mount(&local, root).map_err(|e| {
|
let mount = self.spawn_ondemand_mount(&local, root).map_err(|e| {
|
||||||
SwitchBlocked::Failed(format!(
|
SwitchBlocked::Failed(format!(
|
||||||
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
|
"mount ondemand failed after local cache eviction; switch back to mirror to restore it: {e}"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
self.mounts
|
self.publish_secondary_mount(id, mount)
|
||||||
.lock()
|
.map_err(SwitchBlocked::Failed)?;
|
||||||
.insert(id, (session, fuse_connection_id(&local)));
|
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
|
self.db.sync_folder_set_state(id, "idle", now_secs()).ok();
|
||||||
info!(id, path = %local.display(), "mounted sync folder on-demand");
|
info!(id, path = %local.display(), "mounted sync folder on-demand");
|
||||||
Ok(format!("{} is now on-demand", local.display()))
|
Ok(format!("{} is now on-demand", local.display()))
|
||||||
}
|
}
|
||||||
_ => {
|
ApplicableMountMode::Mirror => {
|
||||||
// ondemand→mirror: tear down the secondary mount first.
|
// ondemand→mirror: tear down the secondary mount first.
|
||||||
if let Some((session, conn)) = self.mounts.lock().remove(&id)
|
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||||
&& let Err(e) = umount_session_unblocked(session, conn)
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
|
return Err(SwitchBlocked::Failed(format!(
|
||||||
|
"unmount {} failed; folder remains on-demand: {error}",
|
||||||
|
local.display()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
// The evicted local tree makes the old baseline lie ("everything
|
// The evicted local tree makes the old baseline lie ("everything
|
||||||
// deleted locally"); clear it so the reconcile is a pure download.
|
// deleted locally"); clear it so the reconcile is a pure download.
|
||||||
@@ -740,23 +898,52 @@ impl Core {
|
|||||||
&self,
|
&self,
|
||||||
local: &Path,
|
local: &Path,
|
||||||
root: Node,
|
root: Node,
|
||||||
) -> CoreResult<BackgroundSession> {
|
) -> CoreResult<SecondaryMount> {
|
||||||
clear_stale_mount(local);
|
|
||||||
let mut config = Config::default();
|
|
||||||
config.mount_options = vec![
|
|
||||||
MountOption::FSName("protondrive".to_string()),
|
|
||||||
MountOption::Subtype("protondrive".to_string()),
|
|
||||||
MountOption::DefaultPermissions,
|
|
||||||
];
|
|
||||||
let core = self.fork_state();
|
let core = self.fork_state();
|
||||||
let fs = ProtonFs::new(core.clone(), root);
|
let session_live = core.session_live.clone();
|
||||||
let session = Session::new(fs, local, &config)
|
// The fork owns this state/path registration. `spawn_session` only
|
||||||
.and_then(|s| s.spawn())
|
// marks it live after FUSE has spawned successfully.
|
||||||
|
core.register_state(local);
|
||||||
|
let session = spawn_session(&core, local, root)
|
||||||
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
|
.map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display())))?;
|
||||||
// Hand this fork its own session's notification channel, so a background
|
Ok(SecondaryMount::new(
|
||||||
// size upgrade can drop the kernel's cached attrs for these inodes.
|
session,
|
||||||
let _ = core.notifier.set(session.notifier());
|
fuse_connection_id(local),
|
||||||
Ok(session)
|
session_live,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish one newly spawned secondary or explicitly tear it down when
|
||||||
|
/// shutdown has closed publication or another session already owns the id.
|
||||||
|
fn publish_secondary_mount(&self, id: i64, mount: SecondaryMount) -> Result<(), String> {
|
||||||
|
let rejected = self.mounts.lock().insert(id, mount);
|
||||||
|
let Err((reason, mount)) = rejected else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let reason = match reason {
|
||||||
|
SecondaryInsertRejection::Closed => "daemon is shutting down",
|
||||||
|
SecondaryInsertRejection::Duplicate => "folder already has a live mount",
|
||||||
|
};
|
||||||
|
match mount.teardown() {
|
||||||
|
Ok(()) => Err(reason.to_string()),
|
||||||
|
Err(error) => Err(format!("{reason}; rejected mount teardown failed: {error}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_restore_error_if_current(&self, snapshot: &StoredSyncFolder) {
|
||||||
|
let lock = self.sync_lock(snapshot.id);
|
||||||
|
let _guard = lock.lock();
|
||||||
|
if self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(snapshot.id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some_and(|current| restore_snapshot_matches(snapshot, ¤t))
|
||||||
|
{
|
||||||
|
let _ = self
|
||||||
|
.db
|
||||||
|
.sync_folder_set_state(snapshot.id, "error", now_secs());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
|
/// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon
|
||||||
@@ -777,14 +964,57 @@ impl Core {
|
|||||||
if folder.mode != "ondemand" {
|
if folder.mode != "ondemand" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let local = PathBuf::from(&folder.local_path);
|
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
||||||
// A daemon killed before it could unmount (systemd's stop timeout,
|
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
||||||
// say) leaves this path attached to a dead FUSE connection: it still
|
self.mark_restore_error_if_current(&folder);
|
||||||
// exists, but every stat on it fails with ENOTCONN — so `is_dir()`
|
continue;
|
||||||
// reads it as *missing* and the folder gets parked in `error` on
|
};
|
||||||
// every subsequent start, with no way back but a manual
|
let root = match self
|
||||||
// `fusermount -u`. Detach the corpse first; the directory
|
.rt
|
||||||
// underneath then restores normally.
|
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
||||||
|
{
|
||||||
|
Ok(v) => match v.into_iter().next() {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
warn!(id = folder.id, "restore on-demand: remote folder gone");
|
||||||
|
self.mark_restore_error_if_current(&folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
||||||
|
self.mark_restore_error_if_current(&folder);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let lock = self.sync_lock(folder.id);
|
||||||
|
let _guard = lock.lock();
|
||||||
|
let current = match self.db.sync_folder_get(folder.id) {
|
||||||
|
Ok(Some(current)) if restore_snapshot_matches(&folder, ¤t) => current,
|
||||||
|
Ok(_) => {
|
||||||
|
info!(
|
||||||
|
id = folder.id,
|
||||||
|
"restore on-demand: folder changed while fetching"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(
|
||||||
|
id = folder.id,
|
||||||
|
?error,
|
||||||
|
"restore on-demand: cannot re-read folder"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !self.mounts.lock().is_accepting() {
|
||||||
|
info!(id = folder.id, "restore on-demand: shutdown in progress");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let local = PathBuf::from(¤t.local_path);
|
||||||
|
// A daemon killed before it could unmount can leave a dead FUSE
|
||||||
|
// connection over the path. Clear it only after the row identity
|
||||||
|
// and mode have been revalidated under the folder lock.
|
||||||
if is_stale_mount(&local) {
|
if is_stale_mount(&local) {
|
||||||
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
|
warn!(id = folder.id, path = %local.display(), "restore on-demand: clearing stale mount");
|
||||||
clear_stale_mount(&local);
|
clear_stale_mount(&local);
|
||||||
@@ -796,11 +1026,6 @@ impl Core {
|
|||||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
.sync_folder_set_state(folder.id, "error", now_secs());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// An `ondemand` folder's local dir is empty by construction — the switch
|
|
||||||
// evicts it. Finding files there means the row is lying (a switch that
|
|
||||||
// died between persisting the mode and evicting), and mounting over them
|
|
||||||
// would hide real local data behind the remote tree. Leave the files
|
|
||||||
// alone and let the user resolve it.
|
|
||||||
match dir_is_empty(&local) {
|
match dir_is_empty(&local) {
|
||||||
Ok(true) => {}
|
Ok(true) => {}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
@@ -822,40 +1047,16 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some(root_uid) = parse_uid(&folder.remote_uid) else {
|
match self.spawn_ondemand_mount(&local, root) {
|
||||||
warn!(id = folder.id, "restore on-demand: bad remote uid");
|
Ok(mount) => match self.publish_secondary_mount(folder.id, mount) {
|
||||||
continue;
|
Ok(()) => {
|
||||||
};
|
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
||||||
let root = match self
|
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
||||||
.rt
|
}
|
||||||
.block_on(self.client.enumerate_nodes(std::slice::from_ref(&root_uid)))
|
Err(error) => {
|
||||||
{
|
warn!(id = folder.id, %error, "restore on-demand: mount rejected");
|
||||||
Ok(v) => match v.into_iter().next() {
|
|
||||||
Some(n) => n,
|
|
||||||
None => {
|
|
||||||
warn!(id = folder.id, "restore on-demand: remote folder gone");
|
|
||||||
let _ = self
|
|
||||||
.db
|
|
||||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
|
||||||
warn!(id = folder.id, error = %e, "restore on-demand: fetch remote failed");
|
|
||||||
let _ = self
|
|
||||||
.db
|
|
||||||
.sync_folder_set_state(folder.id, "error", now_secs());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match self.spawn_ondemand_mount(&local, root) {
|
|
||||||
Ok(session) => {
|
|
||||||
self.mounts
|
|
||||||
.lock()
|
|
||||||
.insert(folder.id, (session, fuse_connection_id(&local)));
|
|
||||||
let _ = self.db.sync_folder_set_state(folder.id, "idle", now_secs());
|
|
||||||
info!(id = folder.id, path = %local.display(), "remounted on-demand folder");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
|
warn!(id = folder.id, error = %e, "restore on-demand: mount failed");
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -866,3 +1067,118 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use pdfs_core::db::StoredSyncFolder;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ApplicableMountMode, commit_after_teardown, pending_intent_matches,
|
||||||
|
restore_snapshot_matches, select_mode_transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_mode_cannot_select_a_destructive_transition() {
|
||||||
|
let mut unmounts = 0;
|
||||||
|
let mut baseline_clears = 0;
|
||||||
|
let mut mode_writes = 0;
|
||||||
|
if let Ok(mode) = ApplicableMountMode::parse("streaming") {
|
||||||
|
match mode {
|
||||||
|
ApplicableMountMode::Mirror => {
|
||||||
|
unmounts += 1;
|
||||||
|
baseline_clears += 1;
|
||||||
|
mode_writes += 1;
|
||||||
|
}
|
||||||
|
ApplicableMountMode::OnDemand => {
|
||||||
|
mode_writes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!((unmounts, baseline_clears, mode_writes), (0, 0, 0));
|
||||||
|
assert_eq!(
|
||||||
|
ApplicableMountMode::parse("streaming"),
|
||||||
|
Err("unknown mode \"streaming\" (want mirror|ondemand)".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ApplicableMountMode::parse("mirror"),
|
||||||
|
Ok(ApplicableMountMode::Mirror)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ApplicableMountMode::parse("ondemand"),
|
||||||
|
Ok(ApplicableMountMode::OnDemand)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_source_mode_with_pending_mirror_cannot_select_a_transition() {
|
||||||
|
let mut destructive_helper_reached = false;
|
||||||
|
let selected = select_mode_transition("streaming", "mirror");
|
||||||
|
if let Ok(Some(_)) = selected {
|
||||||
|
destructive_helper_reached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(!destructive_helper_reached);
|
||||||
|
assert_eq!(
|
||||||
|
selected,
|
||||||
|
Err(
|
||||||
|
"unsupported current mode: unknown mode \"streaming\" (want mirror|ondemand)"
|
||||||
|
.to_string()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder(local_path: &str, remote_uid: &str, mode: &str) -> StoredSyncFolder {
|
||||||
|
StoredSyncFolder {
|
||||||
|
id: 7,
|
||||||
|
local_path: local_path.to_string(),
|
||||||
|
remote_uid: remote_uid.to_string(),
|
||||||
|
remote_share_id: "share".to_string(),
|
||||||
|
mode: mode.to_string(),
|
||||||
|
pending_mode: None,
|
||||||
|
state: "idle".to_string(),
|
||||||
|
last_sync: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_restore_identity_or_mode_is_rejected() {
|
||||||
|
let snapshot = folder("/mnt/folder", "volume~node", "ondemand");
|
||||||
|
assert!(restore_snapshot_matches(
|
||||||
|
&snapshot,
|
||||||
|
&folder("/mnt/folder", "volume~node", "ondemand")
|
||||||
|
));
|
||||||
|
assert!(!restore_snapshot_matches(
|
||||||
|
&snapshot,
|
||||||
|
&folder("/mnt/replaced", "volume~node", "ondemand")
|
||||||
|
));
|
||||||
|
assert!(!restore_snapshot_matches(
|
||||||
|
&snapshot,
|
||||||
|
&folder("/mnt/folder", "volume~other", "ondemand")
|
||||||
|
));
|
||||||
|
assert!(!restore_snapshot_matches(
|
||||||
|
&snapshot,
|
||||||
|
&folder("/mnt/folder", "volume~node", "mirror")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn teardown_failure_prevents_follow_up_state_changes() {
|
||||||
|
let mut commits = 0;
|
||||||
|
let result = commit_after_teardown::<_, &str>(Err("busy"), || {
|
||||||
|
commits += 1;
|
||||||
|
});
|
||||||
|
assert_eq!(result, Err("busy"));
|
||||||
|
assert_eq!(commits, 0);
|
||||||
|
|
||||||
|
commit_after_teardown::<_, &str>(Ok(()), || commits += 1).unwrap();
|
||||||
|
assert_eq!(commits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canceled_or_replaced_queued_intent_cannot_transition() {
|
||||||
|
assert!(pending_intent_matches(Some("ondemand"), Some("ondemand")));
|
||||||
|
assert!(!pending_intent_matches(None, Some("ondemand")));
|
||||||
|
assert!(!pending_intent_matches(Some("mirror"), Some("ondemand")));
|
||||||
|
assert!(pending_intent_matches(Some("mirror"), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+533
-55
@@ -27,7 +27,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp};
|
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||||
use proton_drive_rs::Node;
|
use proton_drive_rs::Node;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
@@ -36,10 +36,82 @@ use super::state::Intervals;
|
|||||||
use super::transfers::CountingReader;
|
use super::transfers::CountingReader;
|
||||||
use super::{
|
use super::{
|
||||||
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
|
Core, DRAIN_BACKOFF_MAX, DRAIN_BACKOFF_MIN, DRAIN_IDLE_POLL, ROOT_INO, conflict_name,
|
||||||
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_size, now_millis, now_secs,
|
is_already_exists, is_gone, is_local_uid_str, media_type_for, node_revision_id, node_size,
|
||||||
parse_node_uid,
|
now_millis, now_secs, parse_node_uid,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DRAIN_ACCESS_RECHECK: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum DrainDisposition {
|
||||||
|
Applied,
|
||||||
|
AccessDeferred,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_op_authorities(op: &PendingOp) -> Result<Vec<NodeUid>, Box<dyn std::error::Error>> {
|
||||||
|
let uid = || -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||||
|
parse_node_uid(&op.uid)
|
||||||
|
.ok_or_else(|| Box::<dyn std::error::Error>::from("pending op has an unparseable uid"))
|
||||||
|
};
|
||||||
|
let parent = || -> Result<NodeUid, Box<dyn std::error::Error>> {
|
||||||
|
let parent = op
|
||||||
|
.parent_uid
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("pending op has no parent authority")?;
|
||||||
|
parse_node_uid(parent).ok_or_else(|| {
|
||||||
|
Box::<dyn std::error::Error>::from("pending op has an unparseable parent")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match op.kind.as_str() {
|
||||||
|
OP_REVISION | OP_TRASH => Ok(vec![uid()?]),
|
||||||
|
OP_CREATE | OP_MKDIR => Ok(vec![parent()?]),
|
||||||
|
OP_RENAME => {
|
||||||
|
let mut authorities = vec![uid()?];
|
||||||
|
if let Some(json) = op.meta_json.as_deref() {
|
||||||
|
let meta: RenameMeta = serde_json::from_str(json)?;
|
||||||
|
authorities.push(
|
||||||
|
parse_node_uid(&meta.original_parent_uid)
|
||||||
|
.ok_or("rename op has an unparseable original parent")?,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Rows written by 1.1.1 predate foreign shared mounts. Their
|
||||||
|
// source tree was necessarily owned, so the node plus desired
|
||||||
|
// destination are the complete available authority set.
|
||||||
|
}
|
||||||
|
authorities.push(parent()?);
|
||||||
|
authorities.dedup();
|
||||||
|
Ok(authorities)
|
||||||
|
}
|
||||||
|
other => Err(format!("unknown pending op kind {other:?}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_authorized_drain(
|
||||||
|
op: &PendingOp,
|
||||||
|
mut require: impl FnMut(&NodeUid) -> Result<(), fuser::Errno>,
|
||||||
|
apply: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
|
||||||
|
) -> Result<DrainDisposition, Box<dyn std::error::Error>> {
|
||||||
|
// This check is the admission/linearization point. A downgrade that lands
|
||||||
|
// first defers the op; one observed afterwards does not revoke this admitted
|
||||||
|
// attempt. Every later queued operation rechecks. No permission lock is
|
||||||
|
// held across remote latency.
|
||||||
|
for authority in pending_op_authorities(op)? {
|
||||||
|
match require(&authority) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(error) if error.code() == libc::EACCES => {
|
||||||
|
return Ok(DrainDisposition::AccessDeferred);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(
|
||||||
|
format!("drain access check failed with errno {}", error.code()).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply()?;
|
||||||
|
Ok(DrainDisposition::Applied)
|
||||||
|
}
|
||||||
|
|
||||||
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).
|
/// (offline.md Phase 3).
|
||||||
@@ -69,7 +141,30 @@ impl Core {
|
|||||||
self.wait_for_drain_work_or_due();
|
self.wait_for_drain_work_or_due();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Err(e) = self.drain_op(&op) {
|
let outcome = run_authorized_drain(
|
||||||
|
&op,
|
||||||
|
|uid| self.require_uid_writable(uid),
|
||||||
|
|| self.drain_op(&op),
|
||||||
|
);
|
||||||
|
if matches!(outcome, Ok(DrainDisposition::AccessDeferred)) {
|
||||||
|
let next_attempt_at = now_millis() + DRAIN_ACCESS_RECHECK.as_millis() as i64;
|
||||||
|
if let Err(error) = self.db.defer_op_without_attempt(op.id, next_attempt_at) {
|
||||||
|
error!(
|
||||||
|
uid = %op.uid,
|
||||||
|
%error,
|
||||||
|
"deferring access-blocked pending operation failed"
|
||||||
|
);
|
||||||
|
self.wait_for_drain_work();
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
uid = %op.uid,
|
||||||
|
next_attempt_at,
|
||||||
|
"pending operation deferred until access is writable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = outcome {
|
||||||
let attempts = op.attempts + 1;
|
let attempts = op.attempts + 1;
|
||||||
let err_str = e.to_string();
|
let err_str = e.to_string();
|
||||||
// Never infer that user data is disposable from an error string
|
// Never infer that user data is disposable from an error string
|
||||||
@@ -229,6 +324,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
|
self.note_self_change(&uid);
|
||||||
info!(%uid, name = %landed, "pending rename landed");
|
info!(%uid, name = %landed, "pending rename landed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -251,7 +347,8 @@ impl Core {
|
|||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
self.db.delete_op(op.id)?;
|
self.db.complete_trash_op(op.id, &uid)?;
|
||||||
|
self.own_sealed_revs.lock().remove(&uid);
|
||||||
self.invalidate_trash();
|
self.invalidate_trash();
|
||||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||||
info!(%uid, name, "pending trash landed");
|
info!(%uid, name, "pending trash landed");
|
||||||
@@ -262,18 +359,19 @@ impl Core {
|
|||||||
/// it away from the one the user asked for. Best effort: the event sync
|
/// it away from the one the user asked for. Best effort: the event sync
|
||||||
/// would correct the tree anyway, but not before the user has looked at it.
|
/// would correct the tree anyway, but not before the user has looked at it.
|
||||||
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
pub(crate) fn adopt_drained_name(&self, uid: &NodeUid, name: &str) {
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(entry) = st.entries.get_mut(&ino) else {
|
let Some(entry) = st.entries.get_mut(&ino) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
entry.node.name = name.to_string();
|
entry.node.name = name.to_string();
|
||||||
let node = entry.node.clone();
|
let node = entry.node.clone();
|
||||||
if let Err(e) = st.db.upsert_node(&node) {
|
if let Err(e) = st.db.upsert_node(&node) {
|
||||||
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
warn!(%uid, error = %e, "db upsert_node failed after a conflict rename");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Make a node that so far exists only on this machine real, and adopt the
|
/// Make a node that so far exists only on this machine real, and adopt the
|
||||||
@@ -342,6 +440,9 @@ impl Core {
|
|||||||
// sync, whereas a surviving op would create the file a second time.
|
// sync, whereas a surviving op would create the file a second time.
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.adopt_real_uid(&local, &real)?;
|
self.adopt_real_uid(&local, &real)?;
|
||||||
|
// The feed will report this create back to us; the tree already has it
|
||||||
|
// under its real uid, so that event is ours to ignore (`Core::self_changes`).
|
||||||
|
self.note_self_change(&real);
|
||||||
if let Some(blob) = op.blob_path.as_deref() {
|
if let Some(blob) = op.blob_path.as_deref() {
|
||||||
self.cache.discard_staged(Path::new(blob));
|
self.cache.discard_staged(Path::new(blob));
|
||||||
}
|
}
|
||||||
@@ -418,6 +519,14 @@ impl Core {
|
|||||||
///
|
///
|
||||||
/// The inode is deliberately kept, so anything already holding the file open
|
/// The inode is deliberately kept, so anything already holding the file open
|
||||||
/// keeps working across the drain.
|
/// keeps working across the drain.
|
||||||
|
///
|
||||||
|
/// Applied to **every** live inode space, not just this Core's. A node
|
||||||
|
/// created inside an on-demand sync folder lives in that fork's `state`,
|
||||||
|
/// while the drain that lands its create belongs to the primary mount — so
|
||||||
|
/// reaching for `self.state` here found nothing and left the fork's entry on
|
||||||
|
/// its `local~` uid, which reads as an empty file for as long as the daemon
|
||||||
|
/// runs (`docs/BUGS.md` B74). A uid is unique across mounts, so at most one
|
||||||
|
/// of them matches and the others are no-ops.
|
||||||
pub(crate) fn adopt_real_uid(
|
pub(crate) fn adopt_real_uid(
|
||||||
&self,
|
&self,
|
||||||
local: &NodeUid,
|
local: &NodeUid,
|
||||||
@@ -430,8 +539,10 @@ impl Core {
|
|||||||
self.db
|
self.db
|
||||||
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
.remap_local_uid(&local.to_string(), &real.to_string())?;
|
||||||
|
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
if let Some(ino) = st.by_uid.remove(local) {
|
let Some(ino) = st.by_uid.remove(local) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
st.by_uid.insert(real.clone(), ino);
|
st.by_uid.insert(real.clone(), ino);
|
||||||
if let Some(e) = st.entries.get_mut(&ino) {
|
if let Some(e) = st.entries.get_mut(&ino) {
|
||||||
e.uid = real.clone();
|
e.uid = real.clone();
|
||||||
@@ -459,8 +570,7 @@ impl Core {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
drop(st);
|
|
||||||
// Write the real node through, now that nothing points at the old uid.
|
// Write the real node through, now that nothing points at the old uid.
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
if let Err(e) = self.db.upsert_node(&node) {
|
||||||
warn!(%real, error = %e, "db upsert_node failed after remap");
|
warn!(%real, error = %e, "db upsert_node failed after remap");
|
||||||
@@ -503,15 +613,23 @@ impl Core {
|
|||||||
if node.trashed {
|
if node.trashed {
|
||||||
return Ok(Some("the file was trashed remotely".into()));
|
return Ok(Some("the file was trashed remotely".into()));
|
||||||
}
|
}
|
||||||
let (mtime, size) = (node.modification_time, node_size(&node));
|
let Some(reason) = revision_changed(base, &node) else {
|
||||||
if mtime != base.mtime || size != base.size {
|
return Ok(None);
|
||||||
return Ok(Some(format!(
|
};
|
||||||
"the remote revision changed under the queued write \
|
// The remote moved on from the baseline — normally a conflict. But if it
|
||||||
(expected {} bytes at mtime {}, found {size} at {mtime})",
|
// sits at a revision *this daemon* sealed, no other device changed the
|
||||||
base.size, base.mtime
|
// file: it is a single-writer stall→resume (a browser that closed and
|
||||||
)));
|
// reopened its download fd; B70 layer B). Chain onto it — supersede
|
||||||
|
// instead of forking — so the finished bytes win the name rather than
|
||||||
|
// being exiled to a `(sync-conflict)` copy.
|
||||||
|
let remote_rev = node_revision_id(&node);
|
||||||
|
let own_rev = self.own_sealed_revs.lock().get(uid).cloned();
|
||||||
|
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
|
||||||
|
debug!(%uid, ?remote_rev,
|
||||||
|
"queued write chains onto our own sealed revision; not a conflict");
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(Some(reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a queued write that can no longer be applied to its own node as a
|
/// Land a queued write that can no longer be applied to its own node as a
|
||||||
@@ -595,11 +713,11 @@ impl Core {
|
|||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
// The conflict copy is a node the tree has never seen.
|
// The conflict copy is a node the tree has never seen.
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
if let Some(&ino) = st.by_uid.get(&parent) {
|
if let Some(&ino) = st.by_uid.get(&parent) {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
}
|
}
|
||||||
drop(st);
|
});
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Upload,
|
ActivityKind::Upload,
|
||||||
&name,
|
&name,
|
||||||
@@ -653,13 +771,25 @@ impl Core {
|
|||||||
|
|
||||||
/// A node's parent uid and name, as the tree currently has them.
|
/// A node's parent uid and name, as the tree currently has them.
|
||||||
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
pub(crate) fn node_place(&self, uid: &NodeUid) -> Option<(NodeUid, String)> {
|
||||||
{
|
// Across every mount: the drain runs on the primary Core, but a node
|
||||||
let st = self.state.lock();
|
// written inside a sync folder is interned in that fork's inode space,
|
||||||
|
// so asking `self.state` alone always missed it and fell through to the
|
||||||
|
// DB. That still gave the right answer — the fallback below exists for
|
||||||
|
// exactly this shape of miss — but it went to disk for something already
|
||||||
|
// in memory, and only *because* of the fallback was it not a bug.
|
||||||
|
let mut found = None;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
if found.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
if let Some(entry) = st.by_uid.get(uid).and_then(|ino| st.entries.get(ino))
|
||||||
&& let Some(parent) = entry.node.parent_uid.clone()
|
&& let Some(parent) = entry.node.parent_uid.clone()
|
||||||
{
|
{
|
||||||
return Some((parent, entry.node.name.clone()));
|
found = Some((parent, entry.node.name.clone()));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
if found.is_some() {
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
// The in-memory tree only holds what has been walked to since the daemon
|
// The in-memory tree only holds what has been walked to since the daemon
|
||||||
// started, and the drain routinely runs before anything has walked to
|
// started, and the drain routinely runs before anything has walked to
|
||||||
@@ -814,6 +944,11 @@ impl Core {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// This function's whole job is to bring the tree level with the revision
|
||||||
|
// we just sealed, so the feed's report of that revision has nothing left
|
||||||
|
// to tell us. Claimed here rather than at the upload call so it is only
|
||||||
|
// recorded when the node was actually re-read (`Core::self_changes`).
|
||||||
|
self.note_self_change(uid);
|
||||||
// Rebase any *open* write handles targeting this node. A handle opened
|
// Rebase any *open* write handles targeting this node. A handle opened
|
||||||
// before this upload carries a stale base — its `base_mtime`/`base_size`
|
// before this upload carries a stale base — its `base_mtime`/`base_size`
|
||||||
// name the revision we just replaced. Without this update,
|
// name the revision we just replaced. Without this update,
|
||||||
@@ -826,15 +961,25 @@ impl Core {
|
|||||||
{
|
{
|
||||||
let sealed_mtime = node.modification_time;
|
let sealed_mtime = node.modification_time;
|
||||||
let sealed_size = node_size(&node);
|
let sealed_size = node_size(&node);
|
||||||
let mut st = self.state.lock();
|
let sealed_rev = node_revision_id(&node);
|
||||||
for aw in st.active_writes.values_mut() {
|
// Remember it as ours, so a queued write that opened over an earlier
|
||||||
if aw.uid == *uid {
|
// revision recognises this as a self-supersede rather than a foreign
|
||||||
aw.base_mtime = sealed_mtime;
|
// change and does not fork (B70 layer B; consulted in
|
||||||
aw.base_size = sealed_size;
|
// `revision_conflict`).
|
||||||
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
|
if let Some(rev) = sealed_rev.clone() {
|
||||||
"rebased open write handle onto the revision just uploaded");
|
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
for aw in st.active_writes.values_mut() {
|
||||||
|
if aw.uid == *uid {
|
||||||
|
aw.base_mtime = sealed_mtime;
|
||||||
|
aw.base_size = sealed_size;
|
||||||
|
aw.base_revision_id = sealed_rev.clone();
|
||||||
|
debug!(%uid, mtime = sealed_mtime, size = sealed_size,
|
||||||
|
"rebased open write handle onto the revision just uploaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Ordered so that a write queued *during* the fetch above is still
|
// Ordered so that a write queued *during* the fetch above is still
|
||||||
// caught: it took its baseline from the node's optimistic stamp, and
|
// caught: it took its baseline from the node's optimistic stamp, and
|
||||||
@@ -842,16 +987,17 @@ impl Core {
|
|||||||
if self.rebaseline_pending(uid, &node) {
|
if self.rebaseline_pending(uid, &node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let mut st = self.state.lock();
|
self.for_each_state(|st| {
|
||||||
let Some(parent) = st
|
let Some(parent) = st
|
||||||
.by_uid
|
.by_uid
|
||||||
.get(uid)
|
.get(uid)
|
||||||
.and_then(|ino| st.entries.get(ino))
|
.and_then(|ino| st.entries.get(ino))
|
||||||
.map(|e| e.parent)
|
.map(|e| e.parent)
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
st.intern(parent, node);
|
st.intern(parent, node.clone());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Point a still-queued write at the revision this upload just sealed, and
|
/// Point a still-queued write at the revision this upload just sealed, and
|
||||||
@@ -875,6 +1021,7 @@ impl Core {
|
|||||||
mtime: sealed.modification_time,
|
mtime: sealed.modification_time,
|
||||||
size: node_size(sealed),
|
size: node_size(sealed),
|
||||||
hash: None,
|
hash: None,
|
||||||
|
revision_id: node_revision_id(sealed),
|
||||||
};
|
};
|
||||||
let mut pending = self.pending.lock();
|
let mut pending = self.pending.lock();
|
||||||
let Some(p) = pending.get_mut(uid) else {
|
let Some(p) = pending.get_mut(uid) else {
|
||||||
@@ -896,3 +1043,334 @@ impl Core {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the remote `node` moved on from the revision a queued write was
|
||||||
|
/// `base`d on — the reason string when it did, `None` when the write can still be
|
||||||
|
/// applied. Pure so it is testable without a live remote (the trashed/gone checks
|
||||||
|
/// stay in [`Core::revision_conflict`], which does the fetch).
|
||||||
|
///
|
||||||
|
/// The revision id is the authoritative identity: it advances iff a *new* revision
|
||||||
|
/// was sealed. When both sides carry one, it alone decides — the server re-stamps
|
||||||
|
/// the *same* revision's mtime, and reading that drift as a change is exactly what
|
||||||
|
/// diverted a write into a conflict copy of its own base (B16/B25). Only when
|
||||||
|
/// there is no id to compare (an old sidecar, or a surface that omits it) does it
|
||||||
|
/// fall back to the observable `(mtime, size)` tuple.
|
||||||
|
fn revision_changed(base: &Baseline, node: &Node) -> Option<String> {
|
||||||
|
if let (Some(base_rev), Some(remote_rev)) = (&base.revision_id, node_revision_id(node)) {
|
||||||
|
return (*base_rev != remote_rev).then(|| {
|
||||||
|
format!(
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(based on revision {base_rev}, remote now at {remote_rev})"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (mtime, size) = (node.modification_time, node_size(node));
|
||||||
|
(mtime != base.mtime || size != base.size).then(|| {
|
||||||
|
format!(
|
||||||
|
"the remote revision changed under the queued write \
|
||||||
|
(expected {} bytes at mtime {}, found {size} at {mtime})",
|
||||||
|
base.size, base.mtime
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a queued write that found the remote moved on ([`revision_changed`]
|
||||||
|
/// fired) is nonetheless a single-writer self-supersede rather than a foreign
|
||||||
|
/// conflict — the remote sits at a revision this daemon sealed itself, so
|
||||||
|
/// chaining onto it is safe and forking would be wrong (B70 layer B).
|
||||||
|
///
|
||||||
|
/// `remote_rev` is the id the remote currently holds, `own_rev` this node's
|
||||||
|
/// latest own-sealed id. Requires a `complete` blob: an incomplete one's gaps
|
||||||
|
/// still refer to the stale base, so it must take the non-destructive
|
||||||
|
/// conflict-copy path instead of overwriting. A `None` remote id never matches
|
||||||
|
/// (there is nothing to prove it was ours), so absence falls through to a fork.
|
||||||
|
fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Option<&str>) -> bool {
|
||||||
|
complete && remote_rev.is_some() && remote_rev == own_rev
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::Access;
|
||||||
|
use pdfs_core::db::Db;
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
fn pending(kind: &str) -> PendingOp {
|
||||||
|
PendingOp {
|
||||||
|
id: 0,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("node")).to_string(),
|
||||||
|
parent_uid: Some(NodeUid::new(VolumeId::from("v"), LinkId::from("parent")).to_string()),
|
||||||
|
name: Some("name".to_string()),
|
||||||
|
blob_path: (kind == OP_REVISION).then(|| "/staging/blob".to_string()),
|
||||||
|
meta_json: None,
|
||||||
|
created_at: 1,
|
||||||
|
attempts: 0,
|
||||||
|
last_error: None,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drain_authorities_match_each_operation_kind() {
|
||||||
|
for kind in [OP_REVISION, OP_TRASH] {
|
||||||
|
assert_eq!(pending_op_authorities(&pending(kind)).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
for kind in [OP_CREATE, OP_MKDIR] {
|
||||||
|
let authorities = pending_op_authorities(&pending(kind)).unwrap();
|
||||||
|
assert_eq!(authorities.len(), 1);
|
||||||
|
assert_eq!(authorities[0].to_string(), "v~parent");
|
||||||
|
}
|
||||||
|
let authorities = pending_op_authorities(&pending(OP_RENAME)).unwrap();
|
||||||
|
assert_eq!(authorities.len(), 2);
|
||||||
|
assert_eq!(authorities[0].to_string(), "v~node");
|
||||||
|
assert_eq!(authorities[1].to_string(), "v~parent");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_node(link: &str, parent: Option<&str>) -> Node {
|
||||||
|
Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(link)),
|
||||||
|
parent_uid: parent
|
||||||
|
.map(|parent| NodeUid::new(VolumeId::from("v"), LinkId::from(parent))),
|
||||||
|
kind: NodeKind::Folder,
|
||||||
|
name: link.to_string(),
|
||||||
|
creation_time: 0,
|
||||||
|
modification_time: 0,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_cross_parent_rename_retains_viewer_source_authority_after_local_move() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-drain-rename-access-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
let source = folder_node("source", None);
|
||||||
|
let destination = folder_node("destination", None);
|
||||||
|
let mut moved = file_node(1, 0, None);
|
||||||
|
moved.parent_uid = Some(destination.uid.clone());
|
||||||
|
for node in [&source, &destination, &moved] {
|
||||||
|
db.upsert_node(node).unwrap();
|
||||||
|
}
|
||||||
|
db.set_share_access(&source.uid, Access::Viewer).unwrap();
|
||||||
|
db.set_share_access(&destination.uid, Access::Editor)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut op = pending(OP_RENAME);
|
||||||
|
op.uid = moved.uid.to_string();
|
||||||
|
op.parent_uid = Some(destination.uid.to_string());
|
||||||
|
op.meta_json = Some(
|
||||||
|
serde_json::to_string(&RenameMeta {
|
||||||
|
original_parent_uid: source.uid.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let authorities = pending_op_authorities(&op).unwrap();
|
||||||
|
assert_eq!(authorities.len(), 3);
|
||||||
|
assert_eq!(authorities[1], source.uid);
|
||||||
|
|
||||||
|
let applied = Cell::new(false);
|
||||||
|
let disposition = run_authorized_drain(
|
||||||
|
&op,
|
||||||
|
|uid| {
|
||||||
|
db.effective_node_access(uid)
|
||||||
|
.unwrap()
|
||||||
|
.filter(|access| access.writable())
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(fuser::Errno::EACCES)
|
||||||
|
},
|
||||||
|
|| {
|
||||||
|
applied.set(true);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(disposition, DrainDisposition::AccessDeferred);
|
||||||
|
assert!(!applied.get());
|
||||||
|
assert_eq!(
|
||||||
|
db.effective_node_access(&moved.uid).unwrap(),
|
||||||
|
Some(Access::Editor),
|
||||||
|
"the node's optimistic destination ancestry is writable"
|
||||||
|
);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_deferred_drain_never_applies_or_consumes_an_attempt() {
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
static N: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-drain-access-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
N.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db = Db::open(&dir.join("cache.db")).unwrap();
|
||||||
|
let mut queued = pending(OP_REVISION);
|
||||||
|
queued.attempts = 2;
|
||||||
|
db.enqueue_op(&queued).unwrap();
|
||||||
|
let op = db.pending_ops().unwrap().remove(0);
|
||||||
|
let before_count = db.pending_op_counts().unwrap();
|
||||||
|
let applied = Cell::new(false);
|
||||||
|
|
||||||
|
let disposition = run_authorized_drain(
|
||||||
|
&op,
|
||||||
|
|_| Err(fuser::Errno::EACCES),
|
||||||
|
|| {
|
||||||
|
applied.set(true);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(disposition, DrainDisposition::AccessDeferred);
|
||||||
|
assert!(!applied.get(), "no API surrogate may run while read-only");
|
||||||
|
db.defer_op_without_attempt(op.id, 5_000).unwrap();
|
||||||
|
|
||||||
|
let retained = db.pending_ops().unwrap();
|
||||||
|
let after_count = db.pending_op_counts().unwrap();
|
||||||
|
assert_eq!(retained.len(), 1);
|
||||||
|
assert_eq!(retained[0].attempts, op.attempts);
|
||||||
|
assert_eq!(after_count.uploads, before_count.uploads);
|
||||||
|
assert_eq!(after_count.changes, before_count.changes);
|
||||||
|
drop(db);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn owner_and_editor_drain_authorities_apply_normally() {
|
||||||
|
for access in [Access::Owner, Access::Editor] {
|
||||||
|
let applied = Cell::new(false);
|
||||||
|
let disposition = run_authorized_drain(
|
||||||
|
&pending(OP_REVISION),
|
||||||
|
|_| access.writable().then_some(()).ok_or(fuser::Errno::EACCES),
|
||||||
|
|| {
|
||||||
|
applied.set(true);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(disposition, DrainDisposition::Applied);
|
||||||
|
assert!(applied.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_node(mtime: i64, size: i64, rev: Option<&str>) -> Node {
|
||||||
|
Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from("l")),
|
||||||
|
parent_uid: None,
|
||||||
|
kind: NodeKind::File {
|
||||||
|
media_type: "text/plain".into(),
|
||||||
|
total_size_on_storage: size,
|
||||||
|
active_revision_state: None,
|
||||||
|
active_revision_id: rev.map(String::from),
|
||||||
|
claimed_size: Some(size),
|
||||||
|
claimed_modification_time: None,
|
||||||
|
content_sha1: None,
|
||||||
|
},
|
||||||
|
name: "f.txt".into(),
|
||||||
|
creation_time: 0,
|
||||||
|
modification_time: mtime,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn baseline(mtime: i64, size: u64, rev: Option<&str>) -> Baseline {
|
||||||
|
Baseline {
|
||||||
|
mtime,
|
||||||
|
size,
|
||||||
|
hash: None,
|
||||||
|
revision_id: rev.map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_revision_id_is_not_a_conflict_despite_mtime_drift() {
|
||||||
|
// The exact shape of the reported bug: same revision, server re-stamped
|
||||||
|
// its mtime (1784897777 -> 1784897780), same size. Must NOT conflict.
|
||||||
|
let base = baseline(1_784_897_777, 87_438, Some("rev-A"));
|
||||||
|
let remote = file_node(1_784_897_780, 87_438, Some("rev-A"));
|
||||||
|
assert_eq!(revision_changed(&base, &remote), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_revision_id_is_a_conflict_even_at_the_same_size() {
|
||||||
|
// A same-size edit by another device advances the revision id: this is a
|
||||||
|
// real conflict the id catches where (mtime, size) alone could miss it.
|
||||||
|
let base = baseline(100, 40, Some("rev-A"));
|
||||||
|
let remote = file_node(100, 40, Some("rev-B"));
|
||||||
|
assert!(revision_changed(&base, &remote).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_ids_it_falls_back_to_mtime_and_size() {
|
||||||
|
// Old sidecar (no revision id): the (mtime, size) tuple still governs.
|
||||||
|
let base = baseline(100, 40, None);
|
||||||
|
assert_eq!(revision_changed(&base, &file_node(100, 40, None)), None);
|
||||||
|
assert!(revision_changed(&base, &file_node(101, 40, None)).is_some());
|
||||||
|
assert!(revision_changed(&base, &file_node(100, 41, None)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_complete_write_over_our_own_sealed_revision_chains_not_forks() {
|
||||||
|
// B70 layer B: the partial we sealed ourselves (rev-mine) is what the
|
||||||
|
// remote holds; the resume rewrote the whole file. Not a foreign change.
|
||||||
|
assert!(is_own_self_supersede(
|
||||||
|
true,
|
||||||
|
Some("rev-mine"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_move_to_a_revision_we_did_not_seal_still_forks() {
|
||||||
|
// Another device wrote rev-theirs; we only ever sealed rev-mine. Fork.
|
||||||
|
assert!(!is_own_self_supersede(
|
||||||
|
true,
|
||||||
|
Some("rev-theirs"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_incomplete_blob_never_self_supersedes() {
|
||||||
|
// Its gaps still refer to the stale base; overwriting would mix
|
||||||
|
// revisions, so it must take the conflict-copy path even over our seal.
|
||||||
|
assert!(!is_own_self_supersede(
|
||||||
|
false,
|
||||||
|
Some("rev-mine"),
|
||||||
|
Some("rev-mine")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_remote_id_never_self_supersedes() {
|
||||||
|
// No id to prove the revision was ours: fall through to a fork rather
|
||||||
|
// than matching None==None.
|
||||||
|
assert!(!is_own_self_supersede(true, None, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
+875
-457
File diff suppressed because it is too large.
Load diff
+2636
-122
File diff suppressed because it is too large.
Load diff
+328
-65
@@ -28,10 +28,101 @@ pub(crate) fn is_stale_mount(path: &Path) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A secondary (on-demand sync folder) FUSE session, paired with its FUSE
|
/// A secondary (on-demand sync folder) FUSE session and the exact liveness flag
|
||||||
/// connection id so teardown can abort a mid-transfer connection rather than
|
/// published with its forked inode state.
|
||||||
/// block on it. See [`Core::mounts`] and [`umount_session_unblocked`].
|
///
|
||||||
pub(super) type SecondaryMount = (BackgroundSession, Option<u32>);
|
/// Keeping these together makes every teardown mark the fork unroutable before
|
||||||
|
/// unmounting, including paths that can block on an in-flight FUSE request.
|
||||||
|
pub(super) struct SecondaryMount {
|
||||||
|
session: BackgroundSession,
|
||||||
|
conn: Option<u32>,
|
||||||
|
session_live: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) enum SecondaryInsertRejection {
|
||||||
|
Closed,
|
||||||
|
Duplicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary sessions and the daemon-wide gate controlling whether newly
|
||||||
|
/// spawned sessions may become visible. The gate and map share one lock so
|
||||||
|
/// shutdown cannot race an insertion between checking and publishing.
|
||||||
|
pub(super) struct SecondaryMountRegistry<T> {
|
||||||
|
accepting: bool,
|
||||||
|
entries: HashMap<i64, T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Default for SecondaryMountRegistry<T> {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
accepting: false,
|
||||||
|
entries: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> SecondaryMountRegistry<T> {
|
||||||
|
pub(super) fn open(&mut self) {
|
||||||
|
self.accepting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_accepting(&self) -> bool {
|
||||||
|
self.accepting
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn close(&mut self) {
|
||||||
|
self.accepting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn insert(
|
||||||
|
&mut self,
|
||||||
|
id: i64,
|
||||||
|
mount: T,
|
||||||
|
) -> Result<(), (SecondaryInsertRejection, T)> {
|
||||||
|
if !self.accepting {
|
||||||
|
return Err((SecondaryInsertRejection::Closed, mount));
|
||||||
|
}
|
||||||
|
if self.entries.contains_key(&id) {
|
||||||
|
return Err((SecondaryInsertRejection::Duplicate, mount));
|
||||||
|
}
|
||||||
|
self.entries.insert(id, mount);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove(&mut self, id: &i64) -> Option<T> {
|
||||||
|
self.entries.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn contains_key(&self, id: &i64) -> bool {
|
||||||
|
self.entries.contains_key(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn close_and_drain(&mut self) -> Vec<(i64, T)> {
|
||||||
|
self.close();
|
||||||
|
self.entries.drain().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecondaryMount {
|
||||||
|
pub(super) fn new(
|
||||||
|
session: BackgroundSession,
|
||||||
|
conn: Option<u32>,
|
||||||
|
session_live: Arc<AtomicBool>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
conn,
|
||||||
|
session_live,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn teardown(self) -> std::io::Result<()> {
|
||||||
|
teardown_session(&self.session_live, || {
|
||||||
|
umount_session_unblocked(self.session, self.conn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
|
/// The kernel's id for the FUSE connection backing the mount at `mountpoint` —
|
||||||
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
|
/// the directory name under `/sys/fs/fuse/connections`, which is the minor
|
||||||
@@ -143,6 +234,80 @@ fn fetch_or_recall_root(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-mount settings resolved from [`pdfs_core::config::AppConfig`] by the
|
||||||
|
/// caller, so `mount` itself never reads config or the environment.
|
||||||
|
pub struct MountOptions {
|
||||||
|
/// Proton account the session belongs to, for display and per-user paths.
|
||||||
|
pub username: String,
|
||||||
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
|
pub sweep_mode: SweepMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
|
///
|
||||||
|
/// Primary My Files and secondary on-demand device locations use this same
|
||||||
|
/// construction path so mount options, stale-mount recovery, registry
|
||||||
|
/// liveness, and notifier ownership cannot drift apart. Each caller owns its
|
||||||
|
/// state/path registration and performs it exactly once before calling here.
|
||||||
|
pub(super) fn spawn_session(
|
||||||
|
core: &Core,
|
||||||
|
mountpoint: &Path,
|
||||||
|
root: Node,
|
||||||
|
) -> std::io::Result<BackgroundSession> {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.mount_options = vec![
|
||||||
|
MountOption::FSName("protondrive".to_string()),
|
||||||
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
|
MountOption::DefaultPermissions,
|
||||||
|
];
|
||||||
|
|
||||||
|
clear_stale_mount(mountpoint);
|
||||||
|
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive location");
|
||||||
|
let fs = ProtonFs::new(core.clone(), root);
|
||||||
|
if core.primary {
|
||||||
|
// Only My Files rehydrates the daemon-wide node cache. A secondary
|
||||||
|
// inode space is rooted at one device subtree and must not import every
|
||||||
|
// unrelated persisted node.
|
||||||
|
core.hydrate();
|
||||||
|
}
|
||||||
|
let session = Session::new(fs, mountpoint, &config)?.spawn()?;
|
||||||
|
let _ = core.notifier.set(session.notifier());
|
||||||
|
core.session_live.store(true, Ordering::Release);
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make a session unroutable before beginning any teardown that may block.
|
||||||
|
fn teardown_session<T>(session_live: &AtomicBool, teardown: impl FnOnce() -> T) -> T {
|
||||||
|
session_live.store(false, Ordering::Release);
|
||||||
|
teardown()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Resolve the primary share id away from startup and publish it only if the
|
||||||
|
/// projected root has not changed while the request was in flight.
|
||||||
|
pub(crate) fn repair_primary_share_id(&self, root_uid: &NodeUid) {
|
||||||
|
match self.rt.block_on(self.client.context_share_id(root_uid)) {
|
||||||
|
Ok(share_id) => {
|
||||||
|
match self
|
||||||
|
.db
|
||||||
|
.mount_repair_my_files_share_id(&root_uid.to_string(), &share_id.to_string())
|
||||||
|
{
|
||||||
|
Ok(true) => debug!(%root_uid, "repaired My Files share id"),
|
||||||
|
Ok(false) => {
|
||||||
|
debug!(%root_uid, "discarded stale My Files share-id result");
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%root_uid, error = %error, "persist My Files share id failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%root_uid, error = %error, "resolve My Files share id failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
/// Mount the filesystem at `mountpoint` and block until it is unmounted or the
|
||||||
/// daemon is asked to stop.
|
/// daemon is asked to stop.
|
||||||
///
|
///
|
||||||
@@ -162,10 +327,19 @@ pub fn mount(
|
|||||||
cache: ContentCache,
|
cache: ContentCache,
|
||||||
control_socket: &Path,
|
control_socket: &Path,
|
||||||
db: Arc<Db>,
|
db: Arc<Db>,
|
||||||
username: String,
|
options: MountOptions,
|
||||||
) -> std::io::Result<MountOutcome> {
|
) -> std::io::Result<MountOutcome> {
|
||||||
|
let MountOptions {
|
||||||
|
username,
|
||||||
|
sweep_mode,
|
||||||
|
} = options;
|
||||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||||
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)
|
||||||
|
.map_err(|error| std::io::Error::other(format!("project My Files mount: {error}")))?;
|
||||||
|
let share_access = db
|
||||||
|
.all_share_access()
|
||||||
|
.map_err(|error| std::io::Error::other(format!("load shared access: {error}")))?;
|
||||||
|
|
||||||
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
// The folder-sync engine (devices.md Phase 2) runs on its own thread and is
|
||||||
// nudged over this channel; the sender lives in Core so control-socket
|
// nudged over this channel; the sender lives in Core so control-socket
|
||||||
@@ -175,6 +349,8 @@ pub fn mount(
|
|||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
|
primary_root_uid: root.uid.clone(),
|
||||||
|
primary: true,
|
||||||
state: Arc::new(Mutex::new(State {
|
state: Arc::new(Mutex::new(State {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
by_uid: HashMap::new(),
|
by_uid: HashMap::new(),
|
||||||
@@ -183,6 +359,8 @@ pub fn mount(
|
|||||||
active_writes: HashMap::new(),
|
active_writes: HashMap::new(),
|
||||||
handles: HashMap::new(),
|
handles: HashMap::new(),
|
||||||
next_fh: 1,
|
next_fh: 1,
|
||||||
|
access_changes: HashSet::new(),
|
||||||
|
share_access,
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
})),
|
})),
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
@@ -190,23 +368,37 @@ pub fn mount(
|
|||||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
||||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||||
db,
|
db,
|
||||||
|
shared_publication: Arc::new(Mutex::new(())),
|
||||||
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
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())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
conflict_notified: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
sweep_mode,
|
||||||
|
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
mounts: Arc::new(Mutex::new(HashMap::new())),
|
mounts: Arc::new(Mutex::new(SecondaryMountRegistry::default())),
|
||||||
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
sync_locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
states: Arc::new(StateRegistry::default()),
|
||||||
};
|
};
|
||||||
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
|
// every mount's inode space through this registry, not through `core.state`.
|
||||||
|
core.register_state(mountpoint);
|
||||||
|
|
||||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||||
// an upload, and reads must be served from their staged blobs until they land.
|
// an upload, and reads must be served from their staged blobs until they land.
|
||||||
@@ -226,6 +418,21 @@ pub fn mount(
|
|||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
sync::spawn(core.clone(), sync_rx);
|
sync::spawn(core.clone(), sync_rx);
|
||||||
|
|
||||||
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
|
// Its own thread — it enumerates and trashes, and must never block a FUSE call.
|
||||||
|
// `Off` skips the thread entirely rather than starting an idle one, so the
|
||||||
|
// setting is verifiable from the outside (`ls /proc/<pid>/task/*/comm`).
|
||||||
|
if sweep_mode == SweepMode::Off {
|
||||||
|
info!("conflict sweep disabled by configuration");
|
||||||
|
} else {
|
||||||
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
|
let core = core.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("pdfs-conflict-sweep".into())
|
||||||
|
.spawn(move || core.run_conflict_sweep_loop())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Mounted from the cache: watch for the network coming back so the mount can
|
// Mounted from the cache: watch for the network coming back so the mount can
|
||||||
// stop being read-only-ish without the user restarting the daemon.
|
// stop being read-only-ish without the user restarting the daemon.
|
||||||
if !online {
|
if !online {
|
||||||
@@ -235,16 +442,6 @@ pub fn mount(
|
|||||||
.spawn(move || core.run_online_probe())?;
|
.spawn(move || core.run_online_probe())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-establish on-demand mounts left over from a previous run (devices.md
|
|
||||||
// Phase 4). On its own thread: each remount fetches a remote node, and we
|
|
||||||
// must not block the main mount below on the network.
|
|
||||||
{
|
|
||||||
let core = core.clone();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("pdfs-restore-ondemand".into())
|
|
||||||
.spawn(move || core.restore_ondemand_mounts())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
// the walk is I/O-heavy and must never sit in front of a FUSE callback.
|
||||||
{
|
{
|
||||||
@@ -257,18 +454,6 @@ pub fn mount(
|
|||||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut config = Config::default();
|
|
||||||
config.mount_options = vec![
|
|
||||||
MountOption::FSName("protondrive".to_string()),
|
|
||||||
MountOption::Subtype("protondrive".to_string()),
|
|
||||||
MountOption::DefaultPermissions,
|
|
||||||
];
|
|
||||||
|
|
||||||
// A crashed previous run can leave the kernel mount in place, which makes
|
|
||||||
// the fresh mount below fail with EBUSY. Lazily detach any leftover first.
|
|
||||||
clear_stale_mount(mountpoint);
|
|
||||||
info!(mountpoint = %mountpoint.display(), "mounting Proton Drive");
|
|
||||||
|
|
||||||
// Bind the control socket before the FUSE session takes over the thread. A
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
// stale socket file from a previous run would block the bind, so clear it.
|
// stale socket file from a previous run would block the bind, so clear it.
|
||||||
let _ = std::fs::remove_file(control_socket);
|
let _ = std::fs::remove_file(control_socket);
|
||||||
@@ -285,40 +470,70 @@ pub fn mount(
|
|||||||
"control socket permissions: {e}"
|
"control socket permissions: {e}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
{
|
// Do not accept control requests until `spawn_session` has transitioned the
|
||||||
let core = core.clone();
|
// primary registration to live. The listener may queue a connection during
|
||||||
let username = username.clone();
|
// this short window, but its handler cannot observe `mounted = false`.
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let bg = match spawn_session(&core, mountpoint, root) {
|
||||||
std::thread::Builder::new()
|
Ok(session) => session,
|
||||||
.name("pdfs-control".into())
|
Err(error) => {
|
||||||
.spawn(move || run_control_socket(core, username, mountpoint, listener))?;
|
let _ = std::fs::remove_file(control_socket);
|
||||||
}
|
return Err(error);
|
||||||
|
}
|
||||||
let fs = ProtonFs::new(core.clone(), root);
|
};
|
||||||
// Warm the in-memory maps from the DB so a cold start serves previously
|
core.mounts.lock().open();
|
||||||
// seen metadata without re-hitting the API (plan.md P1).
|
|
||||||
core.hydrate();
|
|
||||||
|
|
||||||
// Build the session explicitly (not `mount2`) so we can grab a `Notifier`
|
|
||||||
// for the event task. `spawn` runs the session loop on its own background
|
|
||||||
// thread; we then wait here for either a stop signal or the mount ending.
|
|
||||||
let bg = Session::new(fs, mountpoint, &config)?.spawn()?;
|
|
||||||
// The connection id, captured now while the mount is live, so a stop signal
|
// The connection id, captured now while the mount is live, so a stop signal
|
||||||
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
|
// mid-transfer can abort it rather than block `join` (see `abort_fuse_connection`).
|
||||||
let main_conn = fuse_connection_id(mountpoint);
|
let main_conn = fuse_connection_id(mountpoint);
|
||||||
let notifier = bg.notifier();
|
{
|
||||||
// Same channel, kept on the `Core` so background work (a size upgrade, say)
|
let control_core = core.clone();
|
||||||
// can invalidate kernel-cached metadata without threading a handle through.
|
let username = username.clone();
|
||||||
let _ = core.notifier.set(notifier.clone());
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
rt.spawn(run_event_sync(
|
if let Err(error) = std::thread::Builder::new()
|
||||||
client,
|
.name("pdfs-control".into())
|
||||||
scope,
|
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||||
core.state,
|
{
|
||||||
core.cache,
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
core.db,
|
for (id, mount) in secondaries {
|
||||||
core.pending,
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
notifier,
|
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||||
));
|
}
|
||||||
|
}
|
||||||
|
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||||
|
umount_session_unblocked(bg, main_conn)
|
||||||
|
}) {
|
||||||
|
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
|
// fetch to observe that closure, and then drain a stable registry.
|
||||||
|
let restore_worker = {
|
||||||
|
let restore_core = core.clone();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-restore-ondemand".into())
|
||||||
|
.spawn(move || restore_core.restore_ondemand_mounts())
|
||||||
|
{
|
||||||
|
Ok(worker) => Some(worker),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "start on-demand restore worker failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||||
|
if online {
|
||||||
|
let repair_core = core.clone();
|
||||||
|
let root_uid = core.primary_root_uid.clone();
|
||||||
|
if let Err(error) = std::thread::Builder::new()
|
||||||
|
.name("pdfs-share-id".into())
|
||||||
|
.spawn(move || repair_core.repair_primary_share_id(&root_uid))
|
||||||
|
{
|
||||||
|
warn!(%error, "start My Files share-id repair failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
// Stop signals (SIGTERM from `systemctl --user stop`, SIGINT from Ctrl-C)
|
||||||
// are delivered onto the async runtime; bridge them onto a sync channel so
|
// are delivered onto the async runtime; bridge them onto a sync channel so
|
||||||
@@ -357,7 +572,10 @@ pub fn mount(
|
|||||||
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
match sig_rx.recv_timeout(Duration::from_millis(500)) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("stop requested; unmounting");
|
info!("stop requested; unmounting");
|
||||||
if let Err(e) = umount_session_unblocked(bg, main_conn) {
|
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");
|
warn!(error = %e, "umount_and_join failed");
|
||||||
}
|
}
|
||||||
break MountOutcome::Shutdown;
|
break MountOutcome::Shutdown;
|
||||||
@@ -365,7 +583,8 @@ pub fn mount(
|
|||||||
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");
|
||||||
if let Err(e) = bg.join() {
|
core.mounts.lock().close();
|
||||||
|
if let Err(e) = teardown_session(&core.session_live, || bg.join()) {
|
||||||
warn!(error = %e, "session join failed");
|
warn!(error = %e, "session join failed");
|
||||||
}
|
}
|
||||||
break MountOutcome::Unmounted;
|
break MountOutcome::Unmounted;
|
||||||
@@ -373,7 +592,8 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
|
||||||
// Signal task gone (failed to install); fall back to join.
|
// Signal task gone (failed to install); fall back to join.
|
||||||
let _ = bg.join();
|
core.mounts.lock().close();
|
||||||
|
let _ = teardown_session(&core.session_live, || bg.join());
|
||||||
break MountOutcome::Unmounted;
|
break MountOutcome::Unmounted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,9 +601,14 @@ pub fn mount(
|
|||||||
|
|
||||||
// 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 (devices.md Phase 3).
|
||||||
let secondaries: Vec<_> = core.mounts.lock().drain().collect();
|
if let Some(worker) = restore_worker
|
||||||
for (id, (session, conn)) in secondaries {
|
&& worker.join().is_err()
|
||||||
if let Err(e) = umount_session_unblocked(session, conn) {
|
{
|
||||||
|
warn!("on-demand restore worker panicked");
|
||||||
|
}
|
||||||
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
|
for (id, mount) in secondaries {
|
||||||
|
if let Err(e) = mount.teardown() {
|
||||||
warn!(id, error = %e, "unmount on-demand folder failed");
|
warn!(id, error = %e, "unmount on-demand folder failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,3 +616,41 @@ pub fn mount(
|
|||||||
let _ = std::fs::remove_file(control_socket);
|
let _ = std::fs::remove_file(control_socket);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use super::{SecondaryInsertRejection, SecondaryMountRegistry, teardown_session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_is_unroutable_before_teardown_starts() {
|
||||||
|
let live = AtomicBool::new(true);
|
||||||
|
teardown_session(&live, || {
|
||||||
|
assert!(
|
||||||
|
!live.load(Ordering::Acquire),
|
||||||
|
"teardown must never run while the registry still reports mounted"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secondary_registry_rejects_closed_and_duplicate_insertions() {
|
||||||
|
let mut mounts = SecondaryMountRegistry::default();
|
||||||
|
assert_eq!(
|
||||||
|
mounts.insert(7, "closed"),
|
||||||
|
Err((SecondaryInsertRejection::Closed, "closed"))
|
||||||
|
);
|
||||||
|
|
||||||
|
mounts.open();
|
||||||
|
assert_eq!(mounts.insert(7, "first"), Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
mounts.insert(7, "duplicate"),
|
||||||
|
Err((SecondaryInsertRejection::Duplicate, "duplicate"))
|
||||||
|
);
|
||||||
|
assert!(mounts.contains_key(&7));
|
||||||
|
|
||||||
|
assert_eq!(mounts.close_and_drain(), vec![(7, "first")]);
|
||||||
|
assert!(!mounts.is_accepting());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
//! user. Thumbnails the server has none for (anything a camera wrote rather than
|
||||||
//! a phone) are generated locally and stored as if the server had served them.
|
//! a phone) are generated locally and stored as if the server had served them.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb};
|
||||||
@@ -154,7 +154,20 @@ impl Core {
|
|||||||
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
pub(crate) fn photo_thumbs(&self, uids: &[NodeUid]) -> Vec<PhotoThumb> {
|
||||||
let ttype = ThumbnailType::Thumbnail.as_i32();
|
let ttype = ThumbnailType::Thumbnail.as_i32();
|
||||||
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
let keys: Vec<String> = uids.iter().map(|u| u.to_string()).collect();
|
||||||
let stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
let mut stored = self.db.photos_by_uid(&keys).unwrap_or_default();
|
||||||
|
// Album covers and the contents of an album shared with us are not in our
|
||||||
|
// own timeline, so their capture time — the cache's validity tag — only
|
||||||
|
// exists on the album rows. Fill in whatever the timeline didn't cover.
|
||||||
|
{
|
||||||
|
let have: HashSet<String> = stored.iter().map(|p| p.uid.clone()).collect();
|
||||||
|
let missing: Vec<String> = keys
|
||||||
|
.iter()
|
||||||
|
.filter(|k| !have.contains(*k))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
stored.extend(self.db.album_photos_by_uid(&missing).unwrap_or_default());
|
||||||
|
}
|
||||||
|
let stored = stored;
|
||||||
let tags: HashMap<String, i64> = stored
|
let tags: HashMap<String, i64> = stored
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| (p.uid.clone(), p.capture_time))
|
.map(|p| (p.uid.clone(), p.capture_time))
|
||||||
@@ -329,11 +342,17 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist what a thumbnail attempt learned about a photo.
|
/// Persist what a thumbnail attempt learned about a photo, against the
|
||||||
|
/// timeline and against every album the photo sits in — one attempt, one
|
||||||
|
/// verdict, wherever the photo is painted.
|
||||||
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
pub(crate) fn record_thumb(&self, uid: &NodeUid, state: i64, ratio: Option<f64>) {
|
||||||
if let Err(e) = self.db.photo_set_thumb(&uid.to_string(), state, ratio) {
|
let key = uid.to_string();
|
||||||
|
if let Err(e) = self.db.photo_set_thumb(&key, state, ratio) {
|
||||||
warn!(%uid, error = %e, "recording thumbnail state failed");
|
warn!(%uid, error = %e, "recording thumbnail state failed");
|
||||||
}
|
}
|
||||||
|
if let Err(e) = self.db.album_photo_set_thumb(&key, state, ratio) {
|
||||||
|
warn!(%uid, error = %e, "recording album thumbnail state failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
/// Whether the listing stamped under `key` is older than `ttl` (or was never
|
||||||
|
|||||||
+164
-21
@@ -12,7 +12,10 @@ use std::sync::OnceLock;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use pdfs_core::control::RestorableFolder;
|
use pdfs_core::control::RestorableFolder;
|
||||||
use pdfs_core::profile::{PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin};
|
use pdfs_core::mounts::MountMode;
|
||||||
|
use pdfs_core::profile::{
|
||||||
|
PROFILE_DIR_NAME, PROFILE_FILE_NAME, PROFILE_VERSION, Profile, ProfileFolder, ProfilePin,
|
||||||
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -63,8 +66,9 @@ impl Core {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize this machine's arrangement and upload it to `profile.json` in
|
/// Serialize this machine's arrangement and upload it to `profile.json`
|
||||||
/// the device root, replacing any existing one.
|
/// inside the device's `.proton-drive-linux` folder, replacing any existing
|
||||||
|
/// one.
|
||||||
///
|
///
|
||||||
/// Does nothing when no device is registered: a machine that has never
|
/// Does nothing when no device is registered: a machine that has never
|
||||||
/// synced a folder has nothing to describe, and registering a device just to
|
/// synced a folder has nothing to describe, and registering a device just to
|
||||||
@@ -85,11 +89,15 @@ impl Core {
|
|||||||
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
let bytes = profile.to_bytes().map_err(CoreError::internal)?;
|
||||||
let len = bytes.len() as i64;
|
let len = bytes.len() as i64;
|
||||||
|
|
||||||
|
// The device root itself rejects file creation (B78), so the profile
|
||||||
|
// lives one level down, in a folder created on first save.
|
||||||
|
let dir_uid = self.ensure_profile_dir(&root_uid)?;
|
||||||
|
|
||||||
// Replace the existing document rather than adding a second file of the
|
// Replace the existing document rather than adding a second file of the
|
||||||
// same name: the profile is a single mutable record, and a device root
|
// same name: the profile is a single mutable record, and a folder with
|
||||||
// with `profile.json` and `profile (1).json` in it teaches the restore
|
// `profile.json` and `profile (1).json` in it teaches the restore
|
||||||
// nothing about which to trust.
|
// nothing about which to trust.
|
||||||
let existing = self.find_device_child_file(&root_uid, PROFILE_FILE_NAME)?;
|
let existing = self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?;
|
||||||
match existing {
|
match existing {
|
||||||
Some(uid) => self
|
Some(uid) => self
|
||||||
.rt
|
.rt
|
||||||
@@ -105,7 +113,7 @@ impl Core {
|
|||||||
None => {
|
None => {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.upload_file_replacing_draft_from(
|
.block_on(self.client.upload_file_replacing_draft_from(
|
||||||
&root_uid,
|
&dir_uid,
|
||||||
PROFILE_FILE_NAME,
|
PROFILE_FILE_NAME,
|
||||||
"application/json",
|
"application/json",
|
||||||
Cursor::new(bytes),
|
Cursor::new(bytes),
|
||||||
@@ -133,7 +141,7 @@ impl Core {
|
|||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
// A queued switch is where the user asked the folder to be, so
|
// A queued switch is where the user asked the folder to be, so
|
||||||
// that is what the next machine should set up.
|
// that is what the next machine should set up.
|
||||||
mode: f.pending_mode.unwrap_or(f.mode),
|
mode: MountMode::from(f.pending_mode.unwrap_or(f.mode).as_str()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let pins = self
|
let pins = self
|
||||||
@@ -163,18 +171,18 @@ impl Core {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An untrashed *file* named `name` directly under the device root.
|
/// An untrashed *file* named `name` directly under `parent_uid`.
|
||||||
///
|
///
|
||||||
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
/// The sibling of `find_device_child_folder`; kept separate because a folder
|
||||||
/// named `profile.json` must not be mistaken for the profile.
|
/// named `profile.json` must not be mistaken for the profile.
|
||||||
fn find_device_child_file(
|
fn find_device_child_file(
|
||||||
&self,
|
&self,
|
||||||
root_uid: &NodeUid,
|
parent_uid: &NodeUid,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> CoreResult<Option<NodeUid>> {
|
) -> CoreResult<Option<NodeUid>> {
|
||||||
let uids = self
|
let uids = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_folder_children_node_uids(root_uid))
|
.block_on(self.client.enumerate_folder_children_node_uids(parent_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(None);
|
||||||
@@ -189,10 +197,47 @@ impl Core {
|
|||||||
.map(|n| n.uid))
|
.map(|n| n.uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download and parse the profile in the device root, if there is one.
|
/// The device's `.proton-drive-linux` folder, creating it if this is the
|
||||||
|
/// first save on this device.
|
||||||
|
///
|
||||||
|
/// Reused by stable identity rather than recreated: a second folder of the
|
||||||
|
/// same name would split the profile across two documents, and the restore
|
||||||
|
/// would have no way to tell which is current.
|
||||||
|
fn ensure_profile_dir(&self, root_uid: &NodeUid) -> CoreResult<NodeUid> {
|
||||||
|
if let Some(uid) = self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||||
|
return Ok(uid);
|
||||||
|
}
|
||||||
|
let uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.create_folder(root_uid, PROFILE_DIR_NAME, Some(now_secs())),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "create profile folder"))?;
|
||||||
|
info!(name = PROFILE_DIR_NAME, "created device profile folder");
|
||||||
|
Ok(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download and parse this device's profile, if there is one.
|
||||||
|
///
|
||||||
|
/// Looks in `.proton-drive-linux/` first and falls back to the device root,
|
||||||
|
/// where clients before the B78 fix wrote it. The fallback is read-only: the
|
||||||
|
/// next save writes to the folder, and the legacy document is left alone
|
||||||
|
/// rather than trashed, so an older client on another machine keeps working.
|
||||||
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
fn load_profile(&self, root_uid: &NodeUid) -> CoreResult<Option<Profile>> {
|
||||||
let Some(uid) = self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? else {
|
let current = match self.find_device_child_folder(root_uid, PROFILE_DIR_NAME)? {
|
||||||
return Ok(None);
|
Some(dir_uid) => self.find_device_child_file(&dir_uid, PROFILE_FILE_NAME)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let uid = match current {
|
||||||
|
Some(uid) => uid,
|
||||||
|
None => match self.find_device_child_file(root_uid, PROFILE_FILE_NAME)? {
|
||||||
|
Some(uid) => {
|
||||||
|
info!("reading legacy profile from the device root");
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
None => return Ok(None),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
self.rt
|
self.rt
|
||||||
@@ -251,7 +296,10 @@ impl Core {
|
|||||||
|
|
||||||
Ok(nodes
|
Ok(nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|n| n.is_folder() && !n.trashed)
|
// `.proton-drive-linux` holds the profile itself, not user data:
|
||||||
|
// offering it as a restorable folder would sync the bookkeeping
|
||||||
|
// back onto the machine it describes.
|
||||||
|
.filter(|n| n.is_folder() && !n.trashed && n.name != PROFILE_DIR_NAME)
|
||||||
.map(|n| {
|
.map(|n| {
|
||||||
let uid = n.uid.to_string();
|
let uid = n.uid.to_string();
|
||||||
let saved = profile
|
let saved = profile
|
||||||
@@ -267,7 +315,7 @@ impl Core {
|
|||||||
name: n.name,
|
name: n.name,
|
||||||
local_path: proposed.to_string_lossy().to_string(),
|
local_path: proposed.to_string_lossy().to_string(),
|
||||||
mode: saved
|
mode: saved
|
||||||
.map(|f| f.mode.clone())
|
.map(|f| f.mode.to_string())
|
||||||
.unwrap_or_else(|| "mirror".into()),
|
.unwrap_or_else(|| "mirror".into()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -319,10 +367,10 @@ impl Core {
|
|||||||
skipped.push(format!("{}: {e}", item.local_path));
|
skipped.push(format!("{}: {e}", item.local_path));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mode = match item.mode.as_str() {
|
let mode = match restore_mode(&item.mode) {
|
||||||
"mirror" | "ondemand" => item.mode.as_str(),
|
Some(mode) => mode,
|
||||||
other => {
|
None => {
|
||||||
skipped.push(format!("{}: unknown mode {other}", item.local_path));
|
skipped.push(format!("{}: unknown mode {}", item.local_path, item.mode));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -333,7 +381,7 @@ impl Core {
|
|||||||
// `ondemand` is applied through the normal request path, which
|
// `ondemand` is applied through the normal request path, which
|
||||||
// mounts the FUSE session and handles the folder being busy — the
|
// mounts the FUSE session and handles the folder being busy — the
|
||||||
// restore does not get its own copy of that logic.
|
// restore does not get its own copy of that logic.
|
||||||
if mode == "ondemand" {
|
if mode == MountMode::OnDemand {
|
||||||
let _ = self.request_sync_folder_mode(id, "ondemand");
|
let _ = self.request_sync_folder_mode(id, "ondemand");
|
||||||
}
|
}
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id));
|
||||||
@@ -350,6 +398,17 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accept only modes this build can safely restore. `MountMode` itself is
|
||||||
|
/// tolerant so a future value does not reject the whole profile; the folder
|
||||||
|
/// carrying that value is skipped here with the message assembled above.
|
||||||
|
fn restore_mode(value: &str) -> Option<MountMode> {
|
||||||
|
match MountMode::from(value) {
|
||||||
|
MountMode::Mirror => Some(MountMode::Mirror),
|
||||||
|
MountMode::OnDemand => Some(MountMode::OnDemand),
|
||||||
|
MountMode::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a path recorded on another machine can be reused verbatim here.
|
/// Whether a path recorded on another machine can be reused verbatim here.
|
||||||
///
|
///
|
||||||
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
|
/// Requires the *parent* to exist: `~/Documents` on a machine with the same
|
||||||
@@ -382,6 +441,90 @@ mod tests {
|
|||||||
assert!(!path_is_usable_here(Path::new("relative/path")));
|
assert!(!path_is_usable_here(Path::new("relative/path")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_profile_mode_is_skipped_without_rejecting_known_modes() {
|
||||||
|
assert_eq!(restore_mode("mirror"), Some(MountMode::Mirror));
|
||||||
|
assert_eq!(restore_mode("ondemand"), Some(MountMode::OnDemand));
|
||||||
|
assert_eq!(restore_mode("streaming"), None);
|
||||||
|
assert_eq!(restore_mode("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The device root refuses file creation (B78), so every upload in
|
||||||
|
/// `save_profile` must be addressed to the profile folder. Checked against
|
||||||
|
/// the source because both branches are pure network calls.
|
||||||
|
#[test]
|
||||||
|
fn the_profile_is_never_uploaded_to_the_device_root() {
|
||||||
|
let source = include_str!("profile.rs");
|
||||||
|
let save = function_source(source, "fn save_profile(");
|
||||||
|
assert!(
|
||||||
|
save.contains("ensure_profile_dir(&root_uid)"),
|
||||||
|
"save_profile resolves the profile folder"
|
||||||
|
);
|
||||||
|
for upload in [
|
||||||
|
"upload_new_revision_from",
|
||||||
|
"upload_file_replacing_draft_from",
|
||||||
|
] {
|
||||||
|
let call = save.find(upload).expect("upload call exists");
|
||||||
|
let args = &save[call..];
|
||||||
|
let root = args.find("&root_uid").unwrap_or(usize::MAX);
|
||||||
|
let dir = args.find("&dir_uid").unwrap_or(usize::MAX);
|
||||||
|
assert!(dir < root, "{upload} must target the profile folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read has to keep finding profiles written before the fix, which sat in
|
||||||
|
/// the device root.
|
||||||
|
#[test]
|
||||||
|
fn loading_falls_back_to_a_legacy_device_root_profile() {
|
||||||
|
let source = include_str!("profile.rs");
|
||||||
|
let load = function_source(source, "fn load_profile(");
|
||||||
|
assert!(load.contains("PROFILE_DIR_NAME"), "prefers the folder");
|
||||||
|
assert!(
|
||||||
|
load.find("PROFILE_DIR_NAME").unwrap()
|
||||||
|
< load.find("legacy profile").expect("legacy fallback exists"),
|
||||||
|
"the folder is consulted before the device root"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile folder is the client's own bookkeeping: neither the restore
|
||||||
|
/// picker nor `add_sync_folder` may treat it as a user folder.
|
||||||
|
#[test]
|
||||||
|
fn the_profile_folder_is_not_offered_as_user_data() {
|
||||||
|
let restorable = function_source(include_str!("profile.rs"), "fn list_restorable_folders(");
|
||||||
|
assert!(
|
||||||
|
restorable.contains("n.name != PROFILE_DIR_NAME"),
|
||||||
|
"restorable folders exclude the profile folder"
|
||||||
|
);
|
||||||
|
let add = function_source(include_str!("devices.rs"), "fn add_sync_folder(");
|
||||||
|
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||||
|
let start = source.find(signature).expect("function signature exists");
|
||||||
|
let body = &source[start..];
|
||||||
|
let open = body.find('{').expect("function body starts");
|
||||||
|
let mut depth = 0usize;
|
||||||
|
for (offset, byte) in body.as_bytes()[open..].iter().enumerate() {
|
||||||
|
match byte {
|
||||||
|
b'{' => depth += 1,
|
||||||
|
b'}' => {
|
||||||
|
depth -= 1;
|
||||||
|
if depth == 0 {
|
||||||
|
return &body[..open + offset + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("function body closes")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_before(source: &str, first: &str, second: &str) {
|
||||||
|
let first_at = source.find(first).expect("first operation exists");
|
||||||
|
let second_at = source.find(second).expect("second operation exists");
|
||||||
|
assert!(first_at < second_at, "{first} must precede {second}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A path whose parent exists but which does not itself is exactly the
|
/// A path whose parent exists but which does not itself is exactly the
|
||||||
/// restore case — the directory is about to be created.
|
/// restore case — the directory is about to be created.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+181
-32
@@ -19,10 +19,15 @@ use proton_drive_rs::Node;
|
|||||||
|
|
||||||
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
use super::{Core, ROOT_INO, node_size, parse_uid, public_link_info, role_from_str, role_to_str};
|
||||||
|
|
||||||
/// A node from a *shared* listing as a [`DirEntry`]. Shared nodes live outside
|
/// A node from a *shared* listing as a [`DirEntry`].
|
||||||
/// the mount, so the entry is uid-addressed: `path` is empty, and the local-state
|
///
|
||||||
/// flags (`pinned`, `cached`) are false — they describe my own tree, not this one.
|
/// Still uid-addressed — that is the only handle a foreign-volume node is
|
||||||
fn shared_entry(n: Node) -> DirEntry {
|
/// guaranteed to have — but `path` is filled in when the node is resident under
|
||||||
|
/// the synthetic `Shared with me/` directory, which is what lets a front-end
|
||||||
|
/// offer path-keyed actions on it. The local-state flags (`pinned`, `cached`)
|
||||||
|
/// stay false: they describe content this account owns a local copy policy for,
|
||||||
|
/// which a share on someone else's volume is not.
|
||||||
|
fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||||
let is_dir = n.is_folder();
|
let is_dir = n.is_folder();
|
||||||
let size = match &n.kind {
|
let size = match &n.kind {
|
||||||
NodeKind::File {
|
NodeKind::File {
|
||||||
@@ -32,6 +37,13 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
} => claimed_size.unwrap_or(*total_size_on_storage).max(0) as u64,
|
||||||
NodeKind::Folder => 0,
|
NodeKind::Folder => 0,
|
||||||
};
|
};
|
||||||
|
let role = n
|
||||||
|
.membership
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.role_exact())
|
||||||
|
.map(role_to_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -39,8 +51,9 @@ fn shared_entry(n: Node) -> DirEntry {
|
|||||||
modified: n.modification_time,
|
modified: n.modification_time,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
path: String::new(),
|
role,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +70,33 @@ impl Core {
|
|||||||
message: Option<&str>,
|
message: Option<&str>,
|
||||||
) -> CoreResult<(usize, usize)> {
|
) -> CoreResult<(usize, usize)> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.share_node_for_uid(&uid, emails, role, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::share_node`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn share_node_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
emails: &[String],
|
||||||
|
role: &str,
|
||||||
|
message: Option<&str>,
|
||||||
|
) -> CoreResult<(usize, usize)> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.share_node_for_uid(&uid, emails, role, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_node_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
emails: &[String],
|
||||||
|
role: &str,
|
||||||
|
message: Option<&str>,
|
||||||
|
) -> CoreResult<(usize, usize)> {
|
||||||
let role = role_from_str(role)?;
|
let role = role_from_str(role)?;
|
||||||
let invitees: Vec<(String, MemberRole)> =
|
let invitees: Vec<(String, MemberRole)> =
|
||||||
emails.iter().map(|e| (e.clone(), role)).collect();
|
emails.iter().map(|e| (e.clone(), role)).collect();
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(self.client.invite_users(&uid, &invitees, message))
|
.block_on(self.client.invite_users(uid, &invitees, message))
|
||||||
.map_err(|e| CoreError::from_api(&e, "share"))
|
.map_err(|e| CoreError::from_api(&e, "share"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +106,26 @@ impl Core {
|
|||||||
rel: &Path,
|
rel: &Path,
|
||||||
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.list_share_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::list_share`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn list_share_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.list_share_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_share_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
) -> CoreResult<(Vec<ShareEntry>, Option<PublicLinkInfo>)> {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for m in self
|
for m in self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||||
{
|
{
|
||||||
entries.push(ShareEntry {
|
entries.push(ShareEntry {
|
||||||
@@ -87,7 +137,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
for inv in self
|
for inv in self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||||
{
|
{
|
||||||
entries.push(ShareEntry {
|
entries.push(ShareEntry {
|
||||||
@@ -99,7 +149,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
for ext in self
|
for ext in self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||||
{
|
{
|
||||||
entries.push(ShareEntry {
|
entries.push(ShareEntry {
|
||||||
@@ -112,7 +162,7 @@ impl Core {
|
|||||||
|
|
||||||
let link = self
|
let link = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.get_public_link(&uid))
|
.block_on(self.client.get_public_link(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||||
.map(public_link_info);
|
.map(public_link_info);
|
||||||
|
|
||||||
@@ -129,12 +179,35 @@ impl Core {
|
|||||||
role: &str,
|
role: &str,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::update_share_role`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn update_share_role_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
id: &str,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
role: &str,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.update_share_role_for_uid(&uid, id, kind, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_share_role_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
id: &str,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
role: &str,
|
||||||
|
) -> CoreResult<()> {
|
||||||
let role = role_from_str(role)?;
|
let role = role_from_str(role)?;
|
||||||
match kind {
|
match kind {
|
||||||
ShareEntryKind::Member => {
|
ShareEntryKind::Member => {
|
||||||
let member = self
|
let member = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|m| m.membership_id.to_string() == id)
|
.find(|m| m.membership_id.to_string() == id)
|
||||||
@@ -146,7 +219,7 @@ impl Core {
|
|||||||
ShareEntryKind::ProtonInvite => {
|
ShareEntryKind::ProtonInvite => {
|
||||||
let inv = self
|
let inv = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|i| i.invitation_id == id)
|
.find(|i| i.invitation_id == id)
|
||||||
@@ -170,11 +243,32 @@ impl Core {
|
|||||||
kind: ShareEntryKind,
|
kind: ShareEntryKind,
|
||||||
) -> CoreResult<()> {
|
) -> CoreResult<()> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::remove_share_entry`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn remove_share_entry_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
id: &str,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.remove_share_entry_for_uid(&uid, id, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_share_entry_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
id: &str,
|
||||||
|
kind: ShareEntryKind,
|
||||||
|
) -> CoreResult<()> {
|
||||||
match kind {
|
match kind {
|
||||||
ShareEntryKind::Member => {
|
ShareEntryKind::Member => {
|
||||||
let member = self
|
let member = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_members(&uid))
|
.block_on(self.client.list_share_members(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
.map_err(|e| CoreError::from_api(&e, "list members"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|m| m.membership_id.to_string() == id)
|
.find(|m| m.membership_id.to_string() == id)
|
||||||
@@ -186,7 +280,7 @@ impl Core {
|
|||||||
ShareEntryKind::ProtonInvite => {
|
ShareEntryKind::ProtonInvite => {
|
||||||
let inv = self
|
let inv = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_share_invitations(&uid))
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|i| i.invitation_id == id)
|
.find(|i| i.invitation_id == id)
|
||||||
@@ -198,7 +292,7 @@ impl Core {
|
|||||||
ShareEntryKind::ExternalInvite => {
|
ShareEntryKind::ExternalInvite => {
|
||||||
let ext = self
|
let ext = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.list_external_invitations(&uid))
|
.block_on(self.client.list_external_invitations(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|i| i.invitation_id == id)
|
.find(|i| i.invitation_id == id)
|
||||||
@@ -219,13 +313,33 @@ impl Core {
|
|||||||
expires: Option<i64>,
|
expires: Option<i64>,
|
||||||
) -> CoreResult<PublicLinkInfo> {
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::create_public_link`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn create_public_link_by_uid(
|
||||||
|
&self,
|
||||||
|
uid: &str,
|
||||||
|
role: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
expires: Option<i64>,
|
||||||
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.create_public_link_for_uid(&uid, role, password, expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_public_link_for_uid(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
role: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
expires: Option<i64>,
|
||||||
|
) -> CoreResult<PublicLinkInfo> {
|
||||||
let role = role_from_str(role)?;
|
let role = role_from_str(role)?;
|
||||||
let link = self
|
let link = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(self.client.create_public_link(uid, role, password, expires))
|
||||||
self.client
|
|
||||||
.create_public_link(&uid, role, password, expires),
|
|
||||||
)
|
|
||||||
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
.map_err(|e| CoreError::from_api(&e, "create public link"))?;
|
||||||
Ok(public_link_info(link))
|
Ok(public_link_info(link))
|
||||||
}
|
}
|
||||||
@@ -233,9 +347,20 @@ impl Core {
|
|||||||
/// Remove the public link `id` from the node at `rel`.
|
/// Remove the public link `id` from the node at `rel`.
|
||||||
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> {
|
||||||
let (_ino, uid) = self.resolve(rel)?;
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.remove_public_link_for_uid(&uid, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::remove_public_link`] for a node addressed through any daemon
|
||||||
|
/// location.
|
||||||
|
pub(crate) fn remove_public_link_by_uid(&self, uid: &str, id: &str) -> CoreResult<()> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.remove_public_link_for_uid(&uid, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_public_link_for_uid(&self, uid: &NodeUid, id: &str) -> CoreResult<()> {
|
||||||
let link = self
|
let link = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.get_public_link(&uid))
|
.block_on(self.client.get_public_link(uid))
|
||||||
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?
|
||||||
.filter(|l| l.public_link_id == id)
|
.filter(|l| l.public_link_id == id)
|
||||||
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
.ok_or_else(|| CoreError::not_found("public link not found"))?;
|
||||||
@@ -259,7 +384,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&uids))
|
.block_on(self.client.enumerate_nodes(&uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
Ok(nodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| shared_entry_of(self, node))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the children of a folder shared with me, addressed by uid.
|
/// List the children of a folder shared with me, addressed by uid.
|
||||||
@@ -281,7 +409,10 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&child_uids))
|
.block_on(self.client.enumerate_nodes(&child_uids))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?;
|
||||||
Ok(nodes.into_iter().map(shared_entry).collect())
|
Ok(nodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| shared_entry_of(self, node))
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file shared with me into the content cache, returning its
|
/// Download a file shared with me into the content cache, returning its
|
||||||
@@ -466,15 +597,33 @@ impl Core {
|
|||||||
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
/// been seen through the mount (e.g. shared but not browsed to this session) —
|
||||||
/// the caller then leaves the path empty.
|
/// the caller then leaves the path empty.
|
||||||
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
pub(crate) fn rel_path_for_uid(&self, uid: &NodeUid) -> Option<String> {
|
||||||
let st = self.state.lock();
|
// Searched across every mount: a node browsed through a sync folder is
|
||||||
let mut ino = *st.by_uid.get(uid)?;
|
// interned in that fork's inode space, so the primary state alone had no
|
||||||
let mut parts = Vec::new();
|
// entry for it and the caller silently showed an empty path
|
||||||
while ino != ROOT_INO {
|
// (`docs/BUGS.md` B74). The path is relative to whichever mount holds
|
||||||
let entry = st.entries.get(&ino)?;
|
// it, which is the one the user reached it through.
|
||||||
parts.push(entry.node.name.clone());
|
let mut path = None;
|
||||||
ino = entry.parent;
|
self.for_each_state(|st| {
|
||||||
}
|
if path.is_some() {
|
||||||
parts.reverse();
|
return;
|
||||||
Some(parts.join("/"))
|
}
|
||||||
|
path = walk_to_root(st, uid);
|
||||||
|
});
|
||||||
|
path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||||
|
/// that mount has never interned the node, or if the chain breaks partway —
|
||||||
|
/// a broken chain is a missing path, never a partial one.
|
||||||
|
fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||||
|
let mut ino = *st.by_uid.get(uid)?;
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
while ino != ROOT_INO {
|
||||||
|
let entry = st.entries.get(&ino)?;
|
||||||
|
parts.push(entry.node.name.clone());
|
||||||
|
ino = entry.parent;
|
||||||
|
}
|
||||||
|
parts.reverse();
|
||||||
|
Some(parts.join("/"))
|
||||||
|
}
|
||||||
+1063
-3
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,471 @@
|
|||||||
|
//! Background conflict sweep.
|
||||||
|
//!
|
||||||
|
//! A `(sync-conflict <ts>)` copy is only ever a fallback: the drain and the sync
|
||||||
|
//! engine cut one when they cannot prove a queued write is safe to apply, so the
|
||||||
|
//! user never loses bytes. Most of those copies turn out to be byte-identical to
|
||||||
|
//! the live file — a spurious conflict from a re-stamped revision (B16/B25) or a
|
||||||
|
//! first-sync of an already-identical file. This sweep reconciles them after the
|
||||||
|
//! fact:
|
||||||
|
//!
|
||||||
|
//! - **Identical** to the live sibling (same size *and* same content SHA-1): the
|
||||||
|
//! copy is redundant, so it is trashed and logged as a [`ActivityKind::Trash`].
|
||||||
|
//! - **Divergent** (different bytes) or **orphaned** (no live sibling): left in
|
||||||
|
//! place and surfaced once as an [`ActivityKind::Conflict`], because only the
|
||||||
|
//! user can decide which version wins.
|
||||||
|
//!
|
||||||
|
//! Identity is proved without downloading anything: the SHA-1 comes from the
|
||||||
|
//! revision's decrypted extended attributes, which enumeration already reads. A
|
||||||
|
//! copy whose sibling has no recorded SHA-1 is treated as divergent — the sweep
|
||||||
|
//! never removes a file it cannot prove is a duplicate.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use pdfs_core::config::SweepMode;
|
||||||
|
use pdfs_core::control::ActivityKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use super::{Core, node_content_sha1, node_revision_id, node_size};
|
||||||
|
|
||||||
|
/// Time between sweeps. Conflicts are rare and non-urgent; a slow cadence keeps
|
||||||
|
/// the sweep off the hot path while still cleaning up within a few minutes.
|
||||||
|
const CONFLICT_SWEEP_INTERVAL: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// Delay before the first sweep, so it does not compete with the burst of work a
|
||||||
|
/// fresh mount does at startup (hydrate, recover, initial enumeration).
|
||||||
|
const CONFLICT_SWEEP_WARMUP: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Strip a ` (sync-conflict <stamp>)` (optionally `-<n>`) marker from `name`,
|
||||||
|
/// returning the original file name it was a copy of. `None` when `name` is not a
|
||||||
|
/// conflict copy. Inverse of the `conflict_name` / `conflict_path_with_suffix`
|
||||||
|
/// formats, so the two halves of the product agree on what a conflict is.
|
||||||
|
pub(crate) fn conflict_base_name(name: &str) -> Option<String> {
|
||||||
|
const MARKER: &str = " (sync-conflict ";
|
||||||
|
let start = name.find(MARKER)?;
|
||||||
|
let after = start + MARKER.len();
|
||||||
|
let close_rel = name[after..].find(')')?;
|
||||||
|
let close = after + close_rel;
|
||||||
|
let inner = &name[after..close];
|
||||||
|
// The token is `<digits>` or `<digits>-<digits>`; reject anything else so a
|
||||||
|
// file that merely happens to contain the phrase is not mistaken for a copy.
|
||||||
|
let mut parts = inner.splitn(2, '-');
|
||||||
|
let stamp = parts.next().unwrap_or_default();
|
||||||
|
let suffix = parts.next();
|
||||||
|
let is_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
|
||||||
|
if !is_digits(stamp) || suffix.is_some_and(|s| !is_digits(s)) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut base = String::with_capacity(start + name.len() - close);
|
||||||
|
base.push_str(&name[..start]);
|
||||||
|
base.push_str(&name[close + 1..]);
|
||||||
|
Some(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||||
|
/// docs for the identical/divergent policy.
|
||||||
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
|
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
||||||
|
loop {
|
||||||
|
self.sweep_conflicts_once();
|
||||||
|
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sweep pass over every node the daemon currently knows.
|
||||||
|
fn sweep_conflicts_once(&self) {
|
||||||
|
let stored = match self.db.load_all() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = ?e, "conflict sweep: load nodes failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let nodes: Vec<_> = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| stored.node)
|
||||||
|
.filter(|node| self.is_own_or_virtual(&node.uid))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Index live (non-trashed) files by (parent, name) for sibling lookup.
|
||||||
|
let mut by_parent_name: HashMap<(String, &str), &_> = HashMap::new();
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trashed || node.is_folder() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parent = node
|
||||||
|
.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||||
|
}
|
||||||
|
|
||||||
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
for node in &nodes {
|
||||||
|
if node.trashed || node.is_folder() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(base) = conflict_base_name(&node.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let parent = node
|
||||||
|
.parent_uid
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| u.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let sibling = by_parent_name.get(&(parent, base.as_str())).copied();
|
||||||
|
|
||||||
|
match decide(node, sibling, &base, self.sweep_mode) {
|
||||||
|
SweepAction::Remove => {
|
||||||
|
// Only while online — the remote trash is the point. Offline,
|
||||||
|
// it waits for a later pass.
|
||||||
|
if online {
|
||||||
|
self.remove_conflict_copy(node, &base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trash a conflict copy proven identical to its live sibling, and unhook it
|
||||||
|
/// locally — the same sequence the interactive unlink path uses after a trash.
|
||||||
|
///
|
||||||
|
/// `snapshot` is the node as the pass saw it, which by now is stale: the pass
|
||||||
|
/// enumerated every node up front and has been doing network I/O since. Before
|
||||||
|
/// touching anything remote this re-reads the node and refuses to act unless
|
||||||
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||||
|
/// unopened. Without that, a user edit landing inside the window is destroyed
|
||||||
|
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
||||||
|
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
||||||
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||||
|
let uid = &snapshot.uid;
|
||||||
|
let name = snapshot.name.as_str();
|
||||||
|
if !self.duplicate_still_removable(snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.trash_nodes(std::slice::from_ref(uid)))
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if super::is_gone(&e) => {}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = %e, "conflict sweep: trashing duplicate failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every mount, not just the primary: a conflict copy inside a sync
|
||||||
|
// folder lives in that fork's inode space, and leaving it hooked up
|
||||||
|
// there keeps a node we have just trashed remotely visible and
|
||||||
|
// readable there (`docs/BUGS.md` B74).
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
st.forget_or_unlink(uid);
|
||||||
|
});
|
||||||
|
if let Err(error) = self.discard_queued_ops(uid) {
|
||||||
|
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.cache.evict(uid);
|
||||||
|
self.evict_reader(uid);
|
||||||
|
if let Err(e) = self.db.delete_node(uid) {
|
||||||
|
warn!(%uid, error = ?e, "conflict sweep: db delete after trash failed");
|
||||||
|
}
|
||||||
|
self.invalidate_trash();
|
||||||
|
self.conflict_notified.lock().remove(uid);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Trash,
|
||||||
|
name,
|
||||||
|
format!("auto-removed duplicate of {base}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
info!(%uid, name, base, "conflict sweep: removed identical conflict copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-check, immediately before the destructive call, everything the pass
|
||||||
|
/// decided from its stale snapshot. Any doubt means "leave it alone": a
|
||||||
|
/// conflict copy that survives to the next pass costs nothing, a wrongly
|
||||||
|
/// removed one costs the user data.
|
||||||
|
fn duplicate_still_removable(&self, snapshot: &proton_drive_rs::Node) -> bool {
|
||||||
|
let uid = &snapshot.uid;
|
||||||
|
let name = snapshot.name.as_str();
|
||||||
|
|
||||||
|
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
||||||
|
// would throw those away along with the staged blob holding them.
|
||||||
|
match self.db.has_any_op(&uid.to_string()) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => {
|
||||||
|
debug!(%uid, name, "conflict sweep: skipping duplicate with queued ops");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Cannot prove the queue is empty — assume it is not.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = ?e, "conflict sweep: op check failed; skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone is writing to it, or holds it open. Trashing now would pull the
|
||||||
|
// node out from under a live handle.
|
||||||
|
if self.is_busy(uid) {
|
||||||
|
debug!(%uid, name, "conflict sweep: skipping duplicate that is open or being written");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, the node itself must not have moved on since the snapshot.
|
||||||
|
match self.db.node_by_uid(&uid.to_string()) {
|
||||||
|
Ok(Some(fresh)) => {
|
||||||
|
if fresh.trashed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if fresh.name != snapshot.name || fresh.parent_uid != snapshot.parent_uid {
|
||||||
|
debug!(%uid, name, "conflict sweep: duplicate was renamed or moved; skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if node_revision_id(&fresh) != node_revision_id(snapshot)
|
||||||
|
|| node_size(&fresh) != node_size(snapshot)
|
||||||
|
|| node_content_sha1(&fresh) != node_content_sha1(snapshot)
|
||||||
|
{
|
||||||
|
debug!(%uid, name, "conflict sweep: duplicate changed since the pass began");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Already gone locally, or unreadable — either way, not ours to remove.
|
||||||
|
Ok(None) => false,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(%uid, name, error = ?e, "conflict sweep: re-read failed; skipping");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a node has a write in flight or a file handle open against it.
|
||||||
|
/// Read-only opens use fh 0 and are not tracked, so this is specifically the
|
||||||
|
/// writer/holder check.
|
||||||
|
/// Asked across **every** mount. The sweep runs on the primary Core but
|
||||||
|
/// trashes nodes anywhere in the tree, and a file open inside a sync folder
|
||||||
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||||
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
|
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
|
let mut busy = false;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
busy |=
|
||||||
|
st.active_writes.contains_key(&ino) || st.handles.values().any(|&held| held == ino);
|
||||||
|
});
|
||||||
|
busy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||||
|
/// daemon run so the activity feed is not spammed each pass.
|
||||||
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sweep concluded about one `(sync-conflict …)` copy.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
enum SweepAction {
|
||||||
|
/// Provably redundant: trash it, subject to the runtime interlocks in
|
||||||
|
/// [`Core::duplicate_still_removable`].
|
||||||
|
Remove,
|
||||||
|
/// Only the user can resolve this one; surface it with this detail.
|
||||||
|
Flag(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what to do about conflict copy `node` given its live sibling (if any)
|
||||||
|
/// and the configured `mode`.
|
||||||
|
///
|
||||||
|
/// Pure, so the policy that governs deletion is testable without a mount, a
|
||||||
|
/// runtime, or an account — the same split used for `revision_changed` and
|
||||||
|
/// `is_own_self_supersede`. Everything that can fail or race lives in the caller.
|
||||||
|
fn decide(
|
||||||
|
node: &proton_drive_rs::Node,
|
||||||
|
sibling: Option<&proton_drive_rs::Node>,
|
||||||
|
base: &str,
|
||||||
|
mode: SweepMode,
|
||||||
|
) -> SweepAction {
|
||||||
|
match sibling {
|
||||||
|
Some(sib) if is_identical(node, sib) => {
|
||||||
|
if mode.is_enforcing() {
|
||||||
|
SweepAction::Remove
|
||||||
|
} else {
|
||||||
|
// Report-only: say what would happen, remove nothing. The point is
|
||||||
|
// to accumulate field evidence that the identity check is right
|
||||||
|
// before it is trusted with deletion (docs/BUGS.md B71).
|
||||||
|
SweepAction::Flag(format!(
|
||||||
|
"identical to {base}; would be removed (sweep is report-only)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => SweepAction::Flag(format!("differs from {base}")),
|
||||||
|
None => SweepAction::Flag(format!("no original {base} to reconcile against")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two file nodes provably hold the same bytes: equal size and equal,
|
||||||
|
/// present content SHA-1. A missing digest on either side is not proof, so the
|
||||||
|
/// sweep treats it as divergent rather than risk removing a real difference.
|
||||||
|
fn is_identical(a: &proton_drive_rs::Node, b: &proton_drive_rs::Node) -> bool {
|
||||||
|
if node_size(a) != node_size(b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (node_content_sha1(a), node_content_sha1(b)) {
|
||||||
|
(Some(x), Some(y)) => x == y,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use proton_drive_rs::NodeKind;
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
|
||||||
|
|
||||||
|
/// A file node with the two fields the sweep's identity check reads.
|
||||||
|
fn file(name: &str, size: i64, sha1: Option<&str>) -> proton_drive_rs::Node {
|
||||||
|
proton_drive_rs::Node {
|
||||||
|
uid: NodeUid::new(VolumeId::from("v"), LinkId::from(name)),
|
||||||
|
parent_uid: None,
|
||||||
|
kind: NodeKind::File {
|
||||||
|
media_type: "text/plain".into(),
|
||||||
|
total_size_on_storage: size,
|
||||||
|
active_revision_state: None,
|
||||||
|
active_revision_id: Some("rev".into()),
|
||||||
|
claimed_size: Some(size),
|
||||||
|
claimed_modification_time: None,
|
||||||
|
content_sha1: sha1.map(String::from),
|
||||||
|
},
|
||||||
|
name: name.into(),
|
||||||
|
creation_time: 0,
|
||||||
|
modification_time: 0,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only input shape that may ever produce a removal: same size, same
|
||||||
|
/// present digest, live sibling, enforcing mode.
|
||||||
|
#[test]
|
||||||
|
fn removes_only_a_provably_identical_copy_when_enforcing() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Remove
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_only_never_removes_but_still_says_what_it_would_do() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
// Same inputs as the removal case above; only the mode differs.
|
||||||
|
let SweepAction::Flag(detail) = decide(©, Some(&live), "f.txt", SweepMode::Report)
|
||||||
|
else {
|
||||||
|
panic!("report mode must never remove");
|
||||||
|
};
|
||||||
|
assert!(detail.contains("would be removed"), "{detail}");
|
||||||
|
assert!(detail.contains("report-only"), "{detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_differing_size_is_a_real_conflict() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 101, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_digest_is_never_proof_of_identity() {
|
||||||
|
// Equal sizes are not enough: two different 100-byte files are common.
|
||||||
|
// A copy the sweep cannot fingerprint must survive, in either direction.
|
||||||
|
for (a, b) in [(None, Some("aaa")), (Some("aaa"), None), (None, None)] {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, a);
|
||||||
|
let live = file("f.txt", 100, b);
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into()),
|
||||||
|
"{a:?} vs {b:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_differing_digest_at_equal_size_is_a_real_conflict() {
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
let live = file("f.txt", 100, Some("bbb"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, Some(&live), "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("differs from f.txt".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_orphaned_copy_is_surfaced_not_removed() {
|
||||||
|
// No live original left to reconcile against — the copy may be the only
|
||||||
|
// surviving version of the file, so it is never the sweep's to remove.
|
||||||
|
let copy = file("f (sync-conflict 1).txt", 100, Some("aaa"));
|
||||||
|
assert_eq!(
|
||||||
|
decide(©, None, "f.txt", SweepMode::Enforce),
|
||||||
|
SweepAction::Flag("no original f.txt to reconcile against".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_needs_both_size_and_digest_to_match() {
|
||||||
|
let a = file("a", 100, Some("aaa"));
|
||||||
|
assert!(is_identical(&a, &file("b", 100, Some("aaa"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 101, Some("aaa"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 100, Some("bbb"))));
|
||||||
|
assert!(!is_identical(&a, &file("b", 100, None)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_conflict_names_and_leaves_others_alone() {
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("test_export (sync-conflict 1784898786).xml").as_deref(),
|
||||||
|
Some("test_export.xml")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("README (sync-conflict 42)").as_deref(),
|
||||||
|
Some("README")
|
||||||
|
);
|
||||||
|
// The sync engine's de-duplicated suffix form.
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("notes (sync-conflict 1700-3).txt").as_deref(),
|
||||||
|
Some("notes.txt")
|
||||||
|
);
|
||||||
|
// Not a conflict copy.
|
||||||
|
assert_eq!(conflict_base_name("report.xml"), None);
|
||||||
|
// Looks close but the token is not a stamp.
|
||||||
|
assert_eq!(conflict_base_name("x (sync-conflict abc).txt"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dotted_stem_keeps_its_inner_dots() {
|
||||||
|
assert_eq!(
|
||||||
|
conflict_base_name("archive.tar (sync-conflict 7).gz").as_deref(),
|
||||||
|
Some("archive.tar.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -670,6 +670,27 @@ impl Core {
|
|||||||
|
|
||||||
match plan_file(l, r, baseline.get(rel)) {
|
match plan_file(l, r, baseline.get(rel)) {
|
||||||
FilePlan::Unchanged => {}
|
FilePlan::Unchanged => {}
|
||||||
|
FilePlan::AdoptBaseline => {
|
||||||
|
// First sync of an already-identical file: record the baseline
|
||||||
|
// in place so future divergence is detected, without any
|
||||||
|
// transfer or a spurious conflict copy.
|
||||||
|
let local = l.expect("adopt baseline requires a local file");
|
||||||
|
let remote = r.expect("adopt baseline requires a remote file");
|
||||||
|
if let Err(e) = self.db.sync_entry_upsert(
|
||||||
|
folder_id,
|
||||||
|
&StoredSyncEntry {
|
||||||
|
rel_path: rel.clone(),
|
||||||
|
remote_uid: Some(remote.uid.to_string()),
|
||||||
|
local_mtime: local.mtime,
|
||||||
|
local_size: local.size,
|
||||||
|
remote_rev: Some(remote.mtime.to_string()),
|
||||||
|
remote_hash: Some(remote.size.to_string()),
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
warn!(rel, error = ?e, "sync: adopt baseline failed");
|
||||||
|
outcome.errors += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
FilePlan::Deferred => {
|
FilePlan::Deferred => {
|
||||||
debug!(
|
debug!(
|
||||||
rel,
|
rel,
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ pub(super) enum FilePlan {
|
|||||||
DeleteLocal,
|
DeleteLocal,
|
||||||
DeleteRemote,
|
DeleteRemote,
|
||||||
ForgetBaseline,
|
ForgetBaseline,
|
||||||
|
/// First sync of a file that already exists on both sides with identical
|
||||||
|
/// content: record the baseline without transferring anything. Without this,
|
||||||
|
/// a folder synced for the first time cuts a `(sync-conflict)` copy of every
|
||||||
|
/// pre-existing file, because a missing baseline makes both sides read as
|
||||||
|
/// "changed" (see the mass first-sync conflict storm in docs/BUGS.md).
|
||||||
|
AdoptBaseline,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn plan_file(
|
pub(super) fn plan_file(
|
||||||
@@ -219,10 +225,16 @@ pub(super) fn plan_file(
|
|||||||
});
|
});
|
||||||
|
|
||||||
match (local, remote) {
|
match (local, remote) {
|
||||||
(Some(_), Some(_)) => match (local_changed, remote_changed) {
|
(Some(l), Some(r)) => match (local_changed, remote_changed) {
|
||||||
(false, false) => FilePlan::Unchanged,
|
(false, false) => FilePlan::Unchanged,
|
||||||
(true, false) => FilePlan::UploadRevision,
|
(true, false) => FilePlan::UploadRevision,
|
||||||
(false, true) => FilePlan::Download,
|
(false, true) => FilePlan::Download,
|
||||||
|
// With a baseline, both-changed is a genuine concurrent edit and must
|
||||||
|
// be preserved as a conflict. Without one, "both changed" only means
|
||||||
|
// this is the first sync and there was nothing to compare to: adopt
|
||||||
|
// the baseline when the sizes already match rather than conflict-copy
|
||||||
|
// an already-identical file. Divergent sizes stay a real conflict.
|
||||||
|
(true, true) if baseline.is_none() && l.size == r.size => FilePlan::AdoptBaseline,
|
||||||
(true, true) => FilePlan::Conflict,
|
(true, true) => FilePlan::Conflict,
|
||||||
},
|
},
|
||||||
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
(Some(_), None) if baseline.is_none() || local_changed => FilePlan::UploadNew,
|
||||||
@@ -350,6 +362,31 @@ mod file_plan_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_sync_of_an_identical_file_adopts_the_baseline_not_a_conflict() {
|
||||||
|
// No baseline yet (first sync). Same size on both sides is a pre-existing
|
||||||
|
// identical file, not a concurrent edit: adopt it rather than cut a
|
||||||
|
// conflict copy of every file in the folder.
|
||||||
|
let local = local(10, 40);
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
|
FilePlan::AdoptBaseline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_sync_of_differing_sizes_is_still_a_conflict() {
|
||||||
|
// No baseline and the sizes disagree: a genuine both-sides divergence,
|
||||||
|
// preserved non-destructively as a conflict.
|
||||||
|
let local = local(10, 20);
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&local), Some(&remote), None),
|
||||||
|
FilePlan::Conflict
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_remote_signature_is_treated_as_changed() {
|
fn a_missing_remote_signature_is_treated_as_changed() {
|
||||||
let local = local(10, 20);
|
let local = local(10, 20);
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ async fn run_uploads(
|
|||||||
.transfers
|
.transfers
|
||||||
.begin(&t.name, "", TransferDirection::Upload, t.size);
|
.begin(&t.name, "", TransferDirection::Upload, t.size);
|
||||||
let reader = OwnedCountingReader::new(file, guard);
|
let reader = OwnedCountingReader::new(file, guard);
|
||||||
|
if let Err(error) = core.require_uid_writable(&t.parent_uid) {
|
||||||
|
return Err((
|
||||||
|
t.name,
|
||||||
|
format!("destination access denied: errno {}", error.code()),
|
||||||
|
));
|
||||||
|
}
|
||||||
match core
|
match core
|
||||||
.client
|
.client
|
||||||
.upload_file_from(
|
.upload_file_from(
|
||||||
@@ -110,6 +116,8 @@ impl Core {
|
|||||||
let (pino, parent_uid) = self.resolve(parent_rel)?;
|
let (pino, parent_uid) = self.resolve(parent_rel)?;
|
||||||
self.ensure_children(pino)
|
self.ensure_children(pino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
|
self.require_uid_writable(&parent_uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||||
|
|
||||||
// Phase 1 (sequential): build the remote folder skeleton and collect the
|
// Phase 1 (sequential): build the remote folder skeleton and collect the
|
||||||
// flat list of files to upload. Folders must exist before their children,
|
// flat list of files to upload. Folders must exist before their children,
|
||||||
@@ -170,6 +178,8 @@ impl Core {
|
|||||||
{
|
{
|
||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
|
drop(st);
|
||||||
|
self.flush_access_changes();
|
||||||
}
|
}
|
||||||
// Uploaded fine but the metadata refresh failed; it will
|
// Uploaded fine but the metadata refresh failed; it will
|
||||||
// appear on the next directory enumeration regardless.
|
// appear on the next directory enumeration regardless.
|
||||||
@@ -205,19 +215,24 @@ impl Core {
|
|||||||
}
|
}
|
||||||
self.ensure_children(pino)
|
self.ensure_children(pino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
{
|
let existing = {
|
||||||
let st = self.state.lock();
|
let st = self.state.lock();
|
||||||
if let Some(kids) = st.children.get(&pino) {
|
st.children.get(&pino).and_then(|kids| {
|
||||||
for &ino in kids {
|
kids.iter().find_map(|ino| {
|
||||||
if let Some(e) = st.entries.get(&ino)
|
st.entries
|
||||||
&& e.node.is_folder()
|
.get(ino)
|
||||||
&& e.node.name == name
|
.filter(|entry| entry.node.is_folder() && entry.node.name == name)
|
||||||
{
|
.map(|entry| (*ino, entry.uid.clone()))
|
||||||
return Ok((ino, e.uid.clone()));
|
})
|
||||||
}
|
})
|
||||||
}
|
};
|
||||||
}
|
if let Some((ino, uid)) = existing {
|
||||||
|
self.require_uid_writable(&uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||||
|
return Ok((ino, uid));
|
||||||
}
|
}
|
||||||
|
self.require_uid_writable(parent_uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "upload destination"))?;
|
||||||
let new_uid = self
|
let new_uid = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(
|
.block_on(
|
||||||
@@ -235,6 +250,10 @@ impl Core {
|
|||||||
{
|
{
|
||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
|
drop(st);
|
||||||
|
self.flush_access_changes();
|
||||||
|
self.require_uid_writable(&new_uid)
|
||||||
|
.map_err(|error| self.errno_error(error, "created upload destination"))?;
|
||||||
Ok((ino, new_uid))
|
Ok((ino, new_uid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Synthetic nodes and naming rules for the mounted shared tree.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
|
|
||||||
|
pub(crate) const VIRTUAL_VOLUME: &str = "virtual";
|
||||||
|
pub(crate) const SHARED_WITH_ME_LINK: &str = "sharedwithme";
|
||||||
|
pub(crate) const SHARED_WITH_ME_BASE: &str = "Shared with me";
|
||||||
|
const MAX_COMPONENT_BYTES: usize = 255;
|
||||||
|
|
||||||
|
pub(crate) fn shared_with_me_uid() -> NodeUid {
|
||||||
|
NodeUid::new(
|
||||||
|
VolumeId::from(VIRTUAL_VOLUME),
|
||||||
|
LinkId::from(SHARED_WITH_ME_LINK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_virtual_uid(uid: &NodeUid) -> bool {
|
||||||
|
uid.volume_id.as_str() == VIRTUAL_VOLUME
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_own_or_virtual_uid(uid: &NodeUid, own_volume: &VolumeId) -> bool {
|
||||||
|
uid.volume_id == *own_volume || is_virtual_uid(uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_primary_root_listing(
|
||||||
|
primary: bool,
|
||||||
|
folder_uid: &NodeUid,
|
||||||
|
primary_root_uid: &NodeUid,
|
||||||
|
) -> bool {
|
||||||
|
primary && folder_uid == primary_root_uid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) -> Node {
|
||||||
|
Node {
|
||||||
|
uid: shared_with_me_uid(),
|
||||||
|
parent_uid: Some(parent_uid),
|
||||||
|
name,
|
||||||
|
kind: NodeKind::Folder,
|
||||||
|
creation_time: timestamp,
|
||||||
|
modification_time: timestamp,
|
||||||
|
trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
is_shared_publicly: false,
|
||||||
|
signature_email: None,
|
||||||
|
membership: None,
|
||||||
|
// A synthetic folder is neither a photo nor an album.
|
||||||
|
photo: None,
|
||||||
|
album: None,
|
||||||
|
verification: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the initial synthetic-root name. Once persisted, `pinned` always wins;
|
||||||
|
/// a later real collision only controls whether the synthetic dentry is exposed.
|
||||||
|
pub(crate) fn virtual_root_name(
|
||||||
|
real_names: &HashSet<String>,
|
||||||
|
pinned: Option<&str>,
|
||||||
|
) -> (String, bool) {
|
||||||
|
if let Some(pinned) = pinned {
|
||||||
|
return (pinned.to_string(), !real_names.contains(pinned));
|
||||||
|
}
|
||||||
|
for n in 0usize.. {
|
||||||
|
let candidate = match n {
|
||||||
|
0 => SHARED_WITH_ME_BASE.to_string(),
|
||||||
|
1 => format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||||
|
_ => format!("{SHARED_WITH_ME_BASE} (Proton {n})"),
|
||||||
|
};
|
||||||
|
if !real_names.contains(&candidate) {
|
||||||
|
return (candidate, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreachable!("the numbered virtual-root namespace is unbounded")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give every shared root a unique Linux component name, independent of API
|
||||||
|
/// ordering. Literal suffix-shaped names reserve their basename before duplicate
|
||||||
|
/// names are numbered, so `Photos (2)` is never stolen by a second `Photos`.
|
||||||
|
pub(crate) fn disambiguate_shared_names(nodes: &mut [Node]) {
|
||||||
|
nodes.sort_by(|a, b| {
|
||||||
|
a.name
|
||||||
|
.cmp(&b.name)
|
||||||
|
.then_with(|| a.uid.to_string().cmp(&b.uid.to_string()))
|
||||||
|
});
|
||||||
|
let reserved: HashSet<String> = nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| component_with_suffix(&node.name, ""))
|
||||||
|
.collect();
|
||||||
|
let mut used = HashSet::with_capacity(nodes.len());
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
let original = node.name.clone();
|
||||||
|
let basename = component_with_suffix(&original, "");
|
||||||
|
let chosen = if used.insert(basename.clone()) {
|
||||||
|
basename
|
||||||
|
} else {
|
||||||
|
let mut n = 2usize;
|
||||||
|
loop {
|
||||||
|
let suffix = format!(" ({n})");
|
||||||
|
let candidate = component_with_suffix(&original, &suffix);
|
||||||
|
if !reserved.contains(&candidate) && used.insert(candidate.clone()) {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.name = chosen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn listing_needs_refresh(online: bool, stale: bool) -> bool {
|
||||||
|
online && stale
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum SharedListingPlan {
|
||||||
|
Resident,
|
||||||
|
Persisted,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shared_listing_plan(resident: bool, online: bool, stale: bool) -> SharedListingPlan {
|
||||||
|
if listing_needs_refresh(online, stale) {
|
||||||
|
SharedListingPlan::Refresh
|
||||||
|
} else if resident {
|
||||||
|
SharedListingPlan::Resident
|
||||||
|
} else {
|
||||||
|
SharedListingPlan::Persisted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn refresh_generation_is_current(started: u64, current: u64) -> bool {
|
||||||
|
started == current
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct SharedRefreshDeadlines {
|
||||||
|
deadlines: HashMap<String, Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedRefreshDeadlines {
|
||||||
|
pub(crate) fn is_fresh(&mut self, key: &str, now: Instant) -> bool {
|
||||||
|
self.deadlines.retain(|_, deadline| *deadline > now);
|
||||||
|
self.deadlines
|
||||||
|
.get(key)
|
||||||
|
.is_some_and(|deadline| *deadline > now)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn mark(&mut self, key: &str, now: Instant, ttl: Duration) {
|
||||||
|
self.deadlines.insert(key.to_string(), now + ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear(&mut self) {
|
||||||
|
self.deadlines.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn component_with_suffix(name: &str, suffix: &str) -> String {
|
||||||
|
let keep = MAX_COMPONENT_BYTES.saturating_sub(suffix.len());
|
||||||
|
let mut boundary = name.len().min(keep);
|
||||||
|
while !name.is_char_boundary(boundary) {
|
||||||
|
boundary -= 1;
|
||||||
|
}
|
||||||
|
let mut out = String::with_capacity(boundary + suffix.len());
|
||||||
|
out.push_str(&name[..boundary]);
|
||||||
|
out.push_str(suffix);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn folder(volume: &str, link: &str, name: &str) -> Node {
|
||||||
|
let mut node = virtual_node(
|
||||||
|
NodeUid::new(VolumeId::from("virtual"), LinkId::from("parent")),
|
||||||
|
name.to_string(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
node.uid = NodeUid::new(VolumeId::from(volume), LinkId::from(link));
|
||||||
|
node
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn virtual_uid_and_node_round_trip() {
|
||||||
|
let parent = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||||
|
let node = virtual_node(parent.clone(), SHARED_WITH_ME_BASE.into(), 42);
|
||||||
|
assert_eq!(node.uid.to_string(), "virtual~sharedwithme");
|
||||||
|
assert!(is_virtual_uid(&node.uid));
|
||||||
|
assert_eq!(node.parent_uid.as_ref(), Some(&parent));
|
||||||
|
assert!(node.is_folder());
|
||||||
|
let json = serde_json::to_string(&node).unwrap();
|
||||||
|
let restored: Node = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(restored.uid, node.uid);
|
||||||
|
assert_eq!(restored.name, SHARED_WITH_ME_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_collision_ladder_and_pinned_collision_policy() {
|
||||||
|
let names = HashSet::from([
|
||||||
|
SHARED_WITH_ME_BASE.to_string(),
|
||||||
|
format!("{SHARED_WITH_ME_BASE} (Proton)"),
|
||||||
|
format!("{SHARED_WITH_ME_BASE} (Proton 2)"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
virtual_root_name(&names, None),
|
||||||
|
(format!("{SHARED_WITH_ME_BASE} (Proton 3)"), true)
|
||||||
|
);
|
||||||
|
|
||||||
|
let (name, visible) = virtual_root_name(&names, Some(SHARED_WITH_ME_BASE));
|
||||||
|
assert_eq!(name, SHARED_WITH_ME_BASE);
|
||||||
|
assert!(!visible, "a real later collision suppresses the dentry");
|
||||||
|
|
||||||
|
let cleared = HashSet::new();
|
||||||
|
assert_eq!(
|
||||||
|
virtual_root_name(&cleared, Some(SHARED_WITH_ME_BASE)),
|
||||||
|
(SHARED_WITH_ME_BASE.to_string(), true),
|
||||||
|
"the pinned name returns when the collision clears"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_names_are_stable_under_reordering_and_reserve_suffixes() {
|
||||||
|
let input = vec![
|
||||||
|
folder("b", "2", "Photos"),
|
||||||
|
folder("c", "3", "Photos (2)"),
|
||||||
|
folder("a", "1", "Photos"),
|
||||||
|
];
|
||||||
|
let mut forward = input.clone();
|
||||||
|
let mut reverse = input;
|
||||||
|
reverse.reverse();
|
||||||
|
disambiguate_shared_names(&mut forward);
|
||||||
|
disambiguate_shared_names(&mut reverse);
|
||||||
|
let summarize = |nodes: &[Node]| {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| (node.uid.to_string(), node.name.clone()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(summarize(&forward), summarize(&reverse));
|
||||||
|
assert_eq!(
|
||||||
|
forward
|
||||||
|
.iter()
|
||||||
|
.map(|node| node.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["Photos", "Photos (3)", "Photos (2)"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disambiguation_respects_utf8_component_limit() {
|
||||||
|
let long = "é".repeat(200);
|
||||||
|
let mut nodes = vec![folder("a", "1", &long), folder("b", "2", &long)];
|
||||||
|
disambiguate_shared_names(&mut nodes);
|
||||||
|
assert!(nodes.iter().all(|node| node.name.len() <= 255));
|
||||||
|
assert!(
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.all(|node| node.name.is_char_boundary(node.name.len()))
|
||||||
|
);
|
||||||
|
assert_ne!(nodes[0].name, nodes[1].name);
|
||||||
|
assert!(nodes[1].name.ends_with(" (2)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ttl_refresh_only_happens_online() {
|
||||||
|
assert!(!listing_needs_refresh(false, true));
|
||||||
|
assert!(!listing_needs_refresh(true, false));
|
||||||
|
assert!(listing_needs_refresh(true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_snapshot_suppresses_the_api_and_memory_deadline_suppresses_retries() {
|
||||||
|
assert_eq!(
|
||||||
|
shared_listing_plan(false, false, true),
|
||||||
|
SharedListingPlan::Persisted
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
shared_listing_plan(true, true, false),
|
||||||
|
SharedListingPlan::Resident
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut deadlines = SharedRefreshDeadlines::default();
|
||||||
|
deadlines.mark("shared", now, Duration::from_secs(60));
|
||||||
|
assert!(deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||||
|
deadlines.clear();
|
||||||
|
assert!(!deadlines.is_fresh("shared", now + Duration::from_secs(30)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_change_rejects_an_inflight_response() {
|
||||||
|
assert!(refresh_generation_is_current(7, 7));
|
||||||
|
assert!(!refresh_generation_is_current(7, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_primary_inode_space_installs_the_synthetic_root() {
|
||||||
|
let root = NodeUid::new(VolumeId::from("own"), LinkId::from("root"));
|
||||||
|
assert!(is_primary_root_listing(true, &root, &root));
|
||||||
|
assert!(!is_primary_root_listing(false, &root, &root));
|
||||||
|
assert!(!is_primary_root_listing(
|
||||||
|
true,
|
||||||
|
&NodeUid::new(VolumeId::from("own"), LinkId::from("device")),
|
||||||
|
&root
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ownership_predicate_excludes_foreign_share_volumes() {
|
||||||
|
let own = VolumeId::from("own");
|
||||||
|
let own_uid = NodeUid::new(own.clone(), LinkId::from("file"));
|
||||||
|
let foreign = NodeUid::new(VolumeId::from("foreign"), LinkId::from("share"));
|
||||||
|
assert!(is_own_or_virtual_uid(&own_uid, &own));
|
||||||
|
assert!(is_own_or_virtual_uid(&shared_with_me_uid(), &own));
|
||||||
|
assert!(!is_own_or_virtual_uid(&foreign, &own));
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-157
@@ -4,8 +4,10 @@ pub(crate) mod pages;
|
|||||||
pub(crate) mod widgets;
|
pub(crate) mod widgets;
|
||||||
|
|
||||||
use pages::activity::*;
|
use pages::activity::*;
|
||||||
|
use pages::albums::*;
|
||||||
use pages::browser::*;
|
use pages::browser::*;
|
||||||
use pages::devices::*;
|
use pages::devices::*;
|
||||||
|
use pages::locations::*;
|
||||||
use pages::login::*;
|
use pages::login::*;
|
||||||
use pages::photo_viewer::*;
|
use pages::photo_viewer::*;
|
||||||
use pages::photos::*;
|
use pages::photos::*;
|
||||||
@@ -42,13 +44,15 @@ use pdfs_core::auth;
|
|||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
|
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo,
|
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||||
JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response,
|
||||||
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, TransferItem, pending_summary,
|
||||||
send,
|
send,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec};
|
||||||
|
|
||||||
use pdfs_core::service;
|
use pdfs_core::service;
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux";
|
const APP_ID: &str = "io.narl.proton-drive-linux";
|
||||||
@@ -113,6 +117,7 @@ struct Ui {
|
|||||||
pub(crate) shared: SharedState,
|
pub(crate) shared: SharedState,
|
||||||
pub(crate) shared_by_me: SharedByMeState,
|
pub(crate) shared_by_me: SharedByMeState,
|
||||||
pub(crate) devices: DevicesState,
|
pub(crate) devices: DevicesState,
|
||||||
|
pub(crate) locations: LocationsState,
|
||||||
pub(crate) activity: ActivityState,
|
pub(crate) activity: ActivityState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,23 +139,10 @@ impl Ui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its
|
/// Remember a decoded thumbnail, evicting the oldest once the cache is full.
|
||||||
/// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`].
|
/// Scrolling back over a day then repaints from memory rather than decoding
|
||||||
fn ratio(&self, uid: &str) -> f64 {
|
/// the same JPEGs off disk again.
|
||||||
self.gallery
|
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) {
|
||||||
.photo_ratio
|
|
||||||
.borrow()
|
|
||||||
.get(uid)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(RATIO_UNKNOWN)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remember a decoded thumbnail and the aspect ratio it revealed. Returns
|
|
||||||
/// true when the ratio is *new information* — the caller re-justifies the
|
|
||||||
/// affected rows, since they were laid out against a guess.
|
|
||||||
fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool {
|
|
||||||
let (w, h) = (texture.width(), texture.height());
|
|
||||||
|
|
||||||
let mut cache = self.gallery.photo_tex.borrow_mut();
|
let mut cache = self.gallery.photo_tex.borrow_mut();
|
||||||
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
let mut order = self.gallery.photo_tex_order.borrow_mut();
|
||||||
if cache.insert(uid.to_string(), texture).is_none() {
|
if cache.insert(uid.to_string(), texture).is_none() {
|
||||||
@@ -161,58 +153,6 @@ impl Ui {
|
|||||||
cache.remove(&old);
|
cache.remove(&old);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop((cache, order));
|
|
||||||
|
|
||||||
if h <= 0 {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let ratio = f64::from(w) / f64::from(h);
|
|
||||||
let mut ratios = self.gallery.photo_ratio.borrow_mut();
|
|
||||||
match ratios.insert(uid.to_string(), ratio) {
|
|
||||||
// Only a *changed* ratio invalidates a layout; re-decoding a photo we
|
|
||||||
// already sized correctly must not trigger another pass.
|
|
||||||
Some(prev) if (prev - ratio).abs() < f64::EPSILON => false,
|
|
||||||
_ => {
|
|
||||||
self.gallery.photo_ratio_dirty.set(true);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the learned aspect ratios are persisted between runs.
|
|
||||||
fn ratio_path(&self) -> PathBuf {
|
|
||||||
self.dirs.cache_dir().join("photo-ratios.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load the persisted aspect ratios. A missing or corrupt file just means the
|
|
||||||
/// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land.
|
|
||||||
fn load_ratios(&self) {
|
|
||||||
let path = self.ratio_path();
|
|
||||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match serde_json::from_str::<HashMap<String, f64>>(&text) {
|
|
||||||
Ok(map) => *self.gallery.photo_ratio.borrow_mut() = map,
|
|
||||||
Err(e) => tracing::debug!("ignoring {}: {e}", path.display()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist the learned aspect ratios, if any were learned since the last save.
|
|
||||||
fn save_ratios(&self) {
|
|
||||||
if !self.gallery.photo_ratio_dirty.replace(false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let path = self.ratio_path();
|
|
||||||
let ratios = self.gallery.photo_ratio.borrow();
|
|
||||||
let write = serde_json::to_vec(&*ratios)
|
|
||||||
.map_err(std::io::Error::other)
|
|
||||||
.and_then(|bytes| {
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?;
|
|
||||||
std::fs::write(&path, bytes)
|
|
||||||
});
|
|
||||||
if let Err(e) = write {
|
|
||||||
tracing::debug!("cannot save {}: {e}", path.display());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,15 +223,23 @@ fn load_proton_theme() {
|
|||||||
.viewer-spinner {{ color: white; }}\n\
|
.viewer-spinner {{ color: white; }}\n\
|
||||||
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
.viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\
|
||||||
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
.viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\
|
||||||
.gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\
|
.gallery-day {{ font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 4px 2px 8px 2px; }}\n\
|
||||||
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\
|
.photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 14px; background: alpha(currentColor, 0.06); box-shadow: none; transition: box-shadow 180ms ease, background 180ms ease; }}\n\
|
||||||
.photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\
|
.photo-tile:hover {{ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }}\n\
|
||||||
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
|
||||||
|
.photo-thumb {{ transition: transform 220ms ease; }}\n\
|
||||||
|
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
|
||||||
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
|
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
|
||||||
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\
|
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
|
||||||
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
|
||||||
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
|
||||||
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
|
||||||
|
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
|
||||||
|
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
|
||||||
|
.album-cover {{ border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }}\n\
|
||||||
|
.album-card:hover .album-cover {{ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }}\n\
|
||||||
|
.album-card:hover .photo-thumb {{ transform: scale(1.05); }}\n\
|
||||||
|
.view-switch button {{ padding-left: 18px; padding-right: 18px; }}\n\
|
||||||
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
.card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\
|
||||||
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
.card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\
|
||||||
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
.navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\
|
||||||
@@ -333,6 +281,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
let (shared_page, shared_widgets) = build_shared_page();
|
let (shared_page, shared_widgets) = build_shared_page();
|
||||||
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page();
|
||||||
let (devices_page, devices_widgets) = build_devices_page();
|
let (devices_page, devices_widgets) = build_devices_page();
|
||||||
|
let (locations_page, locations_widgets) = build_locations_page();
|
||||||
let (activity_page, activity_widgets) = build_activity_page();
|
let (activity_page, activity_widgets) = build_activity_page();
|
||||||
let (trash_page, trash_widgets) = build_trash_page();
|
let (trash_page, trash_widgets) = build_trash_page();
|
||||||
stack.add_named(&login_page, Some("login"));
|
stack.add_named(&login_page, Some("login"));
|
||||||
@@ -342,6 +291,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
stack.add_named(&shared_by_me_page, Some("sharedbyme"));
|
||||||
stack.add_named(&shared_page, Some("shared"));
|
stack.add_named(&shared_page, Some("shared"));
|
||||||
stack.add_named(&devices_page, Some("devices"));
|
stack.add_named(&devices_page, Some("devices"));
|
||||||
|
stack.add_named(&locations_page, Some("locations"));
|
||||||
stack.add_named(&activity_page, Some("activity"));
|
stack.add_named(&activity_page, Some("activity"));
|
||||||
stack.add_named(&trash_page, Some("trash"));
|
stack.add_named(&trash_page, Some("trash"));
|
||||||
|
|
||||||
@@ -456,9 +406,21 @@ fn build_window(app: &adw::Application) {
|
|||||||
retry: gallery_widgets.retry.clone(),
|
retry: gallery_widgets.retry.clone(),
|
||||||
more: gallery_widgets.more.clone(),
|
more: gallery_widgets.more.clone(),
|
||||||
upload: gallery_widgets.upload.clone(),
|
upload: gallery_widgets.upload.clone(),
|
||||||
|
title: gallery_widgets.title.clone(),
|
||||||
subtitle: gallery_widgets.subtitle.clone(),
|
subtitle: gallery_widgets.subtitle.clone(),
|
||||||
|
albums: gallery_widgets.albums.clone(),
|
||||||
|
albums_stack: gallery_widgets.albums_stack.clone(),
|
||||||
|
albums_status: gallery_widgets.albums_status.clone(),
|
||||||
|
photos_btn: gallery_widgets.photos_btn.clone(),
|
||||||
|
albums_btn: gallery_widgets.albums_btn.clone(),
|
||||||
|
view_switch: gallery_widgets.view_switch.clone(),
|
||||||
|
albums_loading: Cell::new(false),
|
||||||
|
album: RefCell::new(None),
|
||||||
|
back: gallery_widgets.back.clone(),
|
||||||
|
filters: gallery_widgets.filters.clone(),
|
||||||
kind: Cell::new(None),
|
kind: Cell::new(None),
|
||||||
tabs: gallery_widgets.tabs.clone(),
|
tabs: gallery_widgets.tabs.clone(),
|
||||||
|
counts: Cell::new(None),
|
||||||
dates: gallery_widgets.dates.clone(),
|
dates: gallery_widgets.dates.clone(),
|
||||||
date_ranges: RefCell::new(vec![None]),
|
date_ranges: RefCell::new(vec![None]),
|
||||||
range: Cell::new(None),
|
range: Cell::new(None),
|
||||||
@@ -467,8 +429,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
width: Cell::new(0),
|
width: Cell::new(0),
|
||||||
photo_tex: RefCell::new(HashMap::new()),
|
photo_tex: RefCell::new(HashMap::new()),
|
||||||
photo_tex_order: RefCell::new(VecDeque::new()),
|
photo_tex_order: RefCell::new(VecDeque::new()),
|
||||||
photo_ratio: RefCell::new(HashMap::new()),
|
|
||||||
photo_ratio_dirty: Cell::new(false),
|
|
||||||
photo_nothumb: RefCell::new(HashSet::new()),
|
photo_nothumb: RefCell::new(HashSet::new()),
|
||||||
thumb_wanted: RefCell::new(HashMap::new()),
|
thumb_wanted: RefCell::new(HashMap::new()),
|
||||||
thumb_queue: RefCell::new(VecDeque::new()),
|
thumb_queue: RefCell::new(VecDeque::new()),
|
||||||
@@ -513,6 +473,15 @@ fn build_window(app: &adw::Application) {
|
|||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
loaded_at: Cell::new(None),
|
loaded_at: Cell::new(None),
|
||||||
},
|
},
|
||||||
|
locations: LocationsState {
|
||||||
|
content: locations_widgets.content.clone(),
|
||||||
|
status: locations_widgets.status.clone(),
|
||||||
|
retry: locations_widgets.retry.clone(),
|
||||||
|
group: locations_widgets.group.clone(),
|
||||||
|
rows: RefCell::new(Vec::new()),
|
||||||
|
inflight: Cell::new(false),
|
||||||
|
loaded_at: Cell::new(None),
|
||||||
|
},
|
||||||
activity: ActivityState {
|
activity: ActivityState {
|
||||||
content: activity_widgets.content.clone(),
|
content: activity_widgets.content.clone(),
|
||||||
status: activity_widgets.status.clone(),
|
status: activity_widgets.status.clone(),
|
||||||
@@ -523,8 +492,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
key: RefCell::new(None),
|
key: RefCell::new(None),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
ui.load_ratios();
|
|
||||||
|
|
||||||
wire_login(&ui);
|
wire_login(&ui);
|
||||||
wire_logout(&ui, &main_widgets.logout_button);
|
wire_logout(&ui, &main_widgets.logout_button);
|
||||||
wire_settings(
|
wire_settings(
|
||||||
@@ -543,15 +510,12 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_details(&ui);
|
wire_details(&ui);
|
||||||
wire_search(&ui);
|
wire_search(&ui);
|
||||||
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
|
||||||
|
wire_albums(&ui);
|
||||||
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty);
|
||||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
wire_devices(
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
&ui,
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
&devices_widgets.retry,
|
|
||||||
&devices_widgets.add_folder,
|
|
||||||
&devices_widgets.restore,
|
|
||||||
);
|
|
||||||
wire_activity(&ui, &activity_widgets.retry);
|
wire_activity(&ui, &activity_widgets.retry);
|
||||||
wire_refresh(
|
wire_refresh(
|
||||||
&ui,
|
&ui,
|
||||||
@@ -562,6 +526,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
&shared_widgets.refresh,
|
&shared_widgets.refresh,
|
||||||
&shared_by_me_widgets.refresh,
|
&shared_by_me_widgets.refresh,
|
||||||
&devices_widgets.refresh,
|
&devices_widgets.refresh,
|
||||||
|
&locations_widgets.refresh,
|
||||||
&activity_widgets.refresh,
|
&activity_widgets.refresh,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -584,6 +549,8 @@ fn build_window(app: &adw::Application) {
|
|||||||
Some("shared") => load_shared(&ui_nav),
|
Some("shared") => load_shared(&ui_nav),
|
||||||
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {}
|
||||||
Some("devices") => load_devices(&ui_nav),
|
Some("devices") => load_devices(&ui_nav),
|
||||||
|
Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {}
|
||||||
|
Some("locations") => load_locations(&ui_nav),
|
||||||
// Activity is intentionally not TTL-cached: it changes out from under
|
// Activity is intentionally not TTL-cached: it changes out from under
|
||||||
// the page as background uploads and edits complete, so it reloads on
|
// the page as background uploads and edits complete, so it reloads on
|
||||||
// every visit to stay live.
|
// every visit to stay live.
|
||||||
@@ -612,12 +579,10 @@ fn build_window(app: &adw::Application) {
|
|||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
let cell = RefCell::new(Some(source));
|
let cell = RefCell::new(Some(source));
|
||||||
let ui_close = ui.clone();
|
|
||||||
window.connect_close_request(move |_| {
|
window.connect_close_request(move |_| {
|
||||||
if let Some(id) = cell.borrow_mut().take() {
|
if let Some(id) = cell.borrow_mut().take() {
|
||||||
id.remove();
|
id.remove();
|
||||||
}
|
}
|
||||||
ui_close.save_ratios();
|
|
||||||
glib::Propagation::Proceed
|
glib::Propagation::Proceed
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -626,12 +591,13 @@ fn build_window(app: &adw::Application) {
|
|||||||
|
|
||||||
/// The sidebar destinations, in order: the row index is the index into this table,
|
/// The sidebar destinations, in order: the row index is the index into this table,
|
||||||
/// and each entry is `(stack page name, label, icon)`.
|
/// and each entry is `(stack page name, label, icon)`.
|
||||||
const DESTINATIONS: [(&str, &str, &str); 8] = [
|
const DESTINATIONS: [(&str, &str, &str); 9] = [
|
||||||
("browser", "My files", "folder-symbolic"),
|
("browser", "My files", "folder-symbolic"),
|
||||||
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
("sharedbyme", "Shared", "emblem-shared-symbolic"),
|
||||||
("shared", "Shared with me", "system-users-symbolic"),
|
("shared", "Shared with me", "system-users-symbolic"),
|
||||||
|
("locations", "Locations", "drive-harddisk-symbolic"),
|
||||||
("devices", "Computers", "computer-symbolic"),
|
("devices", "Computers", "computer-symbolic"),
|
||||||
("gallery", "Photos", "image-x-generic-symbolic"),
|
("gallery", "Gallery", "image-x-generic-symbolic"),
|
||||||
("activity", "Activity", "document-open-recent-symbolic"),
|
("activity", "Activity", "document-open-recent-symbolic"),
|
||||||
("trash", "Trash", "user-trash-symbolic"),
|
("trash", "Trash", "user-trash-symbolic"),
|
||||||
("main", "Settings", "emblem-system-symbolic"),
|
("main", "Settings", "emblem-system-symbolic"),
|
||||||
@@ -734,7 +700,15 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
let path = ui.browser.path.borrow().clone();
|
let path = ui.browser.path.borrow().clone();
|
||||||
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
refresh_then(ui, RefreshScope::Dir { path }, load_browser);
|
||||||
}
|
}
|
||||||
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)),
|
// One scope covers the whole photos view, so Refresh reloads whichever of
|
||||||
|
// the two — album grid or timeline/album — is actually on screen.
|
||||||
|
Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| {
|
||||||
|
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||||
|
load_albums(ui);
|
||||||
|
} else {
|
||||||
|
load_gallery(ui, false);
|
||||||
|
}
|
||||||
|
}),
|
||||||
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash),
|
||||||
Some("shared") => {
|
Some("shared") => {
|
||||||
ui.shared.loaded_at.set(None);
|
ui.shared.loaded_at.set(None);
|
||||||
@@ -748,6 +722,10 @@ fn reload_current_page(ui: &Rc<Ui>) {
|
|||||||
ui.devices.loaded_at.set(None);
|
ui.devices.loaded_at.set(None);
|
||||||
load_devices(ui);
|
load_devices(ui);
|
||||||
}
|
}
|
||||||
|
Some("locations") => {
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
load_locations(ui);
|
||||||
|
}
|
||||||
Some("activity") => load_activity(ui),
|
Some("activity") => load_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1045,81 +1023,54 @@ fn human_bytes(bytes: u64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A justified row must fill the content width exactly: tile widths plus the
|
/// The full width one row of `columns` tiles occupies, gaps included.
|
||||||
/// gaps between them add up to the width, with nothing left ragged.
|
fn row_width(columns: usize, edge: i32) -> i32 {
|
||||||
fn row_width(row: &[(i32, i32)]) -> i32 {
|
edge * columns as i32 + TILE_GAP * (columns as i32 - 1)
|
||||||
let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32;
|
|
||||||
row.iter().map(|(width, _)| *width).sum::<i32>() + gaps
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn justified_rows_fill_the_width_exactly() {
|
fn the_grid_spans_the_content_width() {
|
||||||
let ratios = [
|
// Whatever the width, the columns plus their gaps land on it (bar the
|
||||||
1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6,
|
// few px integer division cannot divide), so no ragged right margin.
|
||||||
];
|
for width in [640, 900, 1000, 1440, 1920, 2560] {
|
||||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
let (columns, edge) = plan_grid(TILE_DEFAULT, width);
|
||||||
|
let used = row_width(columns, edge);
|
||||||
assert!(
|
assert!(
|
||||||
rows.len() > 1,
|
used <= width && width - used < columns as i32,
|
||||||
"12 photos at 180px should need several rows"
|
"{columns} x {edge}px = {used}px does not span {width}px"
|
||||||
);
|
);
|
||||||
// Every row but the trailing one is justified.
|
|
||||||
for row in &rows[..rows.len() - 1] {
|
|
||||||
assert_eq!(row_width(row), 1000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_photo_lands_in_exactly_one_row() {
|
fn tiles_stay_near_the_target_size() {
|
||||||
let ratios: Vec<f64> = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect();
|
// The column count rounds and the edge absorbs the remainder, so a tile
|
||||||
let rows = plan_rows(&ratios, 180.0, 1000.0);
|
// is never more than one gap-and-a-bit away from what was asked for.
|
||||||
let tiles: usize = rows.iter().map(Vec::len).sum();
|
let (columns, edge) = plan_grid(TILE_DEFAULT, 1920);
|
||||||
assert_eq!(tiles, ratios.len());
|
assert!(columns >= 7, "1920px should hold several 220px tiles");
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tiles_keep_their_aspect_ratio() {
|
|
||||||
let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0);
|
|
||||||
// All but the last tile of a justified row are sized straight from the
|
|
||||||
// ratio, so w/h is the photo's own — no cropping needed to place it.
|
|
||||||
for row in &rows {
|
|
||||||
for (width, height) in &row[..row.len() - 1] {
|
|
||||||
let ratio = f64::from(*width) / f64::from(*height);
|
|
||||||
assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn trailing_row_is_not_stretched() {
|
|
||||||
// Two photos can't fill a 1000px row at 180px tall, so they must keep the
|
|
||||||
// target height rather than being blown up to a screen-wide row.
|
|
||||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0);
|
|
||||||
assert_eq!(rows.len(), 1);
|
|
||||||
assert!(rows[0].iter().all(|(_, height)| *height == 180));
|
|
||||||
assert!(row_width(&rows[0]) < 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_extreme_panorama_cannot_squash_its_row() {
|
|
||||||
// Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall.
|
|
||||||
// Clamped at RATIO_MAX it stays a wide tile in a row of normal height.
|
|
||||||
let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0);
|
|
||||||
let (width, height) = rows[0][0];
|
|
||||||
assert!(height > 120, "row squashed to {height}px");
|
|
||||||
assert!(width <= 1000, "tile overflows the row at {width}px");
|
|
||||||
assert_eq!(row_width(&rows[0]), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn narrow_widths_and_no_photos_are_survivable() {
|
|
||||||
assert!(plan_rows(&[], 180.0, 1000.0).is_empty());
|
|
||||||
// Width below the floor is clamped rather than producing zero-px tiles.
|
|
||||||
let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0);
|
|
||||||
assert!(
|
assert!(
|
||||||
rows.iter()
|
(edge - TILE_DEFAULT).abs() < TILE_DEFAULT / 3,
|
||||||
.flatten()
|
"{edge}px is not near the {TILE_DEFAULT}px target"
|
||||||
.all(|(width, height)| *width > 0 && *height > 0)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zooming_in_widens_the_tiles_and_drops_columns() {
|
||||||
|
let (wide_columns, small_edge) = plan_grid(TILE_MIN, 1200);
|
||||||
|
let (few_columns, big_edge) = plan_grid(TILE_MAX, 1200);
|
||||||
|
assert!(wide_columns > few_columns);
|
||||||
|
assert!(big_edge > small_edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_narrower_than_one_tile_still_gets_a_column() {
|
||||||
|
// A single column at whatever fits, rather than zero columns (which would
|
||||||
|
// divide by zero) or a zero-px tile.
|
||||||
|
let (columns, edge) = plan_grid(TILE_DEFAULT, 40);
|
||||||
|
assert_eq!(columns, 1);
|
||||||
|
assert!(edge > 0);
|
||||||
|
let (columns, edge) = plan_grid(TILE_DEFAULT, 0);
|
||||||
|
assert_eq!(columns, 1);
|
||||||
|
assert!(edge > 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +281,7 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Share => "Shared",
|
ActivityKind::Share => "Shared",
|
||||||
ActivityKind::PublicLink => "Linked",
|
ActivityKind::PublicLink => "Linked",
|
||||||
ActivityKind::Unshare => "Unshared",
|
ActivityKind::Unshare => "Unshared",
|
||||||
|
ActivityKind::Conflict => "Conflict",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +300,7 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Restore => "edit-undo-symbolic",
|
ActivityKind::Restore => "edit-undo-symbolic",
|
||||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||||
|
ActivityKind::Conflict => "dialog-warning-symbolic",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! The Albums view of the Photos page: a grid of album covers, and the album
|
||||||
|
//! that opens when one is clicked.
|
||||||
|
//!
|
||||||
|
//! An open album reuses the gallery wholesale — same square grid, same
|
||||||
|
//! on-demand thumbnails, same lightbox — by parking the album in
|
||||||
|
//! [`GalleryState::album`] and letting [`load_gallery`] page
|
||||||
|
//! [`Request::AlbumPhotos`] instead of the timeline. Only the header and the
|
||||||
|
//! filter bar differ, because an album is served whole rather than filtered.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// Edge length in px of an album cover in the grid. Bigger than a timeline tile:
|
||||||
|
/// there are far fewer albums than photos, and the cover is the only thing
|
||||||
|
/// identifying one at a glance.
|
||||||
|
const COVER_EDGE: i32 = 200;
|
||||||
|
|
||||||
|
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||||
|
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||||
|
close_album(ui);
|
||||||
|
ui.gallery.content.set_visible_child_name("albums");
|
||||||
|
load_albums(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for the album listing and rebuild the grid.
|
||||||
|
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.albums_loading.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery.albums_loading.set(true);
|
||||||
|
albums_status(
|
||||||
|
ui,
|
||||||
|
"view-grid-symbolic",
|
||||||
|
"Loading albums…",
|
||||||
|
"Reading your Proton Drive albums.",
|
||||||
|
);
|
||||||
|
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.gallery.albums_loading.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Albums {
|
||||||
|
available: false, ..
|
||||||
|
})) => albums_status(
|
||||||
|
&ui,
|
||||||
|
"image-missing-symbolic",
|
||||||
|
"No photo library",
|
||||||
|
"This Proton account doesn't have Photos enabled.",
|
||||||
|
),
|
||||||
|
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||||
|
&ui,
|
||||||
|
"view-grid-symbolic",
|
||||||
|
"No albums",
|
||||||
|
"Albums you create in Proton Photos appear here.",
|
||||||
|
),
|
||||||
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
|
fill_albums(&ui, &items);
|
||||||
|
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||||
|
albums_status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
"Couldn't load albums",
|
||||||
|
&message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||||
|
&ui,
|
||||||
|
"network-offline-symbolic",
|
||||||
|
"Not connected",
|
||||||
|
"The Proton Drive mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||||
|
/// (newest activity first).
|
||||||
|
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||||
|
while let Some(child) = ui.gallery.albums.first_child() {
|
||||||
|
ui.gallery.albums.remove(&child);
|
||||||
|
}
|
||||||
|
for album in albums {
|
||||||
|
ui.gallery.albums.append(&album_card(ui, album));
|
||||||
|
}
|
||||||
|
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||||
|
// batch that fills them is the gallery's own.
|
||||||
|
schedule_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||||
|
/// holds. An album shared with us says so — it lives on someone else's volume,
|
||||||
|
/// and that is worth knowing before opening it.
|
||||||
|
fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
// Fills and crops the cover square, like a timeline tile.
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("photo-thumb");
|
||||||
|
|
||||||
|
let placeholder = gtk4::Image::builder()
|
||||||
|
.icon_name("view-grid-symbolic")
|
||||||
|
.pixel_size(32)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
placeholder.add_css_class("photo-placeholder");
|
||||||
|
|
||||||
|
let cover = gtk4::Overlay::new();
|
||||||
|
cover.set_child(Some(&placeholder));
|
||||||
|
cover.add_overlay(&picture);
|
||||||
|
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||||
|
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||||
|
cover.add_css_class("album-cover");
|
||||||
|
|
||||||
|
let name = gtk4::Label::builder()
|
||||||
|
.label(&album.name)
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.xalign(0.0)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
name.add_css_class("heading");
|
||||||
|
|
||||||
|
let count = gtk4::Label::builder()
|
||||||
|
.label(album_subtitle(album))
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.xalign(0.0)
|
||||||
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||||
|
.build();
|
||||||
|
count.add_css_class("dim-label");
|
||||||
|
count.add_css_class("caption");
|
||||||
|
|
||||||
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||||
|
card.append(&cover);
|
||||||
|
card.append(&name);
|
||||||
|
card.append(&count);
|
||||||
|
|
||||||
|
let button = gtk4::Button::builder().child(&card).build();
|
||||||
|
button.add_css_class("flat");
|
||||||
|
button.add_css_class("album-card");
|
||||||
|
button.set_tooltip_text(Some(&album.name));
|
||||||
|
|
||||||
|
if let Some(uid) = album.cover_uid.clone() {
|
||||||
|
want_cover(ui, uid, &picture);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ui_open = ui.clone();
|
||||||
|
let album = album.clone();
|
||||||
|
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||||
|
button
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "12 photos", plus where the album came from when it isn't ours.
|
||||||
|
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||||
|
let photos = match album.photo_count {
|
||||||
|
1 => "1 photo".to_string(),
|
||||||
|
n => format!("{n} photos"),
|
||||||
|
};
|
||||||
|
if album.shared {
|
||||||
|
format!("{photos} · shared with me")
|
||||||
|
} else {
|
||||||
|
photos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||||
|
/// same texture cache, same decode pacing as a timeline tile.
|
||||||
|
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||||
|
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||||
|
picture.set_paintable(Some(texture));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.gallery.photo_nothumb.borrow().contains(&uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery
|
||||||
|
.thumb_wanted
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(uid.clone(), picture.clone());
|
||||||
|
let mut queue = ui.gallery.thumb_queue.borrow_mut();
|
||||||
|
if !queue.contains(&uid) {
|
||||||
|
queue.push_back(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one album in the gallery: the timeline view, paged from the album
|
||||||
|
/// instead, with the filters that don't apply to it out of the way.
|
||||||
|
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||||
|
ui.gallery.title.set_label(&album.name);
|
||||||
|
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
||||||
|
ui.gallery.subtitle.set_visible(true);
|
||||||
|
*ui.gallery.album.borrow_mut() = Some(album);
|
||||||
|
|
||||||
|
// An album page carries no kind or date filter, and Upload targets the
|
||||||
|
// timeline rather than an album — hide those rather than offer controls that
|
||||||
|
// would silently do something else. The Photos/Albums switcher goes too: an
|
||||||
|
// album is a level below it, and back is the way out.
|
||||||
|
ui.gallery.filters.set_visible(false);
|
||||||
|
ui.gallery.view_switch.set_visible(false);
|
||||||
|
ui.gallery.upload.set_visible(false);
|
||||||
|
ui.gallery.back.set_visible(true);
|
||||||
|
// A stale kind/date filter would otherwise be sent with the album request.
|
||||||
|
ui.gallery.kind.set(None);
|
||||||
|
ui.gallery.range.set(None);
|
||||||
|
|
||||||
|
ui.gallery.content.set_visible_child_name("timeline");
|
||||||
|
load_gallery(ui, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave an open album, restoring the timeline's header and filters. A no-op
|
||||||
|
/// when no album is open, so callers can use it as "make sure we're not in one".
|
||||||
|
pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||||
|
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.gallery.title.set_label("Gallery");
|
||||||
|
ui.gallery.filters.set_visible(true);
|
||||||
|
ui.gallery.view_switch.set_visible(true);
|
||||||
|
ui.gallery.upload.set_visible(true);
|
||||||
|
ui.gallery.back.set_visible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the album grid's own status page (loading, empty, or an error).
|
||||||
|
fn albums_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||||
|
ui.gallery.albums_status.set_icon_name(Some(icon));
|
||||||
|
ui.gallery.albums_status.set_title(title);
|
||||||
|
ui.gallery.albums_status.set_description(Some(description));
|
||||||
|
ui.gallery.albums_stack.set_visible_child_name("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire the Photos/Albums switcher and the back button. The switcher swaps the
|
||||||
|
/// whole content area; back leaves an open album for the grid it came from.
|
||||||
|
pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||||
|
let ui_albums = ui.clone();
|
||||||
|
ui.gallery.albums_btn.clone().connect_toggled(move |btn| {
|
||||||
|
if btn.is_active() {
|
||||||
|
show_albums(&ui_albums);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the button being switched *on* acts: the group fires `toggled` for
|
||||||
|
// the one going off too, and acting on both would load twice.
|
||||||
|
let ui_photos = ui.clone();
|
||||||
|
ui.gallery.photos_btn.clone().connect_toggled(move |btn| {
|
||||||
|
if !btn.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The timeline is reloaded rather than restored, because an open album
|
||||||
|
// left the model holding its own photos.
|
||||||
|
close_album(&ui_photos);
|
||||||
|
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||||
|
load_gallery(&ui_photos, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_back = ui.clone();
|
||||||
|
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||||
|
show_albums(&ui_back);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1442,6 +1442,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
|
role: String::new(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| {
|
entries.sort_by(|a, b| {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
pub(crate) sync_group: adw::PreferencesGroup,
|
||||||
|
/// The single "This computer" summary row. The per-folder rows moved to the
|
||||||
|
/// Locations page, which owns every local path.
|
||||||
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) sync_rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
/// "Rename" action in the "This computer" header. Insensitive until the
|
/// "Rename" action in the "This computer" header. Insensitive until the
|
||||||
/// device list identifies this machine's own device.
|
/// device list identifies this machine's own device.
|
||||||
@@ -25,7 +27,8 @@ pub(crate) struct DevicesState {
|
|||||||
pub(crate) struct DevicesWidgets {
|
pub(crate) struct DevicesWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
/// "This computer" — the local folders synced to this machine's device.
|
/// "This computer" — this machine's device identity and a pointer to its
|
||||||
|
/// folders on the Locations page.
|
||||||
pub(crate) sync_group: adw::PreferencesGroup,
|
pub(crate) sync_group: adw::PreferencesGroup,
|
||||||
/// "Rename" in the "This computer" header — renames this machine's device.
|
/// "Rename" in the "This computer" header — renames this machine's device.
|
||||||
pub(crate) rename_this: gtk4::Button,
|
pub(crate) rename_this: gtk4::Button,
|
||||||
@@ -33,7 +36,6 @@ pub(crate) struct DevicesWidgets {
|
|||||||
/// machine's own device is deliberately not among them; see
|
/// machine's own device is deliberately not among them; see
|
||||||
/// [`repaint_devices`].
|
/// [`repaint_devices`].
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
pub(crate) add_folder: gtk4::Button,
|
|
||||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||||
/// directories after adopting it on a new machine.
|
/// directories after adopting it on a new machine.
|
||||||
pub(crate) restore: gtk4::Button,
|
pub(crate) restore: gtk4::Button,
|
||||||
@@ -54,11 +56,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
titles.set_hexpand(true);
|
titles.set_hexpand(true);
|
||||||
titles.append(&title);
|
titles.append(&title);
|
||||||
|
|
||||||
let add_folder = gtk4::Button::builder()
|
|
||||||
.label("Add Folder")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
add_folder.add_css_class("flat");
|
|
||||||
let restore = gtk4::Button::builder()
|
let restore = gtk4::Button::builder()
|
||||||
.label("Restore Folders")
|
.label("Restore Folders")
|
||||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
||||||
@@ -71,18 +68,14 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
header.append(&titles);
|
header.append(&titles);
|
||||||
header.append(&refresh);
|
header.append(&refresh);
|
||||||
header.append(&restore);
|
header.append(&restore);
|
||||||
header.append(&add_folder);
|
|
||||||
|
|
||||||
// The description spells out what the per-row On-demand switch does to the
|
// This page is about device *identity* — which computer this is, which other
|
||||||
// local copy, because the switch itself can't: turning it on *deletes* the
|
// computers back up to the account, and how to adopt one. The folders
|
||||||
// files from this disk, which is not something to discover afterwards.
|
// themselves are local paths, so they live on the Locations page; keeping a
|
||||||
|
// second copy of that list here would be two places to change the same mode.
|
||||||
let sync_group = adw::PreferencesGroup::builder()
|
let sync_group = adw::PreferencesGroup::builder()
|
||||||
.title("This computer")
|
.title("This computer")
|
||||||
.description(
|
.description("The device this machine backs up to.")
|
||||||
"Local folders backed up to this device. Synced folders keep a full copy on \
|
|
||||||
this disk; on-demand folders keep the files in Proton Drive only and fetch \
|
|
||||||
them as you open them.",
|
|
||||||
)
|
|
||||||
.build();
|
.build();
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
// Rename control for *this* machine's device, in the section header. The
|
||||||
// current device is filtered out of "Other computers", so this is the only
|
// current device is filtered out of "Other computers", so this is the only
|
||||||
@@ -145,7 +138,6 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
sync_group,
|
sync_group,
|
||||||
rename_this,
|
rename_this,
|
||||||
group,
|
group,
|
||||||
add_folder,
|
|
||||||
restore,
|
restore,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -153,20 +145,13 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Devices page's retry button and the "Add Folder" action.
|
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||||
pub(crate) fn wire_devices(
|
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||||
ui: &Rc<Ui>,
|
|
||||||
retry: >k4::Button,
|
|
||||||
add_folder: >k4::Button,
|
|
||||||
restore: >k4::Button,
|
|
||||||
) {
|
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_devices(&ui_retry);
|
load_devices(&ui_retry);
|
||||||
});
|
});
|
||||||
let ui_add = ui.clone();
|
|
||||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
|
||||||
let ui_restore = ui.clone();
|
let ui_restore = ui.clone();
|
||||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||||
let ui_ren = ui.clone();
|
let ui_ren = ui.clone();
|
||||||
@@ -224,9 +209,9 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Daemon reachable: show the list and paint the synced-folders section.
|
// Daemon reachable: show the list and paint the "This computer" section.
|
||||||
ui.devices.content.set_visible_child_name("list");
|
ui.devices.content.set_visible_child_name("list");
|
||||||
repaint_sync_folders(&ui, &folders);
|
repaint_this_computer(&ui, &folders);
|
||||||
|
|
||||||
let devices = match devices_reply {
|
let devices = match devices_reply {
|
||||||
Ok(Ok(Response::Devices { items })) => items,
|
Ok(Ok(Response::Devices { items })) => items,
|
||||||
@@ -239,35 +224,6 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh just the synced-folders section, with no status flash, no spinner and
|
|
||||||
/// no devices round-trip. Driven by the periodic tick while the Devices page is
|
|
||||||
/// on screen: a folder's sync progress is only live if something re-reads it.
|
|
||||||
///
|
|
||||||
/// A hiccup leaves the page as it is — the next tick tries again, and a real
|
|
||||||
/// outage is reported by [`load_devices`] on the next visit.
|
|
||||||
pub(crate) fn refresh_sync_folders(ui: &Rc<Ui>) {
|
|
||||||
if ui.devices.inflight.get() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ui.devices.inflight.set(true);
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
|
|
||||||
let ui = ui.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
let result = rx.recv().await;
|
|
||||||
ui.devices.inflight.set(false);
|
|
||||||
let Ok(Ok(Response::SyncFolders { items })) = result else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// The page may have been navigated away from, or collapsed to a status
|
|
||||||
// view, while the request was in flight.
|
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("devices")
|
|
||||||
&& ui.devices.content.visible_child_name().as_deref() == Some("list")
|
|
||||||
{
|
|
||||||
repaint_sync_folders(&ui, &items);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The daemon didn't answer the Devices page.
|
/// The daemon didn't answer the Devices page.
|
||||||
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||||
if service::is_failed() || !service::is_active() {
|
if service::is_failed() || !service::is_active() {
|
||||||
@@ -398,137 +354,53 @@ pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the "This computer" section from a fresh synced-folders listing.
|
/// Rebuild the "This computer" section: one row naming the device this machine
|
||||||
pub(crate) fn repaint_sync_folders(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
/// backs up to, and how many folders it carries.
|
||||||
|
///
|
||||||
|
/// The per-folder rows (mode switch, sync now, remove) live on the Locations
|
||||||
|
/// page. They are local paths, and Locations is the one page that owns those;
|
||||||
|
/// two lists of the same folders would be two places to flip the same switch.
|
||||||
|
pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||||
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
for row in ui.devices.sync_rows.borrow_mut().drain(..) {
|
||||||
ui.devices.sync_group.remove(&row);
|
ui.devices.sync_group.remove(&row);
|
||||||
}
|
}
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("This computer")
|
||||||
|
.subtitle(this_computer_subtitle(folders))
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
|
let manage = gtk4::Button::builder()
|
||||||
|
.label("Locations")
|
||||||
|
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
manage.add_css_class("flat");
|
||||||
|
let ui_go = ui.clone();
|
||||||
|
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||||
|
row.add_suffix(&manage);
|
||||||
|
ui.devices.sync_group.add(&row);
|
||||||
|
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many folders this machine backs up, and whether any of them needs
|
||||||
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
if folders.is_empty() {
|
if folders.is_empty() {
|
||||||
let row = adw::ActionRow::builder()
|
return "No folders backed up yet — add one under Locations.".to_string();
|
||||||
.title("No synced folders")
|
|
||||||
.subtitle("Add a folder to back it up to this device.")
|
|
||||||
.build();
|
|
||||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
|
||||||
ui.devices.sync_group.add(&row);
|
|
||||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
let count = match folders.len() {
|
||||||
for f in folders {
|
1 => "1 folder backed up".to_string(),
|
||||||
// A running pass describes itself ("Uploading photo.jpg — 12 of 40");
|
n => format!("{n} folders backed up"),
|
||||||
// otherwise fall back to the folder's resting state.
|
};
|
||||||
let status = match &f.progress {
|
let attention = folders
|
||||||
Some(p) => sync_progress_label(p),
|
.iter()
|
||||||
None => sync_state_label(&f.state).to_string(),
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
};
|
.count();
|
||||||
// A queued switch is where the folder is *going*, which is what the user
|
match attention {
|
||||||
// just asked for and is waiting to see happen — so it leads the subtitle
|
0 => format!("{count} · manage them under Locations"),
|
||||||
// instead of the mode the folder is still technically in.
|
1 => format!("{count} · 1 needs attention"),
|
||||||
let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) {
|
n => format!("{count} · {n} need attention"),
|
||||||
(Some("ondemand"), _) => format!("Going on-demand · {status}"),
|
|
||||||
(Some(_), _) => format!("Switching to synced · {status}"),
|
|
||||||
(None, "ondemand") => format!("On-demand · {status}"),
|
|
||||||
(None, _) => format!("Synced · {status}"),
|
|
||||||
};
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(&f.local_path)
|
|
||||||
.subtitle(&subtitle)
|
|
||||||
.build();
|
|
||||||
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
|
||||||
let id = f.id;
|
|
||||||
|
|
||||||
// A pass that knows how far it has got shows it: the subtitle alone
|
|
||||||
// ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This
|
|
||||||
// holds for scanning as much as applying — a big folder spends minutes there
|
|
||||||
// — but a folder syncing for the first time has no estimate to draw against
|
|
||||||
// (`total == 0`), and this row is repainted on the 2s tick, too slow for a
|
|
||||||
// pulse to read as motion. So it stays text-only until real counts exist.
|
|
||||||
if let Some(p) = &f.progress
|
|
||||||
&& p.total > 0
|
|
||||||
{
|
|
||||||
let bar = gtk4::ProgressBar::builder()
|
|
||||||
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.width_request(120)
|
|
||||||
.build();
|
|
||||||
row.add_suffix(&bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync now — only meaningful for mirror folders (on-demand has no local
|
|
||||||
// copy to reconcile). The engine also syncs on file changes and on a
|
|
||||||
// 120s poll; this is the "don't wait" button.
|
|
||||||
if f.mode != "ondemand" {
|
|
||||||
let sync_now = gtk4::Button::builder()
|
|
||||||
.icon_name("view-refresh-symbolic")
|
|
||||||
.tooltip_text("Sync this folder now")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
sync_now.add_css_class("flat");
|
|
||||||
let ui_sync = ui.clone();
|
|
||||||
sync_now.connect_clicked(move |_| {
|
|
||||||
let rx = spawn_request(
|
|
||||||
ui_sync.dirs.control_socket(),
|
|
||||||
Request::SyncNow { id: Some(id) },
|
|
||||||
);
|
|
||||||
let ui_sync = ui_sync.clone();
|
|
||||||
glib::spawn_future_local(async move {
|
|
||||||
match rx.recv().await {
|
|
||||||
Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"),
|
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
|
||||||
toast_failure(&ui_sync, "Couldn't sync", &message, kind)
|
|
||||||
}
|
|
||||||
_ => toast_error(
|
|
||||||
&ui_sync,
|
|
||||||
"Couldn't sync",
|
|
||||||
"The mount service didn't respond.",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
row.add_suffix(&sync_now);
|
|
||||||
}
|
|
||||||
|
|
||||||
// On-demand toggle: off = full local copy (mirror), on = FUSE mount that
|
|
||||||
// frees the disk. Set the state before wiring the handler so painting the
|
|
||||||
// current mode doesn't fire a spurious request.
|
|
||||||
//
|
|
||||||
// A queued switch paints as already flipped: the daemon accepted the
|
|
||||||
// request and will act on it, so snapping the switch back to the current
|
|
||||||
// mode would read as "it didn't take" and invite the user to toggle again.
|
|
||||||
let target = f.pending_mode.as_deref().unwrap_or(&f.mode);
|
|
||||||
let ondemand = gtk4::Switch::builder()
|
|
||||||
.tooltip_text(
|
|
||||||
"On-demand: free this disk by keeping the files in Proton Drive only, \
|
|
||||||
fetching each as you open it. Turn off to download them back and keep a \
|
|
||||||
full local copy.",
|
|
||||||
)
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.active(target == "ondemand")
|
|
||||||
.build();
|
|
||||||
let ui_mode = ui.clone();
|
|
||||||
ondemand.connect_state_set(move |_, on| {
|
|
||||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
|
||||||
glib::Propagation::Proceed
|
|
||||||
});
|
|
||||||
row.add_suffix(&ondemand);
|
|
||||||
|
|
||||||
let remove = gtk4::Button::builder()
|
|
||||||
.icon_name("user-trash-symbolic")
|
|
||||||
.tooltip_text("Stop syncing this folder")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
remove.add_css_class("flat");
|
|
||||||
let ui_rm = ui.clone();
|
|
||||||
let path = f.local_path.clone();
|
|
||||||
// The folder's *current* mode, not a queued one: a switch that hasn't
|
|
||||||
// landed yet has not moved the files anywhere.
|
|
||||||
let ondemand = f.mode == "ondemand";
|
|
||||||
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand));
|
|
||||||
row.add_suffix(&remove);
|
|
||||||
ui.devices.sync_group.add(&row);
|
|
||||||
rows.push(row.upcast());
|
|
||||||
}
|
}
|
||||||
*ui.devices.sync_rows.borrow_mut() = rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human label for a synced folder's `state` column.
|
/// Human label for a synced folder's `state` column.
|
||||||
@@ -592,7 +464,7 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
|||||||
// device and creating the remote folder is off-socket network
|
// device and creating the remote folder is off-socket network
|
||||||
// work. A fixed delay here either fires too early (empty list)
|
// work. A fixed delay here either fires too early (empty list)
|
||||||
// or too late (row flashes in). Instead let the periodic
|
// or too late (row flashes in). Instead let the periodic
|
||||||
// `refresh_sync_folders` tick pick the row up whenever it
|
// `refresh_locations` tick pick the row up whenever it
|
||||||
// actually lands, which is what keeps a running pass live too.
|
// actually lands, which is what keeps a running pass live too.
|
||||||
toast(&ui, "Syncing folder…");
|
toast(&ui, "Syncing folder…");
|
||||||
}
|
}
|
||||||
@@ -942,3 +814,47 @@ pub(crate) fn run_devices_mutation(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn folder(state: &str) -> SyncFolderInfo {
|
||||||
|
SyncFolderInfo {
|
||||||
|
id: 1,
|
||||||
|
local_path: "/home/u/Documents".into(),
|
||||||
|
remote_uid: "vol~link".into(),
|
||||||
|
mode: "mirror".into(),
|
||||||
|
state: state.into(),
|
||||||
|
last_sync: 0,
|
||||||
|
pending_mode: None,
|
||||||
|
progress: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[]),
|
||||||
|
"No folders backed up yet — add one under Locations."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folders_needing_attention_are_counted_not_hidden() {
|
||||||
|
// The per-folder rows moved to Locations, so this row is the only place
|
||||||
|
// the Computers page can surface that something is wrong.
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||||
|
"2 folders backed up · manage them under Locations"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||||
|
"2 folders backed up · 1 needs attention"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
this_computer_subtitle(&[folder("conflict"), folder("error")]),
|
||||||
|
"2 folders backed up · 2 need attention"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
//! The Locations page: every local place Proton Drive occupies on this machine.
|
||||||
|
//!
|
||||||
|
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
||||||
|
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||||
|
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
||||||
|
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
||||||
|
//! with no FUSE session behind it (mount-architecture.md §4).
|
||||||
|
//!
|
||||||
|
//! The device rows drive the same control requests the Computers page used to:
|
||||||
|
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
||||||
|
//! listed alongside them, with the mountpoint chooser that used to live in
|
||||||
|
//! Settings.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
pub(crate) struct LocationsState {
|
||||||
|
pub(crate) content: gtk4::Stack,
|
||||||
|
pub(crate) status: adw::StatusPage,
|
||||||
|
pub(crate) retry: gtk4::Button,
|
||||||
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
|
pub(crate) inflight: Cell<bool>,
|
||||||
|
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widgets the Locations page's load/repaint touch.
|
||||||
|
pub(crate) struct LocationsWidgets {
|
||||||
|
pub(crate) content: gtk4::Stack,
|
||||||
|
pub(crate) status: adw::StatusPage,
|
||||||
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) retry: gtk4::Button,
|
||||||
|
pub(crate) refresh: gtk4::Button,
|
||||||
|
pub(crate) add_folder: gtk4::Button,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||||
|
let title = gtk4::Label::builder()
|
||||||
|
.label("Locations")
|
||||||
|
.halign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
title.add_css_class("title-2");
|
||||||
|
|
||||||
|
let add_folder = gtk4::Button::builder()
|
||||||
|
.label("Add Folder")
|
||||||
|
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
add_folder.add_css_class("flat");
|
||||||
|
let refresh = refresh_button();
|
||||||
|
|
||||||
|
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||||
|
titles.set_hexpand(true);
|
||||||
|
titles.append(&title);
|
||||||
|
header.append(&titles);
|
||||||
|
header.append(&refresh);
|
||||||
|
header.append(&add_folder);
|
||||||
|
// Clamped to the same width as the rows below, or the page title floats off
|
||||||
|
// to the left of the list it names.
|
||||||
|
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||||
|
|
||||||
|
// Same warning the Computers page carried, for the same reason: the
|
||||||
|
// on-demand switch removes the local copy, which is not a thing to discover
|
||||||
|
// after the fact.
|
||||||
|
let group = adw::PreferencesGroup::builder()
|
||||||
|
.title("On this computer")
|
||||||
|
.description(
|
||||||
|
"Where Proton Drive lives on this machine. Synced folders keep a full copy on \
|
||||||
|
this disk; on-demand folders keep the files in Proton Drive only and fetch \
|
||||||
|
them as you open them.",
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
groups.append(&group);
|
||||||
|
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||||
|
// Never scroll sideways: a location's title is a full path, and letting the
|
||||||
|
// row grow to fit one pushes the controls at its end off screen. Constrained,
|
||||||
|
// the path ellipsizes and the switch/remove/open stay reachable.
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let retry = gtk4::Button::builder()
|
||||||
|
.label("Retry")
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
retry.add_css_class("pill");
|
||||||
|
retry.add_css_class("suggested-action");
|
||||||
|
retry.set_visible(false);
|
||||||
|
let status = adw::StatusPage::builder()
|
||||||
|
.icon_name("drive-harddisk-symbolic")
|
||||||
|
.title("Loading…")
|
||||||
|
.child(&retry)
|
||||||
|
.build();
|
||||||
|
// A bare `StatusPage` asks for more width than a half-screen window has, and
|
||||||
|
// a `Stack` is as wide as its widest child — so an unwrapped status page
|
||||||
|
// silently widens the *list* page too, pushing each row's controls off the
|
||||||
|
// right edge. Scrolling it (never horizontally) caps that demand.
|
||||||
|
let status_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&status)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let content = gtk4::Stack::new();
|
||||||
|
content.add_named(&scroll, Some("list"));
|
||||||
|
content.add_named(&status_scroll, Some("status"));
|
||||||
|
|
||||||
|
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
page.set_margin_top(18);
|
||||||
|
page.set_margin_bottom(18);
|
||||||
|
page.set_margin_start(18);
|
||||||
|
page.set_margin_end(18);
|
||||||
|
page.append(&header_clamp);
|
||||||
|
page.append(&content);
|
||||||
|
|
||||||
|
let widgets = LocationsWidgets {
|
||||||
|
content: content.clone(),
|
||||||
|
status: status.clone(),
|
||||||
|
group: group.clone(),
|
||||||
|
retry: retry.clone(),
|
||||||
|
refresh,
|
||||||
|
add_folder,
|
||||||
|
};
|
||||||
|
(page.upcast(), widgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||||
|
let ui_retry = ui.clone();
|
||||||
|
retry.connect_clicked(move |_| {
|
||||||
|
ui_retry.locations.loaded_at.set(None);
|
||||||
|
load_locations(&ui_retry);
|
||||||
|
});
|
||||||
|
let ui_add = ui.clone();
|
||||||
|
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a status page in place of the locations list.
|
||||||
|
pub(crate) fn locations_status(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
icon: &str,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
retry: bool,
|
||||||
|
) {
|
||||||
|
ui.locations.status.set_icon_name(Some(icon));
|
||||||
|
ui.locations.status.set_title(title);
|
||||||
|
ui.locations.status.set_description(Some(description));
|
||||||
|
ui.locations.retry.set_visible(retry);
|
||||||
|
ui.locations.content.set_visible_child_name("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch every local location and repaint the list.
|
||||||
|
pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||||
|
if ui.locations.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.locations.inflight.set(true);
|
||||||
|
locations_status(
|
||||||
|
ui,
|
||||||
|
"drive-harddisk-symbolic",
|
||||||
|
"Loading…",
|
||||||
|
"Reading this computer's Proton Drive locations.",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
ui.locations.inflight.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Locations { items })) => {
|
||||||
|
ui.locations.content.set_visible_child_name("list");
|
||||||
|
repaint_locations(&ui, &items);
|
||||||
|
ui.locations.loaded_at.set(Some(Instant::now()));
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
locations_status(
|
||||||
|
&ui,
|
||||||
|
"dialog-warning-symbolic",
|
||||||
|
"Unavailable",
|
||||||
|
&message,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
locations_unreachable(&ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
|
||||||
|
/// a sync pass's progress is only live if something re-reads it.
|
||||||
|
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
|
||||||
|
if ui.locations.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.locations.inflight.set(true);
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.locations.inflight.set(false);
|
||||||
|
let Ok(Ok(Response::Locations { items })) = result else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// The page may have been navigated away from, or collapsed to a status
|
||||||
|
// view, while the request was in flight.
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("locations")
|
||||||
|
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
|
||||||
|
{
|
||||||
|
repaint_locations(&ui, &items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The daemon didn't answer the Locations page.
|
||||||
|
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
||||||
|
if service::is_failed() || !service::is_active() {
|
||||||
|
locations_status(
|
||||||
|
ui,
|
||||||
|
"network-offline-symbolic",
|
||||||
|
"Not connected",
|
||||||
|
"The Proton Drive mount service isn't running.",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locations_status(
|
||||||
|
ui,
|
||||||
|
"folder-remote-symbolic",
|
||||||
|
"Connecting…",
|
||||||
|
"Waiting for the Proton Drive mount service to come up.",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || {
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
|
load_locations(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
||||||
|
for row in ui.locations.rows.borrow_mut().drain(..) {
|
||||||
|
ui.locations.group.remove(&row);
|
||||||
|
}
|
||||||
|
if items.is_empty() {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("No locations")
|
||||||
|
.subtitle("The mount service hasn't reported a mountpoint yet.")
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k4::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||||
|
ui.locations.group.add(&row);
|
||||||
|
*ui.locations.rows.borrow_mut() = vec![row.upcast()];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||||
|
for spec in items {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&spec.local_path)
|
||||||
|
.subtitle(location_subtitle(spec))
|
||||||
|
// One line each: a wrapped path would reflow the whole list every
|
||||||
|
// time a sync state changed under it.
|
||||||
|
.title_lines(1)
|
||||||
|
.subtitle_lines(1)
|
||||||
|
.build();
|
||||||
|
row.add_prefix(>k4::Image::from_icon_name(location_icon(&spec.kind)));
|
||||||
|
|
||||||
|
// A read-only location cannot be written through even where the files are
|
||||||
|
// visible; saying so on the row is cheaper than letting the user find out
|
||||||
|
// from a save dialog.
|
||||||
|
if spec.access == MountAccess::Ro {
|
||||||
|
let badge = gtk4::Label::new(Some("Read-only"));
|
||||||
|
badge.add_css_class("dim-label");
|
||||||
|
badge.add_css_class("caption");
|
||||||
|
badge.set_valign(gtk4::Align::Center);
|
||||||
|
row.add_suffix(&badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule as the Computers page: a first pass has no estimate to draw
|
||||||
|
// against, so the bar appears only once real counts exist.
|
||||||
|
if let Some(p) = &spec.progress
|
||||||
|
&& p.total > 0
|
||||||
|
{
|
||||||
|
let bar = gtk4::ProgressBar::builder()
|
||||||
|
.fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0))
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.width_request(120)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(&bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
match &spec.kind {
|
||||||
|
MountKind::MyFiles => add_my_files_controls(ui, &row),
|
||||||
|
MountKind::Device { sync_folder_id } => {
|
||||||
|
add_device_controls(ui, &row, spec, *sync_folder_id)
|
||||||
|
}
|
||||||
|
// A standalone shared mount has no local mode to switch and is not
|
||||||
|
// this device's to remove.
|
||||||
|
MountKind::Shared { .. } => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let open = gtk4::Button::builder()
|
||||||
|
.icon_name("folder-open-symbolic")
|
||||||
|
.tooltip_text("Open this folder")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
open.add_css_class("flat");
|
||||||
|
let path = spec.local_path.clone();
|
||||||
|
open.connect_clicked(move |_| open_path(&path));
|
||||||
|
row.add_suffix(&open);
|
||||||
|
|
||||||
|
ui.locations.group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
}
|
||||||
|
*ui.locations.rows.borrow_mut() = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||||
|
/// and offers a service restart, which is why it stays a single prompt shared
|
||||||
|
/// with the (now removed) Settings row.
|
||||||
|
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||||
|
let change = gtk4::Button::builder()
|
||||||
|
.label("Change")
|
||||||
|
.tooltip_text("Choose a different folder for the Proton Drive mount")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
change.add_css_class("flat");
|
||||||
|
let ui_mp = ui.clone();
|
||||||
|
change.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
||||||
|
row.add_suffix(&change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
||||||
|
/// controls, and the same handlers, the Computers page used to carry.
|
||||||
|
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||||
|
if spec.mode != MountMode::OnDemand {
|
||||||
|
let sync_now = gtk4::Button::builder()
|
||||||
|
.icon_name("view-refresh-symbolic")
|
||||||
|
.tooltip_text("Sync this folder now")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
sync_now.add_css_class("flat");
|
||||||
|
let ui_sync = ui.clone();
|
||||||
|
sync_now.connect_clicked(move |_| sync_folder_now(&ui_sync, id));
|
||||||
|
row.add_suffix(&sync_now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A queued switch paints as already flipped: the daemon accepted it and will
|
||||||
|
// act on it, so snapping back would read as "it didn't take". The state is
|
||||||
|
// set before the handler is wired so painting the current mode doesn't fire
|
||||||
|
// a spurious request.
|
||||||
|
let target = spec.pending_mode.unwrap_or(spec.mode);
|
||||||
|
let ondemand = gtk4::Switch::builder()
|
||||||
|
.tooltip_text(
|
||||||
|
"On-demand: free this disk by keeping the files in Proton Drive only, \
|
||||||
|
fetching each as you open it. Turn off to download them back and keep a \
|
||||||
|
full local copy.",
|
||||||
|
)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.active(target == MountMode::OnDemand)
|
||||||
|
.build();
|
||||||
|
let ui_mode = ui.clone();
|
||||||
|
ondemand.connect_state_set(move |_, on| {
|
||||||
|
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
row.add_suffix(&ondemand);
|
||||||
|
|
||||||
|
let remove = gtk4::Button::builder()
|
||||||
|
.icon_name("user-trash-symbolic")
|
||||||
|
.tooltip_text("Stop syncing this folder")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove.add_css_class("flat");
|
||||||
|
let ui_rm = ui.clone();
|
||||||
|
let path = spec.local_path.clone();
|
||||||
|
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
||||||
|
// yet has not moved the files anywhere.
|
||||||
|
let is_ondemand = spec.mode == MountMode::OnDemand;
|
||||||
|
remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, is_ondemand));
|
||||||
|
row.add_suffix(&remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon for an immediate pass over one folder.
|
||||||
|
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Syncing folder…"),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't sync", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(&ui, "Couldn't sync", "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
MountKind::MyFiles => "folder-remote-symbolic",
|
||||||
|
MountKind::Device { .. } => "folder-symbolic",
|
||||||
|
MountKind::Shared { .. } => "system-users-symbolic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line describing what a location *is* and what it is doing.
|
||||||
|
///
|
||||||
|
/// Ordered by how likely it is to be what the user came to check: a queued mode
|
||||||
|
/// switch leads (they just asked for it), then the resting mode, then the sync
|
||||||
|
/// state, then — only when it is surprising — the fact that no session owns the
|
||||||
|
/// path. A mirror folder is a plain directory with no FUSE session, so "not
|
||||||
|
/// mounted" is its normal state and saying it would be noise.
|
||||||
|
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
match &spec.kind {
|
||||||
|
MountKind::MyFiles => parts.push("My files".to_string()),
|
||||||
|
MountKind::Shared { .. } => parts.push("Shared folder".to_string()),
|
||||||
|
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
||||||
|
(Some(MountMode::OnDemand), _) => parts.push("Going on-demand".to_string()),
|
||||||
|
(Some(MountMode::Mirror), _) => parts.push("Switching to synced".to_string()),
|
||||||
|
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
|
||||||
|
parts.push("On-demand".to_string())
|
||||||
|
}
|
||||||
|
(Some(MountMode::Unknown) | None, _) => parts.push("Synced".to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if matches!(spec.kind, MountKind::Device { .. }) {
|
||||||
|
parts.push(match &spec.progress {
|
||||||
|
Some(p) => sync_progress_label(p),
|
||||||
|
None => sync_state_label(&spec.state).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Only the locations that are supposed to have a session report its absence:
|
||||||
|
// a mirror folder never has one.
|
||||||
|
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
||||||
|
|| spec.mode == MountMode::OnDemand
|
||||||
|
|| spec.pending_mode == Some(MountMode::OnDemand);
|
||||||
|
if expects_session && !spec.mounted {
|
||||||
|
parts.push("not mounted".to_string());
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
|
||||||
|
MountSpec {
|
||||||
|
id: 1,
|
||||||
|
kind,
|
||||||
|
local_path: "/home/u/ProtonDrive".into(),
|
||||||
|
root_uid: "vol~link".into(),
|
||||||
|
root_share_id: "share".into(),
|
||||||
|
mode,
|
||||||
|
access: MountAccess::Rw,
|
||||||
|
state: "idle".into(),
|
||||||
|
last_sync: 0,
|
||||||
|
pending_mode: None,
|
||||||
|
mounted,
|
||||||
|
progress: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
||||||
|
// It is a plain local directory: no FUSE session is expected, so saying
|
||||||
|
// "not mounted" would describe every healthy mirror folder as broken.
|
||||||
|
let spec = spec(
|
||||||
|
MountKind::Device { sync_folder_id: 7 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_on_demand_folder_without_a_session_says_so() {
|
||||||
|
let spec = spec(
|
||||||
|
MountKind::Device { sync_folder_id: 7 },
|
||||||
|
MountMode::OnDemand,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
location_subtitle(&spec),
|
||||||
|
"On-demand · up to date · not mounted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_queued_switch_leads_the_subtitle() {
|
||||||
|
let mut spec = spec(
|
||||||
|
MountKind::Device { sync_folder_id: 7 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
spec.pending_mode = Some(MountMode::OnDemand);
|
||||||
|
assert!(location_subtitle(&spec).starts_with("Going on-demand"));
|
||||||
|
spec.mode = MountMode::OnDemand;
|
||||||
|
spec.pending_mode = Some(MountMode::Mirror);
|
||||||
|
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unrecognised persisted mode must not silently read as "Synced" *and*
|
||||||
|
/// must not lose the rest of the line.
|
||||||
|
#[test]
|
||||||
|
fn the_primary_mount_reports_only_its_own_facts() {
|
||||||
|
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
|
||||||
|
assert_eq!(location_subtitle(&spec), "My files");
|
||||||
|
spec.mounted = false;
|
||||||
|
assert_eq!(location_subtitle(&spec), "My files · not mounted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_state_reaches_the_subtitle() {
|
||||||
|
let mut spec = spec(
|
||||||
|
MountKind::Device { sync_folder_id: 2 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
spec.state = "conflict".into();
|
||||||
|
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
pub(crate) mod activity;
|
pub(crate) mod activity;
|
||||||
|
pub(crate) mod albums;
|
||||||
pub(crate) mod browser;
|
pub(crate) mod browser;
|
||||||
pub(crate) mod devices;
|
pub(crate) mod devices;
|
||||||
|
pub(crate) mod locations;
|
||||||
pub(crate) mod login;
|
pub(crate) mod login;
|
||||||
pub(crate) mod photo_viewer;
|
pub(crate) mod photo_viewer;
|
||||||
pub(crate) mod photos;
|
pub(crate) mod photos;
|
||||||
|
|||||||
@@ -8,24 +8,53 @@ pub(crate) struct GalleryState {
|
|||||||
/// The day sections rendered by the Photos ListView, rebuilt from
|
/// The day sections rendered by the Photos ListView, rebuilt from
|
||||||
/// [`Self::model`] by [`repaint_gallery`].
|
/// [`Self::model`] by [`repaint_gallery`].
|
||||||
pub(crate) groups: gio::ListStore,
|
pub(crate) groups: gio::ListStore,
|
||||||
/// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see
|
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
|
||||||
/// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are
|
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
|
||||||
/// justified to the content width around this height.
|
/// as the content width holds.
|
||||||
pub(crate) tile: Cell<i32>,
|
pub(crate) tile: Cell<i32>,
|
||||||
/// Swaps the Photos content area between the timeline and its status page.
|
/// Swaps the Photos content area between the timeline, its status page, and
|
||||||
|
/// the Albums grid.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
|
/// "Photos", or the album's name while one is open.
|
||||||
|
pub(crate) title: gtk4::Label,
|
||||||
/// "1,204 photos" under the page title.
|
/// "1,204 photos" under the page title.
|
||||||
pub(crate) subtitle: gtk4::Label,
|
pub(crate) subtitle: gtk4::Label,
|
||||||
|
/// The album grid, and the stack that swaps it for its own status page.
|
||||||
|
pub(crate) albums: gtk4::FlowBox,
|
||||||
|
pub(crate) albums_stack: gtk4::Stack,
|
||||||
|
pub(crate) albums_status: adw::StatusPage,
|
||||||
|
/// The Photos/Albums switcher, and the box holding it — hidden while an
|
||||||
|
/// album is open, where the back button leads instead.
|
||||||
|
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||||
|
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||||
|
pub(crate) view_switch: gtk4::Box,
|
||||||
|
/// True while the album listing is in flight, so a re-entry into the Albums
|
||||||
|
/// view can't stack requests.
|
||||||
|
pub(crate) albums_loading: Cell<bool>,
|
||||||
|
/// The album currently open, or `None` when the timeline is showing. Set by
|
||||||
|
/// [`open_album`]; read by [`load_gallery`], which pages that album instead
|
||||||
|
/// of the timeline while it is set.
|
||||||
|
pub(crate) album: RefCell<Option<AlbumInfo>>,
|
||||||
|
/// Leaves an open album for the grid it was opened from. Visible only while
|
||||||
|
/// an album is open.
|
||||||
|
pub(crate) back: gtk4::Button,
|
||||||
|
/// The kind toggles and the date jump, hidden while an album is open — an
|
||||||
|
/// album page is served whole, not filtered.
|
||||||
|
pub(crate) filters: gtk4::Box,
|
||||||
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
/// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None`
|
||||||
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
/// for All. Read by [`load_gallery`] and set by the filter toggles.
|
||||||
pub(crate) kind: Cell<Option<PhotoKind>>,
|
pub(crate) kind: Cell<Option<PhotoKind>>,
|
||||||
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
/// The filter toggles, index-aligned with [`kind_for_tab`], kept so their
|
||||||
/// labels can carry live per-kind counts.
|
/// labels can carry live per-kind counts.
|
||||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||||
|
/// Whole-timeline `(photos, videos, raw)` counts from the last page that
|
||||||
|
/// carried them, so the subtitle can say how big the library *is* rather
|
||||||
|
/// than how much of it has been paged in.
|
||||||
|
pub(crate) counts: Cell<Option<(usize, usize, usize)>>,
|
||||||
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
|
/// The date-jump dropdown ("All dates" then a month per timeline entry), and
|
||||||
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
|
/// the `[from, to)` window each of its rows selects (index-aligned; `None` is
|
||||||
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
/// "All dates"). Selecting a row loads that month via [`load_gallery`].
|
||||||
@@ -40,21 +69,14 @@ pub(crate) struct GalleryState {
|
|||||||
/// True while a timeline page is in flight, so the scroll-to-the-end paging
|
/// True while a timeline page is in flight, so the scroll-to-the-end paging
|
||||||
/// can't fire a second request for the page already coming.
|
/// can't fire a second request for the page already coming.
|
||||||
pub(crate) loading: Cell<bool>,
|
pub(crate) loading: Cell<bool>,
|
||||||
/// Content width the sections are currently justified to. Updated when the
|
/// Content width the grid is currently laid out to. Updated when the
|
||||||
/// ListView is resized, which re-justifies the visible sections.
|
/// ListView is resized, which re-flows the visible sections.
|
||||||
pub(crate) width: Cell<i32>,
|
pub(crate) width: Cell<i32>,
|
||||||
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
/// Decoded thumbnails by photo uid, with the insertion order that evicts the
|
||||||
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
/// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore
|
||||||
/// repaints from memory instead of re-decoding from disk.
|
/// repaints from memory instead of re-decoding from disk.
|
||||||
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
pub(crate) photo_tex: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||||
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
pub(crate) photo_tex_order: RefCell<VecDeque<String>>,
|
||||||
/// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and
|
|
||||||
/// persisted to disk, so a relaunch justifies its rows correctly on the first
|
|
||||||
/// frame instead of reflowing as thumbnails arrive.
|
|
||||||
pub(crate) photo_ratio: RefCell<HashMap<String, f64>>,
|
|
||||||
/// Ratios learned since the last save, so the ratio file is only rewritten
|
|
||||||
/// when it actually changed.
|
|
||||||
pub(crate) photo_ratio_dirty: Cell<bool>,
|
|
||||||
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
/// Photos the daemon reported as having no thumbnail at all, so a tile that
|
||||||
/// can never be filled isn't requested again on every scroll past it.
|
/// can never be filled isn't requested again on every scroll past it.
|
||||||
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
pub(crate) photo_nothumb: RefCell<HashSet<String>>,
|
||||||
@@ -75,46 +97,33 @@ pub(crate) struct GalleryState {
|
|||||||
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
pub(crate) decode_queue: RefCell<VecDeque<(String, String)>>,
|
||||||
pub(crate) decode_idle: Cell<bool>,
|
pub(crate) decode_idle: Cell<bool>,
|
||||||
/// Pending debounce timers for the thumbnail queue flush and the section
|
/// Pending debounce timers for the thumbnail queue flush and the section
|
||||||
/// re-justify, replaced on each new trigger so only the last one fires.
|
/// re-flow, replaced on each new trigger so only the last one fires.
|
||||||
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
|
||||||
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
|
||||||
/// The day sections currently realised by the ListView, by their index in
|
/// The day sections currently realised by the ListView, by their index in
|
||||||
/// [`Self::groups`]. A learned ratio or a resize re-justifies these
|
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
|
||||||
/// in place — rebuilding the ListStore instead would reset the scroll
|
/// rebuilding the ListStore instead would reset the scroll position out from
|
||||||
/// position out from under the user.
|
/// under the user.
|
||||||
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
/// How many photos to pull per [`Request::PhotosTimeline`] page.
|
||||||
pub(crate) const PHOTOS_PAGE: usize = 60;
|
pub(crate) const PHOTOS_PAGE: usize = 60;
|
||||||
|
|
||||||
/// Gallery row height in px: the zoom range, its default, and the step one
|
/// Gallery tile edge in px: the zoom range, its default, and the step one
|
||||||
/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full
|
/// Ctrl+scroll notch (or Ctrl+±) moves it by. The grid divides the content width
|
||||||
/// content width, so this is the *target* height a row lands near, not an exact
|
/// evenly, so this is the *target* a tile lands near rather than its exact size
|
||||||
/// tile size (see [`justify_rows`]).
|
/// (see [`plan_grid`]).
|
||||||
pub(crate) const TILE_MIN: i32 = 90;
|
pub(crate) const TILE_MIN: i32 = 90;
|
||||||
|
|
||||||
pub(crate) const TILE_MAX: i32 = 340;
|
pub(crate) const TILE_MAX: i32 = 340;
|
||||||
|
|
||||||
pub(crate) const TILE_DEFAULT: i32 = 220;
|
pub(crate) const TILE_DEFAULT: i32 = 180;
|
||||||
|
|
||||||
pub(crate) const TILE_STEP: i32 = 30;
|
pub(crate) const TILE_STEP: i32 = 30;
|
||||||
|
|
||||||
/// Gap between tiles, horizontally and vertically.
|
/// Gap between tiles, horizontally and vertically.
|
||||||
pub(crate) const TILE_GAP: i32 = 6;
|
pub(crate) const TILE_GAP: i32 = 8;
|
||||||
|
|
||||||
/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded
|
|
||||||
/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the
|
|
||||||
/// commonest camera/phone ratio; once the thumbnail lands the real ratio
|
|
||||||
/// replaces it and the section re-justifies in place.
|
|
||||||
pub(crate) const RATIO_UNKNOWN: f64 = 1.5;
|
|
||||||
|
|
||||||
/// Aspect ratios are clamped to this range before a row is packed: one absurd
|
|
||||||
/// panorama, or a sliver of a portrait, must not be able to squash the row it
|
|
||||||
/// lands in down to nothing. The tile still shows the whole image.
|
|
||||||
pub(crate) const RATIO_MIN: f64 = 0.4;
|
|
||||||
|
|
||||||
pub(crate) const RATIO_MAX: f64 = 3.5;
|
|
||||||
|
|
||||||
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for.
|
||||||
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
/// Small, so the first tiles on screen fill in quickly rather than the whole
|
||||||
@@ -134,7 +143,7 @@ pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4);
|
|||||||
/// this caps the gallery's footprint while covering several screens of scroll.
|
/// this caps the gallery's footprint while covering several screens of scroll.
|
||||||
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
pub(crate) const TEXTURE_CACHE_MAX: usize = 600;
|
||||||
|
|
||||||
/// Pause after a resize/zoom before the visible sections are re-justified.
|
/// Pause after a resize/zoom before the visible sections are re-flowed.
|
||||||
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||||
|
|
||||||
/// One day-section of the photos timeline: a heading plus the photos captured
|
/// One day-section of the photos timeline: a heading plus the photos captured
|
||||||
@@ -153,9 +162,11 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) model: gio::ListStore,
|
pub(crate) model: gio::ListStore,
|
||||||
/// Day sections rendered by the ListView, derived from `model`.
|
/// Day sections rendered by the ListView, derived from `model`.
|
||||||
pub(crate) groups: gio::ListStore,
|
pub(crate) groups: gio::ListStore,
|
||||||
/// Swaps between the timeline and the empty/loading/error status page.
|
/// Swaps between the timeline, the empty/loading/error status page, and the
|
||||||
|
/// Albums grid.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
|
pub(crate) title: gtk4::Label,
|
||||||
pub(crate) subtitle: gtk4::Label,
|
pub(crate) subtitle: gtk4::Label,
|
||||||
pub(crate) more: gtk4::Button,
|
pub(crate) more: gtk4::Button,
|
||||||
pub(crate) list: gtk4::ListView,
|
pub(crate) list: gtk4::ListView,
|
||||||
@@ -163,6 +174,17 @@ pub(crate) struct GalleryWidgets {
|
|||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) upload: gtk4::Button,
|
pub(crate) upload: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
|
/// The Albums grid, its own status page and the stack between them, plus the
|
||||||
|
/// Photos/Albums switcher and the back button out of an album.
|
||||||
|
pub(crate) albums: gtk4::FlowBox,
|
||||||
|
pub(crate) albums_stack: gtk4::Stack,
|
||||||
|
pub(crate) albums_status: adw::StatusPage,
|
||||||
|
pub(crate) photos_btn: gtk4::ToggleButton,
|
||||||
|
pub(crate) albums_btn: gtk4::ToggleButton,
|
||||||
|
pub(crate) view_switch: gtk4::Box,
|
||||||
|
pub(crate) back: gtk4::Button,
|
||||||
|
/// The kind toggles and date jump, as one box so an album view can hide them.
|
||||||
|
pub(crate) filters: gtk4::Box,
|
||||||
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
/// The All / Photos / Videos / Raw filter toggles, in that order (index maps
|
||||||
/// to [`kind_for_tab`]).
|
/// to [`kind_for_tab`]).
|
||||||
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
pub(crate) tabs: [gtk4::ToggleButton; 4],
|
||||||
@@ -217,7 +239,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
more.add_css_class("pill");
|
more.add_css_class("pill");
|
||||||
more.set_visible(false);
|
more.set_visible(false);
|
||||||
|
|
||||||
// Horizontal scrolling is never wanted: rows are justified to the viewport
|
// Horizontal scrolling is never wanted: the grid is sized to the viewport
|
||||||
// width, and a stray hscrollbar would fight the layout.
|
// width, and a stray hscrollbar would fight the layout.
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
@@ -226,7 +248,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let title_label = gtk4::Label::builder()
|
let title_label = gtk4::Label::builder()
|
||||||
.label("Photos")
|
.label("Gallery")
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
title_label.add_css_class("title-2");
|
title_label.add_css_class("title-2");
|
||||||
@@ -243,6 +265,17 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
titles.append(&title_label);
|
titles.append(&title_label);
|
||||||
titles.append(&subtitle);
|
titles.append(&subtitle);
|
||||||
|
|
||||||
|
// Leaves an open album for the grid it came from. Only an open album shows
|
||||||
|
// it — the Albums toggle is what leaves the grid itself.
|
||||||
|
let back = gtk4::Button::builder()
|
||||||
|
.icon_name("go-previous-symbolic")
|
||||||
|
.tooltip_text("Back to albums")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
back.add_css_class("flat");
|
||||||
|
back.add_css_class("circular");
|
||||||
|
|
||||||
let upload = gtk4::Button::builder()
|
let upload = gtk4::Button::builder()
|
||||||
.label("Upload")
|
.label("Upload")
|
||||||
.icon_name("list-add-symbolic")
|
.icon_name("list-add-symbolic")
|
||||||
@@ -253,6 +286,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
header_box.append(&back);
|
||||||
header_box.append(&titles);
|
header_box.append(&titles);
|
||||||
header_box.append(&refresh);
|
header_box.append(&refresh);
|
||||||
header_box.append(&upload);
|
header_box.append(&upload);
|
||||||
@@ -287,23 +321,73 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
dates.add_css_class("pill");
|
dates.add_css_class("pill");
|
||||||
dates.set_tooltip_text(Some("Jump to a month"));
|
dates.set_tooltip_text(Some("Jump to a month"));
|
||||||
|
|
||||||
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
// The kind toggles and the date jump travel together: they filter the
|
||||||
filter_bar.append(&tab_group);
|
// timeline, and neither applies to the album grid or to an open album.
|
||||||
|
let filters = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
filters.set_hexpand(true);
|
||||||
|
filters.append(&tab_group);
|
||||||
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
spacer.set_hexpand(true);
|
spacer.set_hexpand(true);
|
||||||
filter_bar.append(&spacer);
|
filters.append(&spacer);
|
||||||
filter_bar.append(&dates);
|
filters.append(&dates);
|
||||||
|
|
||||||
|
// The page's two views, as one segmented control under the title: the
|
||||||
|
// timeline, and the albums. This is navigation, not a filter — which is why
|
||||||
|
// it sits above the filter row rather than beside the kind toggles.
|
||||||
|
let photos_btn = gtk4::ToggleButton::builder()
|
||||||
|
.label("Photos")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
let albums_btn = gtk4::ToggleButton::builder().label("Albums").build();
|
||||||
|
albums_btn.set_group(Some(&photos_btn));
|
||||||
|
let view_switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
view_switch.add_css_class("linked");
|
||||||
|
view_switch.add_css_class("view-switch");
|
||||||
|
view_switch.set_halign(gtk4::Align::Start);
|
||||||
|
for btn in [&photos_btn, &albums_btn] {
|
||||||
|
btn.add_css_class("pill");
|
||||||
|
view_switch.append(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
filter_bar.append(&filters);
|
||||||
|
|
||||||
// The timeline (plus its pager) or the status page, never both.
|
// The timeline (plus its pager) or the status page, never both.
|
||||||
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
timeline.append(&scroll);
|
timeline.append(&scroll);
|
||||||
timeline.append(&more);
|
timeline.append(&more);
|
||||||
|
|
||||||
|
// The album grid: cover-first cards that flow to the width they are given.
|
||||||
|
let albums = gtk4::FlowBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.homogeneous(true)
|
||||||
|
.row_spacing(TILE_GAP as u32 * 2)
|
||||||
|
.column_spacing(TILE_GAP as u32 * 2)
|
||||||
|
.min_children_per_line(2)
|
||||||
|
.max_children_per_line(8)
|
||||||
|
.valign(gtk4::Align::Start)
|
||||||
|
.build();
|
||||||
|
let albums_scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&albums)
|
||||||
|
.build();
|
||||||
|
let albums_status = adw::StatusPage::builder()
|
||||||
|
.icon_name("view-grid-symbolic")
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
albums_status.add_css_class("compact");
|
||||||
|
let albums_stack = gtk4::Stack::new();
|
||||||
|
albums_stack.set_vexpand(true);
|
||||||
|
albums_stack.add_named(&albums_scroll, Some("grid"));
|
||||||
|
albums_stack.add_named(&albums_status, Some("status"));
|
||||||
|
|
||||||
let content = gtk4::Stack::new();
|
let content = gtk4::Stack::new();
|
||||||
content.set_vexpand(true);
|
content.set_vexpand(true);
|
||||||
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
|
||||||
content.add_named(&timeline, Some("timeline"));
|
content.add_named(&timeline, Some("timeline"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
content.add_named(&albums_stack, Some("albums"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(12);
|
||||||
@@ -311,6 +395,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
inner.set_margin_start(12);
|
inner.set_margin_start(12);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(12);
|
||||||
inner.append(&header_box);
|
inner.append(&header_box);
|
||||||
|
inner.append(&view_switch);
|
||||||
inner.append(&filter_bar);
|
inner.append(&filter_bar);
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
@@ -321,6 +406,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
groups,
|
groups,
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
|
title: title_label,
|
||||||
subtitle,
|
subtitle,
|
||||||
more,
|
more,
|
||||||
list,
|
list,
|
||||||
@@ -330,6 +416,14 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
refresh,
|
refresh,
|
||||||
tabs,
|
tabs,
|
||||||
dates,
|
dates,
|
||||||
|
albums,
|
||||||
|
albums_stack,
|
||||||
|
albums_status,
|
||||||
|
photos_btn,
|
||||||
|
albums_btn,
|
||||||
|
view_switch,
|
||||||
|
back,
|
||||||
|
filters,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -416,16 +510,31 @@ pub(crate) fn refresh_photo_months(ui: &Rc<Ui>) {
|
|||||||
/// behind it is disabled — you can't filter to an empty set — but the currently
|
/// behind it is disabled — you can't filter to an empty set — but the currently
|
||||||
/// selected one stays clickable so you can always switch back off it.
|
/// selected one stays clickable so you can always switch back off it.
|
||||||
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
pub(crate) fn update_gallery_tabs(ui: &Rc<Ui>, counts: (usize, usize, usize)) {
|
||||||
|
ui.gallery.counts.set(Some(counts));
|
||||||
let (photos, videos, raw) = counts;
|
let (photos, videos, raw) = counts;
|
||||||
let totals = [photos + videos + raw, photos, videos, raw];
|
let totals = [photos + videos + raw, photos, videos, raw];
|
||||||
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
for (index, tab) in ui.gallery.tabs.iter().enumerate() {
|
||||||
let name = ["All", "Photos", "Videos", "Raw"][index];
|
let name = ["All", "Photos", "Videos", "Raw"][index];
|
||||||
let n = totals[index];
|
let n = totals[index];
|
||||||
tab.set_label(&format!("{name} {n}"));
|
tab.set_label(&format!("{name} {}", thousands(n)));
|
||||||
tab.set_sensitive(n > 0 || tab.is_active());
|
tab.set_sensitive(n > 0 || tab.is_active());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `1422` as `1,422`. Six-figure libraries are ordinary, and an unseparated run
|
||||||
|
/// of digits in a tab label is unreadable at a glance.
|
||||||
|
pub(crate) fn thousands(n: usize) -> String {
|
||||||
|
let digits = n.to_string();
|
||||||
|
let mut out = String::with_capacity(digits.len() + digits.len() / 3);
|
||||||
|
for (i, c) in digits.chars().enumerate() {
|
||||||
|
if i > 0 && (digits.len() - i).is_multiple_of(3) {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
/// Wire the gallery: install the section factory, the zoom gestures, the pager
|
||||||
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
/// and the upload button. Activating a thumbnail downloads the photo and opens it
|
||||||
/// in the in-app lightbox.
|
/// in the in-app lightbox.
|
||||||
@@ -485,8 +594,8 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
});
|
});
|
||||||
list.set_factory(Some(&factory));
|
list.set_factory(Some(&factory));
|
||||||
|
|
||||||
// Rows are justified to the content width, so a resize re-justifies whatever
|
// The grid divides the content width, so a resize re-flows whatever is on
|
||||||
// is on screen (offscreen sections pick the new width up when they bind).
|
// screen (offscreen sections pick the new width up when they bind).
|
||||||
let ui_width = ui.clone();
|
let ui_width = ui.clone();
|
||||||
list.connect_notify_local(Some("width"), move |list, _| {
|
list.connect_notify_local(Some("width"), move |list, _| {
|
||||||
let width = list.width();
|
let width = list.width();
|
||||||
@@ -673,114 +782,55 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: >k4::ListView, scroll: >k4::Sc
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tile in a justified row: the photo, and the pixel size it was justified to.
|
/// One tile of the grid: the photo, and the square edge it was sized to.
|
||||||
pub(crate) struct Tile {
|
pub(crate) struct Tile {
|
||||||
pub(crate) photo: PhotoItem,
|
pub(crate) photo: PhotoItem,
|
||||||
pub(crate) width: i32,
|
pub(crate) edge: i32,
|
||||||
pub(crate) height: i32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Break one day's photos into rows that each fill `width` exactly, at heights
|
/// Break one day's photos into rows of equal square tiles that span `width` —
|
||||||
/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and
|
/// the layout a phone gallery uses, and the reason a day holding two photos
|
||||||
/// the reason nothing here has to be cropped: each tile keeps its own aspect
|
/// looks like every other day rather than like a mistake.
|
||||||
/// ratio, and it is the row *height* that flexes to make the widths add up.
|
|
||||||
///
|
///
|
||||||
/// Greedy, one pass: keep adding photos to the row: the more photos share it, the
|
/// Each tile is centre-cropped to its square (the full frame is one click away
|
||||||
/// shorter it has to be to fit. The moment that height drops to the target, the
|
/// in the lightbox), so nothing here depends on knowing a photo's aspect ratio
|
||||||
/// row is as full as it should be and is emitted. The trailing row keeps the
|
/// and a row never has to be re-flowed when a thumbnail finally lands.
|
||||||
/// target height instead of being stretched, so a day with three photos doesn't
|
pub(crate) fn grid_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
||||||
/// blow them up to fill a screen-wide row.
|
let (columns, edge) = plan_grid(ui.gallery.tile.get(), width);
|
||||||
///
|
photos
|
||||||
/// Photos whose thumbnail hasn't been decoded yet are laid out at
|
.chunks(columns)
|
||||||
/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands
|
|
||||||
/// and the section is re-justified in place.
|
|
||||||
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
|
|
||||||
let ratios: Vec<f64> = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect();
|
|
||||||
let target = f64::from(ui.gallery.tile.get());
|
|
||||||
let plan = plan_rows(&ratios, target, f64::from(width));
|
|
||||||
|
|
||||||
let mut photos = photos.iter();
|
|
||||||
plan.into_iter()
|
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
row.into_iter()
|
row.iter()
|
||||||
.filter_map(|(width, height)| {
|
.map(|photo| Tile {
|
||||||
photos.next().map(|photo| Tile {
|
photo: photo.clone(),
|
||||||
photo: photo.clone(),
|
edge,
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios:
|
/// The grid math: how many columns fit in `width` at roughly `target` px per
|
||||||
/// takes each photo's ratio (w/h) in order and returns the pixel `(width,
|
/// tile, and the exact square edge that divides the width between them.
|
||||||
/// height)` of every tile, grouped into rows.
|
///
|
||||||
pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec<Vec<(i32, i32)>> {
|
/// The column count is what rounds — the edge then absorbs the remainder, so the
|
||||||
let avail = width.max(f64::from(TILE_MIN));
|
/// grid spans the full width at every window size instead of leaving a ragged
|
||||||
let gap = f64::from(TILE_GAP);
|
/// margin. Never fewer than one column, however narrow the window gets.
|
||||||
|
pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
|
||||||
// The height a row of `n` photos with `sum_ratio` total ratio must take for
|
let avail = width.max(TILE_MIN);
|
||||||
// its widths (plus gaps) to add up to exactly `avail`.
|
let target = target.clamp(TILE_MIN, TILE_MAX);
|
||||||
let row_height = |sum_ratio: f64, n: usize| {
|
// A row of n tiles occupies n*edge + (n-1)*gap, so n tiles of the target size
|
||||||
let gaps = gap * (n.saturating_sub(1)) as f64;
|
// fit while n*(target + gap) - gap <= avail.
|
||||||
(avail - gaps) / sum_ratio
|
let columns = ((avail + TILE_GAP) / (target + TILE_GAP)).max(1) as usize;
|
||||||
};
|
let gaps = TILE_GAP * (columns as i32 - 1);
|
||||||
|
let edge = ((avail - gaps) / columns as i32).max(1);
|
||||||
let mut rows: Vec<Vec<(i32, i32)>> = Vec::new();
|
(columns, edge)
|
||||||
let mut row: Vec<f64> = Vec::new();
|
|
||||||
let mut sum_ratio = 0.0;
|
|
||||||
|
|
||||||
for ratio in ratios {
|
|
||||||
let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX);
|
|
||||||
row.push(ratio);
|
|
||||||
sum_ratio += ratio;
|
|
||||||
|
|
||||||
let height = row_height(sum_ratio, row.len());
|
|
||||||
if height <= target {
|
|
||||||
rows.push(size_row(&row, height, avail, true));
|
|
||||||
row.clear();
|
|
||||||
sum_ratio = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !row.is_empty() {
|
|
||||||
let height = row_height(sum_ratio, row.len()).min(target);
|
|
||||||
rows.push(size_row(&row, height, avail, false));
|
|
||||||
}
|
|
||||||
rows
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Size one row's tiles at `height`. A `justified` row is nudged so its widths
|
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
|
||||||
/// plus gaps hit `avail` exactly — rounding each width independently leaves a
|
|
||||||
/// few px of ragged right edge, so the last tile absorbs the remainder.
|
|
||||||
pub(crate) fn size_row(
|
|
||||||
ratios: &[f64],
|
|
||||||
height: f64,
|
|
||||||
avail: f64,
|
|
||||||
justified: bool,
|
|
||||||
) -> Vec<(i32, i32)> {
|
|
||||||
let height = height.round().max(1.0);
|
|
||||||
let mut sizes: Vec<(i32, i32)> = ratios
|
|
||||||
.iter()
|
|
||||||
.map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if justified {
|
|
||||||
let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32;
|
|
||||||
let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum();
|
|
||||||
if let Some(last) = sizes.last_mut() {
|
|
||||||
last.0 = (avail as i32 - gaps - used).max(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sizes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (Re)build a bound day-section's tiles: justify this day's photos to the
|
|
||||||
/// current content width and hand each tile whatever thumbnail is already in
|
/// current content width and hand each tile whatever thumbnail is already in
|
||||||
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
|
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
|
||||||
/// heading — so a re-justify never touches the ListView's model or scroll.
|
/// heading — so a re-flow never touches the ListView's model or scroll.
|
||||||
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem]) {
|
||||||
let Some(heading) = section.first_child() else {
|
let Some(heading) = section.first_child() else {
|
||||||
return;
|
return;
|
||||||
@@ -791,8 +841,11 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
|||||||
|
|
||||||
let width = gallery_width(ui);
|
let width = gallery_width(ui);
|
||||||
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
|
||||||
for row in justify_rows(ui, photos, width) {
|
for row in grid_rows(ui, photos, width) {
|
||||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
|
||||||
|
// A short last row stays left-aligned at the same tile size rather than
|
||||||
|
// stretching: an even grid is the whole point of the square layout.
|
||||||
|
row_box.set_halign(gtk4::Align::Start);
|
||||||
for tile in row {
|
for tile in row {
|
||||||
row_box.append(&photo_tile(ui, tile));
|
row_box.append(&photo_tile(ui, tile));
|
||||||
}
|
}
|
||||||
@@ -802,8 +855,8 @@ pub(crate) fn fill_section(ui: &Rc<Ui>, section: >k4::Box, photos: &[PhotoItem
|
|||||||
schedule_thumbs(ui);
|
schedule_thumbs(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The width justified rows are laid out to: the ListView's own width, less a
|
/// The width the grid is laid out to: the ListView's own width, less a couple of
|
||||||
/// couple of px so a rounding error can't push a row into a horizontal overflow.
|
/// px so a rounding error can't push a row into a horizontal overflow.
|
||||||
/// Falls back to a sane guess before the first allocation.
|
/// Falls back to a sane guess before the first allocation.
|
||||||
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
||||||
match ui.gallery.width.get() {
|
match ui.gallery.width.get() {
|
||||||
@@ -822,12 +875,17 @@ pub(crate) fn gallery_width(ui: &Rc<Ui>) -> i32 {
|
|||||||
/// one keeps an image glyph instead of an empty rectangle.
|
/// one keeps an image glyph instead of an empty rectangle.
|
||||||
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
||||||
let picture = gtk4::Picture::builder()
|
let picture = gtk4::Picture::builder()
|
||||||
// The tile is exactly the thumbnail's own aspect ratio, so Cover scales
|
// Cover fills the square and crops the overflow, so a portrait photo
|
||||||
// it and crops nothing; it only bites during the brief window where the
|
// sits flush in its tile instead of floating in letterbox bars. The
|
||||||
// ratio is still a guess, and the re-justify then fixes the tile.
|
// expands are what make the picture take the whole overlay: without
|
||||||
|
// them it is allocated its natural size and the crop never happens.
|
||||||
.content_fit(gtk4::ContentFit::Cover)
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
.can_shrink(true)
|
.can_shrink(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
// Zoomed on hover by the stylesheet; the tile clips the overflow.
|
||||||
|
picture.add_css_class("photo-thumb");
|
||||||
|
|
||||||
let placeholder = gtk4::Image::builder()
|
let placeholder = gtk4::Image::builder()
|
||||||
.icon_name("image-x-generic-symbolic")
|
.icon_name("image-x-generic-symbolic")
|
||||||
@@ -871,8 +929,8 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
|
|||||||
|
|
||||||
let button = gtk4::Button::builder()
|
let button = gtk4::Button::builder()
|
||||||
.child(&overlay)
|
.child(&overlay)
|
||||||
.width_request(tile.width)
|
.width_request(tile.edge)
|
||||||
.height_request(tile.height)
|
.height_request(tile.edge)
|
||||||
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
.tooltip_text(format_capture_time(tile.photo.capture_time))
|
||||||
.build();
|
.build();
|
||||||
button.add_css_class("photo-tile");
|
button.add_css_class("photo-tile");
|
||||||
@@ -1052,7 +1110,6 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
let mut queue = ui.gallery.decode_queue.borrow_mut();
|
||||||
(0..4).filter_map(|_| queue.pop_front()).collect()
|
(0..4).filter_map(|_| queue.pop_front()).collect()
|
||||||
};
|
};
|
||||||
let mut relayout = false;
|
|
||||||
for (uid, path) in batch {
|
for (uid, path) in batch {
|
||||||
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
let texture = match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
Ok(texture) => texture,
|
Ok(texture) => texture,
|
||||||
@@ -1062,28 +1119,23 @@ pub(crate) fn schedule_decode(ui: &Rc<Ui>) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// A ratio we hadn't seen means the tile was sized against a guess.
|
ui.store_texture(&uid, texture.clone());
|
||||||
relayout |= ui.store_texture(&uid, texture.clone());
|
|
||||||
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) {
|
||||||
picture.set_paintable(Some(&texture));
|
picture.set_paintable(Some(&texture));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if relayout {
|
|
||||||
schedule_relayout(&ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.gallery.decode_queue.borrow().is_empty() {
|
if ui.gallery.decode_queue.borrow().is_empty() {
|
||||||
ui.gallery.decode_idle.set(false);
|
ui.gallery.decode_idle.set(false);
|
||||||
ui.save_ratios();
|
|
||||||
return glib::ControlFlow::Break;
|
return glib::ControlFlow::Break;
|
||||||
}
|
}
|
||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-justify the sections on screen shortly. Debounced, because the triggers
|
/// Re-flow the sections on screen shortly. Debounced, because the triggers (a
|
||||||
/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in
|
/// window resize, a zoom step) arrive in floods and only the final state
|
||||||
/// floods and only the final state matters.
|
/// matters.
|
||||||
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
||||||
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() {
|
||||||
id.remove();
|
id.remove();
|
||||||
@@ -1097,9 +1149,8 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the tiles of the day sections currently on screen, at the current
|
/// Rebuild the tiles of the day sections currently on screen, at the current
|
||||||
/// width, zoom and set of known aspect ratios. Sections that are *not* realised
|
/// width and zoom. Sections that are *not* realised need no work: they lay
|
||||||
/// need no work: they justify themselves against the current state when the
|
/// themselves out against the current state when the ListView binds them.
|
||||||
/// ListView binds them.
|
|
||||||
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
||||||
let bound: Vec<(u32, gtk4::Box)> = ui
|
let bound: Vec<(u32, gtk4::Box)> = ui
|
||||||
.gallery
|
.gallery
|
||||||
@@ -1120,12 +1171,12 @@ pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step the row height by `delta` px and re-justify, clamped to the zoom range.
|
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
|
||||||
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
pub(crate) fn zoom_gallery(ui: &Rc<Ui>, delta: i32) {
|
||||||
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
set_gallery_tile(ui, ui.gallery.tile.get() + delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the row height (clamped) and re-justify the visible sections at it.
|
/// Set the tile size (clamped) and re-flow the visible sections at it.
|
||||||
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
|
||||||
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
let tile = tile.clamp(TILE_MIN, TILE_MAX);
|
||||||
if tile == ui.gallery.tile.get() {
|
if tile == ui.gallery.tile.get() {
|
||||||
@@ -1175,17 +1226,35 @@ pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
|
|||||||
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = ui.gallery.model.n_items();
|
let loaded = ui.gallery.model.n_items() as usize;
|
||||||
ui.gallery.subtitle.set_visible(count > 0);
|
ui.gallery.subtitle.set_visible(loaded > 0);
|
||||||
|
// An album counts what the server says it holds, not how much of it has been
|
||||||
|
// paged in — the subtitle would otherwise climb as the user scrolls.
|
||||||
|
if ui.gallery.album.borrow().is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
// The noun tracks the active filter, so a Videos tab doesn't count "photos".
|
||||||
let (one, many) = match ui.gallery.kind.get() {
|
let kind = ui.gallery.kind.get();
|
||||||
|
let (one, many) = match kind {
|
||||||
Some(PhotoKind::Video) => ("video", "videos"),
|
Some(PhotoKind::Video) => ("video", "videos"),
|
||||||
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
Some(PhotoKind::Raw) => ("raw photo", "raw photos"),
|
||||||
_ => ("photo", "photos"),
|
_ => ("photo", "photos"),
|
||||||
};
|
};
|
||||||
ui.gallery.subtitle.set_label(&match count {
|
// The whole library for this filter, not the page count — the subtitle sits
|
||||||
|
// next to tabs carrying the same totals, and the two disagreeing reads as a
|
||||||
|
// bug. A date jump is the exception: there the window is the subject.
|
||||||
|
let total = match (ui.gallery.range.get(), ui.gallery.counts.get()) {
|
||||||
|
(None, Some((photos, videos, raw))) => match kind {
|
||||||
|
Some(PhotoKind::Photo) => photos,
|
||||||
|
Some(PhotoKind::Video) => videos,
|
||||||
|
Some(PhotoKind::Raw) => raw,
|
||||||
|
None => photos + videos + raw,
|
||||||
|
},
|
||||||
|
_ => loaded,
|
||||||
|
};
|
||||||
|
ui.gallery.subtitle.set_label(&match total {
|
||||||
1 => format!("1 {one}"),
|
1 => format!("1 {one}"),
|
||||||
n => format!("{n} {many}"),
|
n => format!("{} {many}", thousands(n)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1341,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
if ui.gallery.loading.get() {
|
if ui.gallery.loading.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// An open album pages itself instead of the timeline; everything downstream —
|
||||||
|
// the model, the sections, the thumbnails, the lightbox — is the same.
|
||||||
|
let album = ui.gallery.album.borrow().as_ref().map(|a| a.uid.clone());
|
||||||
if !append {
|
if !append {
|
||||||
// Fresh load: clear the timeline and show Loading until the first page lands.
|
// Fresh load: clear the timeline and show Loading until the first page lands.
|
||||||
ui.gallery.model.remove_all();
|
ui.gallery.model.remove_all();
|
||||||
@@ -1284,8 +1356,9 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
);
|
);
|
||||||
// Rebuild the date jump for the current kind, but only for a full-span
|
// Rebuild the date jump for the current kind, but only for a full-span
|
||||||
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
// load — a jump *to* a month sets a range and reloads, and refreshing the
|
||||||
// dropdown then would fight the selection the user just made.
|
// dropdown then would fight the selection the user just made. An album
|
||||||
if ui.gallery.range.get().is_none() {
|
// has no date jump at all.
|
||||||
|
if album.is_none() && ui.gallery.range.get().is_none() {
|
||||||
refresh_photo_months(ui);
|
refresh_photo_months(ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1294,15 +1367,20 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
ui.gallery.more.set_sensitive(false);
|
ui.gallery.more.set_sensitive(false);
|
||||||
|
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
let rx = spawn_request(
|
let request = match album {
|
||||||
ui.dirs.control_socket(),
|
Some(uid) => Request::AlbumPhotos {
|
||||||
Request::PhotosTimeline {
|
uid,
|
||||||
|
offset,
|
||||||
|
limit: PHOTOS_PAGE,
|
||||||
|
},
|
||||||
|
None => Request::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit: PHOTOS_PAGE,
|
limit: PHOTOS_PAGE,
|
||||||
kind: ui.gallery.kind.get(),
|
kind: ui.gallery.kind.get(),
|
||||||
range: ui.gallery.range.get(),
|
range: ui.gallery.range.get(),
|
||||||
},
|
},
|
||||||
);
|
};
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
@@ -1329,20 +1407,13 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
if let Some(counts) = counts {
|
if let Some(counts) = counts {
|
||||||
update_gallery_tabs(&ui, counts);
|
update_gallery_tabs(&ui, counts);
|
||||||
}
|
}
|
||||||
// Take the daemon's word on ratios and thumbnail verdicts before
|
// Take the daemon's word on which photos can never have a
|
||||||
// anything is laid out: it persists both, so the very first frame
|
// thumbnail, so their tiles show a placeholder from the first
|
||||||
// justifies its rows correctly instead of guessing RATIO_UNKNOWN
|
// frame instead of queueing a request that can only fail.
|
||||||
// and reflowing as the images land.
|
|
||||||
{
|
{
|
||||||
let mut ratios = ui.gallery.photo_ratio.borrow_mut();
|
|
||||||
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
let mut nothumb = ui.gallery.photo_nothumb.borrow_mut();
|
||||||
for item in &items {
|
for item in items.iter().filter(|item| item.no_thumb) {
|
||||||
if let Some(ratio) = item.ratio {
|
nothumb.insert(item.uid.clone());
|
||||||
ratios.insert(item.uid.clone(), ratio);
|
|
||||||
}
|
|
||||||
if item.no_thumb {
|
|
||||||
nothumb.insert(item.uid.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for item in &items {
|
for item in &items {
|
||||||
@@ -1350,13 +1421,15 @@ pub(crate) fn load_gallery(ui: &Rc<Ui>, append: bool) {
|
|||||||
}
|
}
|
||||||
repaint_gallery(&ui);
|
repaint_gallery(&ui);
|
||||||
if ui.gallery.model.n_items() == 0 {
|
if ui.gallery.model.n_items() == 0 {
|
||||||
gallery_status(
|
let (title, description) = if ui.gallery.album.borrow().is_some() {
|
||||||
&ui,
|
("Empty album", "This album has no photos in it.")
|
||||||
"image-x-generic-symbolic",
|
} else {
|
||||||
"No photos yet",
|
(
|
||||||
"Photos you upload to Proton Drive appear here.",
|
"No photos yet",
|
||||||
false,
|
"Photos you upload to Proton Drive appear here.",
|
||||||
);
|
)
|
||||||
|
};
|
||||||
|
gallery_status(&ui, "image-x-generic-symbolic", title, description, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.content.set_visible_child_name("timeline");
|
ui.gallery.content.set_visible_child_name("timeline");
|
||||||
|
|||||||
@@ -298,8 +298,15 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
.title(&entry.name)
|
.title(&entry.name)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
if !entry.is_dir {
|
let subtitle = shared_row_subtitle(entry);
|
||||||
row.set_subtitle(&human_bytes(entry.size));
|
if !subtitle.is_empty() {
|
||||||
|
row.set_subtitle(&subtitle);
|
||||||
|
}
|
||||||
|
// What I am allowed to do with someone else's file is the thing this page
|
||||||
|
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||||
|
// that is only visible if it is said.
|
||||||
|
if let Some(badge) = role_badge(&entry.role) {
|
||||||
|
row.add_suffix(&badge);
|
||||||
}
|
}
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||||
"folder-symbolic"
|
"folder-symbolic"
|
||||||
@@ -325,6 +332,43 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A shared row's subtitle: its size (files only), and where it can be reached
|
||||||
|
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
||||||
|
/// it. An unlisted share has no local path yet, and says nothing rather than
|
||||||
|
/// pointing at a directory that is not there.
|
||||||
|
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if !entry.is_dir {
|
||||||
|
parts.push(human_bytes(entry.size));
|
||||||
|
}
|
||||||
|
if !entry.path.is_empty() {
|
||||||
|
parts.push(format!("in {}", entry.path));
|
||||||
|
}
|
||||||
|
parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The display name for a wire role, or `None` when there is nothing to show:
|
||||||
|
/// owned content carries no role, and a role the API did not report is not
|
||||||
|
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
|
||||||
|
/// unrecognised mask rather than degrading it to viewer).
|
||||||
|
pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
||||||
|
match role {
|
||||||
|
"viewer" => Some("Viewer"),
|
||||||
|
"editor" => Some("Editor"),
|
||||||
|
"admin" => Some("Admin"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dim role pill for a share I did not create.
|
||||||
|
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
||||||
|
let badge = gtk4::Label::new(Some(role_label(role)?));
|
||||||
|
badge.add_css_class("dim-label");
|
||||||
|
badge.add_css_class("caption");
|
||||||
|
badge.set_valign(gtk4::Align::Center);
|
||||||
|
Some(badge)
|
||||||
|
}
|
||||||
|
|
||||||
/// Download a file shared with me into the daemon's cache and hand it to the
|
/// Download a file shared with me into the daemon's cache and hand it to the
|
||||||
/// user's default application — the shared-item twin of the browser's
|
/// user's default application — the shared-item twin of the browser's
|
||||||
/// download-and-open, addressed by uid because the file lives outside the mount.
|
/// download-and-open, addressed by uid because the file lives outside the mount.
|
||||||
@@ -663,3 +707,52 @@ pub(crate) fn run_shared_mutation(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(is_dir: bool, path: &str, role: &str) -> DirEntry {
|
||||||
|
DirEntry {
|
||||||
|
name: "Budget.ods".into(),
|
||||||
|
is_dir,
|
||||||
|
size: 2048,
|
||||||
|
modified: 0,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: "vol~link".into(),
|
||||||
|
path: path.into(),
|
||||||
|
role: role.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||||
|
// Before the synthetic directory has been listed, the daemon cannot
|
||||||
|
// resolve a path — pointing at one that isn't there would be worse than
|
||||||
|
// silence.
|
||||||
|
assert_eq!(shared_row_subtitle(&entry(true, "", "viewer")), "");
|
||||||
|
assert_eq!(
|
||||||
|
shared_row_subtitle(&entry(false, "", "viewer")),
|
||||||
|
human_bytes(2048)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resident_share_reports_where_it_can_be_reached() {
|
||||||
|
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
|
||||||
|
assert_eq!(subtitle, "in Shared with me/Team Budget");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_known_role_becomes_a_badge() {
|
||||||
|
// "" is owned content (no role applies) and an unrecognised mask is
|
||||||
|
// deliberately not guessed at — `from_permissions_exact` returns None
|
||||||
|
// rather than degrading to viewer, and this must not undo that.
|
||||||
|
for role in ["viewer", "editor", "admin"] {
|
||||||
|
assert!(role_label(role).is_some(), "{role} should have a label");
|
||||||
|
}
|
||||||
|
assert!(role_label("").is_none());
|
||||||
|
assert!(role_label("inherited").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,19 +236,18 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
row.add_suffix(&open);
|
row.add_suffix(&open);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage opens the per-node Share dialog — only when the node's path is
|
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||||
// known (it has been browsed to this session); the dialog is path-keyed.
|
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||||
if !item.path.is_empty() {
|
// pathless node by uid, so every shared item can be managed from here.
|
||||||
let manage = gtk4::Button::builder()
|
let manage = gtk4::Button::builder()
|
||||||
.label("Manage")
|
.label("Manage")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
manage.add_css_class("flat");
|
manage.add_css_class("flat");
|
||||||
let ui_manage = ui.clone();
|
let ui_manage = ui.clone();
|
||||||
let entry = shared_item_as_entry(item);
|
let entry = shared_item_as_entry(item);
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||||
row.add_suffix(&manage);
|
row.add_suffix(&manage);
|
||||||
}
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
ui.shared_by_me.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
@@ -295,5 +294,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
|
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||||
|
role: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,8 +31,8 @@ pub(crate) struct StatusState {
|
|||||||
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
/// Cache-budget editor (GiB). Populated once from config; user edits drive a
|
||||||
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
/// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`].
|
||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Shows the active mountpoint in its subtitle; updated when the user picks a
|
/// Shows where the primary mount lives; the folder itself is managed on the
|
||||||
/// new folder.
|
/// Locations page, which owns every local path.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
/// Set while a settings widget is being populated programmatically, so its
|
/// Set while a settings widget is being populated programmatically, so its
|
||||||
/// change handler skips the IPC/systemd side effect.
|
/// change handler skips the IPC/systemd side effect.
|
||||||
@@ -100,7 +100,8 @@ pub(crate) struct MainWidgets {
|
|||||||
pub(crate) budget_row: adw::SpinRow,
|
pub(crate) budget_row: adw::SpinRow,
|
||||||
/// Purges all unpinned cached content.
|
/// Purges all unpinned cached content.
|
||||||
pub(crate) purge_button: gtk4::Button,
|
pub(crate) purge_button: gtk4::Button,
|
||||||
/// Shows the active mountpoint; its suffix button opens a folder chooser.
|
/// Shows the active mountpoint; its suffix button opens the Locations page,
|
||||||
|
/// which is where the folder is changed.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
pub(crate) mountpoint_button: gtk4::Button,
|
||||||
}
|
}
|
||||||
@@ -215,12 +216,17 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
.subtitle("Mount Proton Drive automatically when you log in.")
|
.subtitle("Mount Proton Drive automatically when you log in.")
|
||||||
.build();
|
.build();
|
||||||
system_group.add(&autostart_row);
|
system_group.add(&autostart_row);
|
||||||
|
// The mountpoint is a *location*, and every other local path this client
|
||||||
|
// owns is managed on the Locations page. Keeping a second chooser here would
|
||||||
|
// be a second source of truth for the same setting, so this row reports the
|
||||||
|
// path and hands the change over.
|
||||||
let mountpoint_row = adw::ActionRow::builder()
|
let mountpoint_row = adw::ActionRow::builder()
|
||||||
.title("Mountpoint")
|
.title("Mountpoint")
|
||||||
.subtitle("—")
|
.subtitle("—")
|
||||||
.build();
|
.build();
|
||||||
let mountpoint_button = gtk4::Button::builder()
|
let mountpoint_button = gtk4::Button::builder()
|
||||||
.label("Change")
|
.label("Locations")
|
||||||
|
.tooltip_text("Manage this computer's Proton Drive locations")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
mountpoint_button.add_css_class("flat");
|
mountpoint_button.add_css_class("flat");
|
||||||
@@ -372,10 +378,10 @@ pub(crate) fn wire_settings(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mountpoint: pick a folder, persist it, and offer to restart the mount so
|
// Mountpoint: the chooser lives on the Locations page, next to every other
|
||||||
// the change takes effect (the daemon reads the path on mount).
|
// local path; this button is the way there.
|
||||||
let ui_mp = ui.clone();
|
let ui_mp = ui.clone();
|
||||||
mountpoint_button.connect_clicked(move |_| prompt_mountpoint(&ui_mp));
|
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||||
@@ -423,6 +429,12 @@ pub(crate) fn prompt_mountpoint(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.status.mountpoint_row.set_subtitle(&path_str);
|
ui.status.mountpoint_row.set_subtitle(&path_str);
|
||||||
|
// The Locations page shows the same path as a row title; its cached rows
|
||||||
|
// are now stale whether or not it is the visible page.
|
||||||
|
ui.locations.loaded_at.set(None);
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("locations") {
|
||||||
|
load_locations(&ui);
|
||||||
|
}
|
||||||
|
|
||||||
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
// The daemon only reads the mountpoint at mount time, so offer a restart.
|
||||||
let confirm = adw::AlertDialog::builder()
|
let confirm = adw::AlertDialog::builder()
|
||||||
@@ -503,7 +515,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
|||||||
// they are on screen. Every other page loads on navigation only.
|
// they are on screen. Every other page loads on navigation only.
|
||||||
match ui.stack.visible_child_name().as_deref() {
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
Some("main") => refresh_quota(ui),
|
Some("main") => refresh_quota(ui),
|
||||||
Some("devices") => refresh_sync_folders(ui),
|
Some("locations") => refresh_locations(ui),
|
||||||
Some("activity") => refresh_activity(ui),
|
Some("activity") => refresh_activity(ui),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,117 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How an open Share dialog addresses its node.
|
||||||
|
///
|
||||||
|
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||||
|
/// from Shared or Shared-with-me may not: it can live under a device folder's
|
||||||
|
/// own inode space, or on someone else's volume, neither of which the primary
|
||||||
|
/// mount's path space can name. Those carry a uid instead, and the daemon
|
||||||
|
/// resolves it with `Core::resolve_anywhere` (mount-architecture.md P4).
|
||||||
|
///
|
||||||
|
/// The two are separate request variants rather than one request with an
|
||||||
|
/// optional field on purpose: an older daemon that ignored `uid` would resolve
|
||||||
|
/// the empty path to the *mount root* and act on the wrong node.
|
||||||
|
pub(crate) enum ShareTarget {
|
||||||
|
Path(String),
|
||||||
|
Uid(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareTarget {
|
||||||
|
fn list(&self) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::ListShare { path: path.clone() },
|
||||||
|
ShareTarget::Uid(uid) => Request::ListShareByUid { uid: uid.clone() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invite(&self, emails: Vec<String>, role: String, message: Option<String>) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::ShareNode {
|
||||||
|
path: path.clone(),
|
||||||
|
emails,
|
||||||
|
role,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::ShareNodeByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
emails,
|
||||||
|
role,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_role(&self, id: String, kind: ShareEntryKind, role: String) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::UpdateShareRole {
|
||||||
|
path: path.clone(),
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::UpdateShareRoleByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_entry(&self, id: String, kind: ShareEntryKind) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::RemoveShareEntry {
|
||||||
|
path: path.clone(),
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::RemoveShareEntryByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_link(&self, role: String, password: Option<String>) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||||
|
path: path.clone(),
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires: None,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
role,
|
||||||
|
password,
|
||||||
|
expires: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_link(&self, id: String) -> Request {
|
||||||
|
match self {
|
||||||
|
ShareTarget::Path(path) => Request::RemovePublicLink {
|
||||||
|
path: path.clone(),
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
ShareTarget::Uid(uid) => Request::RemovePublicLinkByUid {
|
||||||
|
uid: uid.clone(),
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The mutable state behind an open Share dialog, so the invite/role/link
|
/// The mutable state behind an open Share dialog, so the invite/role/link
|
||||||
/// handlers can rebuild the people and link sections after each change without
|
/// handlers can rebuild the people and link sections after each change without
|
||||||
/// tearing the whole dialog down.
|
/// tearing the whole dialog down.
|
||||||
pub(crate) struct ShareDialog {
|
pub(crate) struct ShareDialog {
|
||||||
pub(crate) ui: Rc<Ui>,
|
pub(crate) ui: Rc<Ui>,
|
||||||
/// The node's mountpoint-relative path — how every request addresses it.
|
/// How every request from this dialog addresses the node.
|
||||||
pub(crate) rel: String,
|
pub(crate) target: ShareTarget,
|
||||||
pub(crate) people: adw::PreferencesGroup,
|
pub(crate) people: adw::PreferencesGroup,
|
||||||
pub(crate) link_group: adw::PreferencesGroup,
|
pub(crate) link_group: adw::PreferencesGroup,
|
||||||
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) people_rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
@@ -41,7 +145,13 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rel = entry_rel(ui, entry);
|
// A node with no path is not a bug here: Shared and Shared-with-me list
|
||||||
|
// nodes that the primary mount never interned. Address those by uid.
|
||||||
|
let target = if entry.path.is_empty() {
|
||||||
|
ShareTarget::Uid(entry.uid.clone())
|
||||||
|
} else {
|
||||||
|
ShareTarget::Path(entry_rel(ui, entry))
|
||||||
|
};
|
||||||
|
|
||||||
let toolbar = adw::ToolbarView::new();
|
let toolbar = adw::ToolbarView::new();
|
||||||
let header = adw::HeaderBar::new();
|
let header = adw::HeaderBar::new();
|
||||||
@@ -110,7 +220,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
|
|
||||||
let state = Rc::new(ShareDialog {
|
let state = Rc::new(ShareDialog {
|
||||||
ui: ui.clone(),
|
ui: ui.clone(),
|
||||||
rel,
|
target,
|
||||||
people,
|
people,
|
||||||
link_group,
|
link_group,
|
||||||
people_rows: RefCell::new(Vec::new()),
|
people_rows: RefCell::new(Vec::new()),
|
||||||
@@ -144,12 +254,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
let msg_clear = message_row.clone();
|
let msg_clear = message_row.clone();
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_inv,
|
&state_inv,
|
||||||
Request::ShareNode {
|
state_inv.target.invite(emails, role, message),
|
||||||
path: state_inv.rel.clone(),
|
|
||||||
emails,
|
|
||||||
role,
|
|
||||||
message,
|
|
||||||
},
|
|
||||||
"Invitations sent",
|
"Invitations sent",
|
||||||
"Couldn't send invitations",
|
"Couldn't send invitations",
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
@@ -165,12 +270,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
|
|
||||||
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
/// Re-fetch the node's share and rebuild the people + public-link sections.
|
||||||
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(state.ui.dirs.control_socket(), state.target.list());
|
||||||
state.ui.dirs.control_socket(),
|
|
||||||
Request::ListShare {
|
|
||||||
path: state.rel.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
@@ -232,12 +332,11 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
drop.connect_selected_notify(move |d| {
|
drop.connect_selected_notify(move |d| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_role,
|
&state_role,
|
||||||
Request::UpdateShareRole {
|
state_role.target.update_role(
|
||||||
path: state_role.rel.clone(),
|
id.clone(),
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
kind,
|
||||||
role: role_index_to_wire(d.selected()).to_string(),
|
role_index_to_wire(d.selected()).to_string(),
|
||||||
},
|
),
|
||||||
"Role updated",
|
"Role updated",
|
||||||
"Couldn't update the role",
|
"Couldn't update the role",
|
||||||
None,
|
None,
|
||||||
@@ -265,11 +364,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemoveShareEntry {
|
state_rm.target.remove_entry(id.clone(), kind),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
"Access removed",
|
"Access removed",
|
||||||
"Couldn't remove access",
|
"Couldn't remove access",
|
||||||
None,
|
None,
|
||||||
@@ -332,10 +427,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
share_dialog_op(
|
share_dialog_op(
|
||||||
&state_rm,
|
&state_rm,
|
||||||
Request::RemovePublicLink {
|
state_rm.target.remove_link(id.clone()),
|
||||||
path: state_rm.rel.clone(),
|
|
||||||
id: id.clone(),
|
|
||||||
},
|
|
||||||
"Public link removed",
|
"Public link removed",
|
||||||
"Couldn't remove the link",
|
"Couldn't remove the link",
|
||||||
None,
|
None,
|
||||||
@@ -407,12 +499,7 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
) {
|
) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
state.ui.dirs.control_socket(),
|
state.ui.dirs.control_socket(),
|
||||||
Request::CreatePublicLink {
|
state.target.create_link(role, password),
|
||||||
path: state.rel.clone(),
|
|
||||||
role,
|
|
||||||
password,
|
|
||||||
expires: None,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
|
|||||||
@@ -76,6 +76,31 @@ sequenceDiagram
|
|||||||
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
FUSE-->>User: returns child inode metadata (attributes, TTL)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Access Classification and Enforcement
|
||||||
|
|
||||||
|
Every `Entry` carries an `Access` (`Owner | Editor | Viewer | Unknown`), inherited from its parent at intern time rather than resolved per node — a child is always interned from its parent's listing, so one edge lookup answers it. A known share root takes its access from the persisted `share_access` table instead, which is also what makes the classification correct offline.
|
||||||
|
|
||||||
|
Two rules decide the cases inheritance cannot:
|
||||||
|
|
||||||
|
* **Not under a share, no role → `Owner` (fail open).** My Files and device folders are owned content; regressing this denies ordinary writes.
|
||||||
|
* **Under a share with no usable role → `Viewer` (fail closed).** An unrecognised permission mask is never degraded into a guess.
|
||||||
|
|
||||||
|
A node whose parent is not resident is the awkward case: a device folder's parent is the device root, which is never persisted as a node, so the whole subtree hydrates parentless. It resolves the way the persisted authority (`Db::effective_node_access`) does — a recorded share row above it wins, otherwise fail open on the mount's own volume and closed on a foreign one (`docs/BUGS.md` B79).
|
||||||
|
|
||||||
|
Enforcement is three layers, and only the third closes B34:
|
||||||
|
|
||||||
|
1. **Mode bits.** `attr()` returns `0o555`/`0o444` for a non-writable entry, and both mount paths set `MountOption::DefaultPermissions`, so the *kernel* refuses `open(O_WRONLY)`, `access(W_OK)` and namespace operations for any non-root process.
|
||||||
|
2. **Handler gates.** `EACCES` in `create`/`mkdir`/`unlink`/`rmdir`/`rename` (parent-writable; both parents for a rename) and in `open`-for-write/`write`/`setattr`/`fallocate`. Covers root and stale attribute TTLs.
|
||||||
|
3. **Queue guards.** `Core::require_uid_writable` admits a mutation only when the persisted authority *and* every live inode space agree the uid is writable. Nothing reaches `pending_op` otherwise, so the perpetual failing drain that B34 describes cannot occur even if a handler check is missed. The intersection is across mounts because a uid may be resident in more than one inode space.
|
||||||
|
|
||||||
|
`EACCES`, not `EROFS`, for a read-only subtree inside a read-write mount: `EROFS` means "read-only filesystem" and misleads the heuristics in `cp`, `rsync` and `git`.
|
||||||
|
|
||||||
|
### Local Locations
|
||||||
|
|
||||||
|
`mount` is a presentation table: one row per local place this client occupies — the primary My Files session, and each device folder in mirror or on-demand mode. `sync_folder` remains the sync engine's own table (`sync_entry` is FK'd to it) and `MountKind::Device { sync_folder_id }` is the join. `Request::ListLocations` serves the table to `pdfs locations` and the GUI's Locations page.
|
||||||
|
|
||||||
|
A mirror folder is a plain local directory with no FUSE session, so a row can legitimately describe a location that is not mounted — which is why the page is called *Locations* rather than *Mounts*. The primary mountpoint stays in `AppConfig`, with its `mount` row written at daemon start as a projection of it, so there are not two sources of truth for that path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Read Path & Block Caching Pipeline
|
## 3. Read Path & Block Caching Pipeline
|
||||||
@@ -225,6 +250,7 @@ The background daemon relies on the following thread topology:
|
|||||||
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
|
3. **IPC listener Thread:** Listens on the Unix socket and admits at most 64 concurrent, timeout-bound handlers for front-end status/configuration requests.
|
||||||
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
4. **Sync Engine Loop Thread:** Serializes sync runs. Wakes on debounced local inotify filesystem changes, remote polling intervals, or manual user requests.
|
||||||
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
5. **Drain Queue Worker Thread:** Processes staged writes (`PendingOp`) sequentially, uploading revisions and retrying with exponential backoff on failures.
|
||||||
|
6. **Conflict Sweep Thread (`pdfs-conflict-sweep`, optional):** Reconciles leftover `(sync-conflict …)` copies — 30 s warmup, then one pass every 5 minutes. A copy proven identical to its live sibling (equal size **and** equal `content_sha1`) is redundant; anything it cannot prove identical is surfaced to the activity feed and left alone. Governed by `AppConfig.conflict_sweep` / `PDFS_CONFLICT_SWEEP` (`SweepMode`): **report-only by default**, `off` skips the thread entirely, and only `enforce` lets it trash. Because it deletes, the enforcing path re-verifies revision id, size, digest, name, parent, queued ops and open handles immediately before acting rather than trusting its own pass-start snapshot. See `docs/BUGS.md` B69 and B71.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+713
-2
@@ -12,6 +12,133 @@ Conventions:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## B79 — Every write in an on-demand device-folder mount fails EACCES
|
||||||
|
|
||||||
|
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
||||||
|
package since)
|
||||||
|
**Found:** 2026-07-31, user on packaged 1.2.1: `touch ~/Documents/test` →
|
||||||
|
`Permission denied`, while the same operation under `~/ProtonDrive` succeeds.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/state.rs`, `State::hydrate_access`
|
||||||
|
|
||||||
|
Not a mode-bit problem — the mount root reads `drwxr-xr-x` and the kernel's
|
||||||
|
`default_permissions` check passes. The denial comes from the queue guard:
|
||||||
|
`serve_create` → `Core::require_uid_writable`, which **intersects the access
|
||||||
|
every live inode space reports for that uid** (`lib.rs:721`) before anything
|
||||||
|
reaches `pending_op`.
|
||||||
|
|
||||||
|
The on-demand fork itself says `Owner`. The **primary** state says `Unknown`:
|
||||||
|
|
||||||
|
- `Core::hydrate` (`lib.rs:1012`) loads *every* `nodes` row into the primary
|
||||||
|
state, including the subtrees that belong to on-demand device folders — they
|
||||||
|
are on the same volume, in the same table.
|
||||||
|
- A device folder's `parent_uid` is the **device root**, which is never
|
||||||
|
persisted as a node. In this account's DB all six device-folder roots
|
||||||
|
(`Downloads`, `Pictures`, `Music`, `Documents`, `Videos`, `narl`) have
|
||||||
|
`parent_uid = <device root>` with no matching row, so pass 2 parks them at
|
||||||
|
`ORPHAN_INO`.
|
||||||
|
- `hydrate_access` then hit its "parent is not resident" branch, which had no
|
||||||
|
answer and fell back to `Access::Unknown` — fail closed.
|
||||||
|
- `Unknown` is not writable, so the intersection denies every create, mkdir,
|
||||||
|
write, rename, setattr and trash in every on-demand mount, permanently: those
|
||||||
|
entries are never re-interned in the primary state, so nothing recomputes them.
|
||||||
|
|
||||||
|
`~/ProtonDrive` was unaffected because its nodes descend from `ROOT_INO`, which
|
||||||
|
`ProtonFs::new` hardcodes to `Owner`.
|
||||||
|
|
||||||
|
Two facts make this a *classification* bug rather than a policy one: the
|
||||||
|
persisted authority `Db::effective_node_access` already answers `Owner` for
|
||||||
|
exactly these uids (no `share_access` row covers them — the table holds only
|
||||||
|
`virtual~sharedwithme` and one accepted share), and the design table in
|
||||||
|
`mount-architecture.md` §2.2 says *not under a share, no role → Owner, fail
|
||||||
|
open*. The in-memory path disagreed with both.
|
||||||
|
|
||||||
|
**Fix:** the non-resident-parent branch now mirrors the persisted authority — a
|
||||||
|
recorded `share_access` row above the node still wins, otherwise fail **open**
|
||||||
|
to `Owner` on the mount's own volume and fail **closed** to `Unknown` on a
|
||||||
|
foreign one (`State::is_own_volume`). Foreign-volume shared content keeps its
|
||||||
|
fail-closed behavior, which is what B34 needs.
|
||||||
|
|
||||||
|
**Second-order effect, also fixed by the same change:** `downgrade_known_shared_access`
|
||||||
|
selects roots with `entry.access != Access::Owner`, so a `ScopeAccessLost` /
|
||||||
|
`SharedWithMeUpdated` event would additionally have force-downgraded every
|
||||||
|
device-folder tree to `Viewer` in memory.
|
||||||
|
|
||||||
|
**Tests:** `state::tests::own_volume_nodes_without_a_resident_parent_stay_owned`,
|
||||||
|
`foreign_volume_nodes_without_a_resident_parent_fail_closed`,
|
||||||
|
`persisted_authority_outranks_the_own_volume_fail_open`. The first fails on the
|
||||||
|
pre-fix branch with `left: Unknown, right: Owner`. The standing live guard is the
|
||||||
|
`regression B79` acceptance case, which finds a mounted on-demand device folder
|
||||||
|
through `pdfs locations --json` and writes into it (skipping when this machine
|
||||||
|
has none).
|
||||||
|
|
||||||
|
**Verified live (2026-07-31):** the packaged 1.2.1 daemon denies
|
||||||
|
`touch ~/Documents/pdfs-b79-test`, `mkdir`, and `>` redirect while
|
||||||
|
`~/ProtonDrive` accepts them; a local `--release` build of this fix accepts all
|
||||||
|
three on the same account and DB, the write drains (`pending_op` back to 0, the
|
||||||
|
new nodes carry real `G88km…` remote uids rather than local ones), and the
|
||||||
|
daemon log has no `403`/EACCES. `~/ProtonDrive/Shared with me/` stayed
|
||||||
|
`dr-xr-xr-x`, and the accepted editor-role share under it stayed writable — the
|
||||||
|
foreign-volume fail-closed path is unchanged.
|
||||||
|
|
||||||
|
**Side note for whoever runs this next:** stopping the daemon and touching a
|
||||||
|
file in an on-demand *mountpoint* while it is unmounted leaves that file in the
|
||||||
|
local directory, and `restore_ondemand_mounts` then refuses to mount over the
|
||||||
|
non-empty dir (`WARN … local dir is not empty; refusing to mount over it`).
|
||||||
|
Empty the directory and restart the unit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B78 — Profile backup fails: Cannot create file at the root of a device
|
||||||
|
|
||||||
|
**Status:** Fixed (verified live 2026-07-31). Same defect as **B68**, which was
|
||||||
|
found first on the managed-live matrix; B68's second half — making backup health
|
||||||
|
visible — is still open.
|
||||||
|
**Found:** 2026-07-29, user reported `upload profile: proton api error NotEnoughPermissions (http 422): Cannot create file at the root of a device`
|
||||||
|
|
||||||
|
The background task that backs up the machine's profile (sync folder mappings, pins, cache budget) currently attempts to write `profile.json` directly to the device root node (`device.root_uid`) on the Drive backend. The Proton Drive API has started enforcing a restriction that files cannot be created directly at the root of a device; they must be placed inside a folder.
|
||||||
|
|
||||||
|
**Consequence:** The upload is rejected with HTTP 422. The daemon retries when changes happen but the profile is never successfully backed up. Local changes (pinning, adding a sync folder) still take effect locally, but they will not be restorable on a new machine.
|
||||||
|
|
||||||
|
**Fix applied (matches the original `RECOVERY.md` spec):** the profile now lives
|
||||||
|
in a `.proton-drive-linux` folder inside the device root.
|
||||||
|
|
||||||
|
- `pdfs_core::profile::PROFILE_DIR_NAME` — the new constant, next to
|
||||||
|
`PROFILE_FILE_NAME`.
|
||||||
|
- `profile.rs:ensure_profile_dir` — reuses the folder by name via the existing
|
||||||
|
`find_device_child_folder`, creating it on the first save. Reuse-by-identity
|
||||||
|
matters: a second folder of the same name would split the record in two with
|
||||||
|
nothing to say which is current.
|
||||||
|
- `profile.rs:save_profile` — both upload branches (new file, new revision) now
|
||||||
|
address the folder, never the device root.
|
||||||
|
- `profile.rs:load_profile` — reads the folder first, then falls back to a
|
||||||
|
device-root `profile.json` written by an older client. The fallback is
|
||||||
|
read-only and the legacy document is **not** trashed: another machine may
|
||||||
|
still be running a pre-fix client that reads it.
|
||||||
|
- `profile.rs:list_restorable_folders` — filters `.proton-drive-linux` out, or
|
||||||
|
the restore picker would offer to sync the bookkeeping back onto the machine
|
||||||
|
it describes.
|
||||||
|
- `devices.rs:add_sync_folder` — refuses a local folder named
|
||||||
|
`.proton-drive-linux`, which would otherwise be reused as the remote profile
|
||||||
|
folder by name and then reconciled (i.e. overwritten) by the sync engine.
|
||||||
|
|
||||||
|
**Verified live (2026-07-31)** against the real account: first save logged
|
||||||
|
`created device profile folder name=".proton-drive-linux"` then
|
||||||
|
`profile backed up folders=6` with no 422; a second trigger reused the folder
|
||||||
|
(no second create) and uploaded a revision; `pdfs sync restore --json` lists
|
||||||
|
exactly the six device folders and not the profile folder.
|
||||||
|
|
||||||
|
**Tests:** `profile::tests::the_profile_is_never_uploaded_to_the_device_root`,
|
||||||
|
`loading_falls_back_to_a_legacy_device_root_profile`,
|
||||||
|
`the_profile_folder_is_not_offered_as_user_data` — source-level guards in the
|
||||||
|
style of `lib.rs`'s queue-guard ordering tests, because both upload branches are
|
||||||
|
pure network calls with nothing to assert offline.
|
||||||
|
|
||||||
|
**Not covered by this fix:** B68 also asks for backup *health* to be visible
|
||||||
|
(currently a `WARN` in the log and nothing in the UI), and for the
|
||||||
|
replacement-machine / fresh-state restore to be re-run end to end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## B1 — FUSE rename loses the file (data loss)
|
## B1 — FUSE rename loses the file (data loss)
|
||||||
|
|
||||||
**Status:** Fixed (verified 2026-07-20)
|
**Status:** Fixed (verified 2026-07-20)
|
||||||
@@ -1159,6 +1286,8 @@ retracted.
|
|||||||
|
|
||||||
**Verified:** Clean build and 200/200 workspace tests passing.
|
**Verified:** Clean build and 200/200 workspace tests passing.
|
||||||
|
|
||||||
|
**Residual (2026-07-24):** the two-part defense narrowed the window but did not close it — the baseline was still keyed on `(mtime, size)`, which drifts when the server re-stamps the *same* revision. Reproduced again on a plain application save (`test_export.xml`): base mtime `1784897777` vs sealed `1784897780`, identical size, no other device involved. The root cause is that mtime is not a revision identity. Fixed properly by **B69**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix
|
## B17 — FUSE mount lock contention, missing next_attempt_at in DB enqueue, and ioctl ENOTTY fix
|
||||||
@@ -1430,12 +1559,22 @@ added later, but it must retain the row and blob.
|
|||||||
|
|
||||||
## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07)
|
## B34 — Writable POSIX Exposure of Read-Only Shared Folders (MED-07)
|
||||||
|
|
||||||
**Status:** Open
|
**Status:** Fixed in code by P2/P3 (2026-07-29); P3 now exposes the shared tree, while credentialed
|
||||||
|
second-account and live FUSE acceptance remain pending P7
|
||||||
**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07)
|
**Found:** 2026-07-21, multi-agent permissions audit (`audit_bugs.md` MED-07)
|
||||||
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs`
|
**Where:** `crates/pdfs-fuse/src/lib.rs`, `crates/pdfs-fuse/src/sharing.rs`
|
||||||
|
|
||||||
**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors.
|
**Cause:** Shared "Viewer" (read-only) folders are exposed via POSIX with write permissions (`0755`). Local writes succeed initially on the mount but fail perpetually during background online drain with `403 Forbidden` errors.
|
||||||
|
|
||||||
|
**Fix:** P2 persists share authority in schema V17 `share_access`, inherits it through resident
|
||||||
|
entries, exposes read-only POSIX modes, and gates mutations at handler, queue, and drain boundaries.
|
||||||
|
Downgrades fail closed and are not acknowledged past failed durable state updates.
|
||||||
|
|
||||||
|
**Verification:** The local workspace passes formatting, locked checks, clippy with warnings denied,
|
||||||
|
and all 347 workspace tests, including P3 virtual-tree materialization and access inheritance plus
|
||||||
|
mutation gating, queued-operation authorization, and downgrade failure paths. Credentialed
|
||||||
|
second-account and live FUSE acceptance have not been run and remain required in P7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B35 — IPC Unix Socket Creation Permission Race (MED-08)
|
## B35 — IPC Unix Socket Creation Permission Race (MED-08)
|
||||||
@@ -2093,7 +2232,9 @@ duplicates, renamed folders, multiple devices, and byte-identical restoration.
|
|||||||
|
|
||||||
## B68 — Profile Backup Cannot Be Written to the Device Root
|
## B68 — Profile Backup Cannot Be Written to the Device Root
|
||||||
|
|
||||||
**Status:** Open — live-reproduced 2026-07-22
|
**Status:** Partly fixed — the destination is fixed and verified live
|
||||||
|
(2026-07-31, see **B78**); backup health is still invisible in the UI and the
|
||||||
|
replacement-machine restore has not been re-run
|
||||||
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
|
**Found:** complete managed-live mode matrix against the 1.0.0 release binary
|
||||||
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
**Where:** `crates/pdfs-fuse/src/profile.rs`, profile backup destination
|
||||||
|
|
||||||
@@ -2110,3 +2251,573 @@ the explicit remote-folder mapping. Repeated retries also add noisy warnings.
|
|||||||
rather than directly at the device root, reuse that folder by stable identity,
|
rather than directly at the device root, reuse that folder by stable identity,
|
||||||
and make backup health visible. Verify upload, replacement, restart restore, and
|
and make backup health visible. Verify upload, replacement, restart restore, and
|
||||||
byte-identical fresh-state recovery on a dedicated account.
|
byte-identical fresh-state recovery on a dedicated account.
|
||||||
|
|
||||||
|
## B69 — Spurious `(sync-conflict)` copies from mtime-keyed revision identity (B16/B25 root fix)
|
||||||
|
|
||||||
|
**Status:** Fixed — offline-tested 2026-07-25, **live-validated 2026-07-26** (see below)
|
||||||
|
**Found:** 2026-07-24, user reported `test_export (sync-conflict 1784898786).xml` created after a normal save from an application into the on-demand mount. A full-mount scan turned up ~156 conflict copies; 151 were byte-identical to their live sibling.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/{drain,filesystem,lib,state,sweep,sync,sync/planner}.rs`, `crates/pdfs-core/src/cache.rs`, `crates/pdfs-core/src/control.rs`; SDK `proton-drive-rs` `node.rs`/`client.rs`/`public_link.rs`.
|
||||||
|
|
||||||
|
**Cause:** Two independent false-positive sources, both from `(mtime, size)` being used as a revision identity (the weakness B25 flagged; the recurrence B16 could not fully close):
|
||||||
|
1. **Re-stamped mtime.** `revision_conflict` compared the queued write's baseline mtime against the remote's. The server stamps a sealed revision with its own commit time (`…780`), a few seconds off the optimistic time the client baselined at (`…777`). Same bytes, same size, drifted mtime → the drain diverted the write into a conflict copy of its own base.
|
||||||
|
2. **First-sync of pre-existing files.** `plan_file` classified `(local, remote, no-baseline)` as `Conflict`, so the *first* reconcile of a folder cut a conflict copy of every file that already existed identically on both sides — the ~151-file storm, all in one ~40s window.
|
||||||
|
|
||||||
|
**Fix:** Make conflict detection key on the server **revision id**, the true identity (it advances iff a new revision was sealed):
|
||||||
|
1. **SDK** exposes `active_revision_id` and `content_sha1` (plaintext SHA-1 from the revision's decrypted `XAttr`) on `NodeKind::File` — both already on the wire / already decrypted, no extra round trips.
|
||||||
|
2. **`Baseline`** gains `revision_id`, captured at write-open (`WriteHandle.base_revision_id`) and carried across supersede/rebaseline like the mtime. `revision_changed` (extracted, pure, unit-tested) trusts the id when both sides have it and only falls back to `(mtime, size)` for an old sidecar. A re-stamped mtime on the same id is no longer a conflict; a same-size edit by another device (new id) still is.
|
||||||
|
3. **Planner** adds `FilePlan::AdoptBaseline`: a no-baseline pair of equal size is recorded into the baseline instead of conflicted. Divergent sizes stay a real conflict.
|
||||||
|
4. **Auto-sweep** (`sweep.rs`, `pdfs-conflict-sweep` thread, 5-min cadence): removes conflict copies proven identical to their live sibling (equal size **and** equal `content_sha1`, trashing the copy remotely) and surfaces divergent/orphaned ones once as a new `ActivityKind::Conflict` entry. Never removes a copy it cannot prove is a duplicate.
|
||||||
|
|
||||||
|
**Verified:** `proton-sdk-rs` 50/61 lib tests + clippy clean; client `cargo fmt`/`clippy -D warnings`/`cargo test --workspace --locked` all green (258 tests, incl. new `revision_changed`, planner `AdoptBaseline`, and `conflict_base_name` cases). The 151 identical copies were removed manually during triage; the 5 divergent ones are left for the user.
|
||||||
|
|
||||||
|
**Live validation (2026-07-26):** 1.1.0 installed over the production account (6
|
||||||
|
FUSE mounts of real data), daemon restarted, sweep observed across its warmup pass
|
||||||
|
and a full 5-minute interval. Pre-run snapshot: exactly 2 `(sync-conflict)` copies
|
||||||
|
left on the account, both divergent in size *and* `content_sha1`, so the correct
|
||||||
|
behaviour was to remove nothing. Result: **nothing removed** — the conflict
|
||||||
|
manifest was byte-identical before and after, the activity feed gained zero
|
||||||
|
`auto-removed duplicate` entries, and the copies were flagged `differs from …` as
|
||||||
|
intended. (The 40 `trash` entries in the feed for that window are
|
||||||
|
`trashed from the mount` — a previous FUSE-acceptance run's queued cleanup ops
|
||||||
|
replaying through the drain, unrelated to the sweep.) The sweep's *removal* path
|
||||||
|
therefore remains unexercised against a real account; it is now report-only by
|
||||||
|
default (B71) precisely so that stays true until there is field evidence.
|
||||||
|
|
||||||
|
**Shipping note (resolved 2026-07-25):** the client's revision-id/sha1 use depends on new SDK surface (`proton-sdk`/`proton-drive-rs` **0.2.2**). That version is now **published to crates.io**; the client's workspace deps were bumped `"0.2"` → `"0.2.2"`, the `Cargo.lock` re-resolved to the registry crates, and the local `[patch.crates-io]` shim removed. No local patch remains. (Live validation of the fix against a real account is still pending.)
|
||||||
|
|
||||||
|
## B70 — Browser in-flight temp files uploaded and conflict-forked on the on-demand mount
|
||||||
|
|
||||||
|
**Status:** Layers A + B fixed in-tree 2026-07-25 — **live-validated 2026-07-26**.
|
||||||
|
Validation was blocked until B74 was fixed (its scenario ends in a rename, which
|
||||||
|
B74 made read back as empty); with that out of the way the acceptance case
|
||||||
|
`regression B70: transient download name is not sealed` passes live.
|
||||||
|
**Found:** triaging the divergent conflict copies B69 left behind. In `~/Downloads`
|
||||||
|
(an on-demand mount) the complete files had been forked into `(sync-conflict)`
|
||||||
|
copies while their truncated partials kept the canonical name — e.g.
|
||||||
|
`teamspeak.tar.gz` (30 MB partial live vs a 3.77 GB conflict copy) and `nils.zip`
|
||||||
|
(17 MB vs 3.18 GB). The conflict copies were byte-exact to the user's independent
|
||||||
|
gdrive backups, proving the *conflict copy* was the finished download and the
|
||||||
|
*live* file the stub. The smoking gun: four `Unconfirmed NNNNN (sync-conflict …).crdownload`
|
||||||
|
copies — Brave's in-flight temp files, forked *mid-download*. User confirmed the
|
||||||
|
trigger: downloading with Brave directly into the on-demand folder, where a stall
|
||||||
|
+ "resume" is routine.
|
||||||
|
**Where:** on-demand write path — `crates/pdfs-fuse/src/filesystem.rs` `release`
|
||||||
|
(`queue_revision`, ~line 780) and `rename` (~line 933); drain seal in
|
||||||
|
`crates/pdfs-fuse/src/drain.rs`. The existing ignore system
|
||||||
|
(`crates/pdfs-core/src/syncignore.rs`, `DEFAULT_IGNORE_PATTERNS`) is wired **only**
|
||||||
|
into mirror reconcile (`sync.rs`), so it never sees on-demand writes.
|
||||||
|
|
||||||
|
**Cause:** A browser downloading into the mount writes a growing temp file
|
||||||
|
(`*.crdownload`, or `*.part` for Firefox) and, on a stall, closes the fd. `release`
|
||||||
|
hands every closed write to `queue_revision`, so a *partial* revision gets sealed
|
||||||
|
as the canonical file. On resume the browser reopens and rewrites the full file;
|
||||||
|
its baseline revision has moved (the client sealed the partial itself), so the
|
||||||
|
drain diverts the finished bytes into a `(sync-conflict)` copy. Net: the stub wins
|
||||||
|
the name, the real file is exiled to a conflict copy, and every abandoned
|
||||||
|
`.crdownload` is uploaded as its own multi-hundred-MB node. Unlike B69 this is a
|
||||||
|
*genuine* two-revision divergence, so B69's revision-id detection correctly refuses
|
||||||
|
to auto-sweep it — the fix has to stop the fork from happening, not reconcile it
|
||||||
|
after.
|
||||||
|
|
||||||
|
**Fix — two layers, both without an SDK release (A stops transient names ever
|
||||||
|
sealing; B stops a single-writer resume forking against its own seal):**
|
||||||
|
|
||||||
|
- **A — transient names never seal a revision until finalized (done 2026-07-25).**
|
||||||
|
A new `syncignore::is_transient_name` recognises browser download temps
|
||||||
|
(`*.crdownload`/`*.part`/`*.partial`/`*.download`), generic scratch
|
||||||
|
(`*.tmp`/`*.temp`), editor swap/backup (`*.swp`/`*.swx`/`*~`), and office/lock
|
||||||
|
files (`.~lock.*`, `~$*`). On `create` (filesystem.rs) a transient name is kept
|
||||||
|
**purely local** — no empty remote node is minted — and its queued create is
|
||||||
|
**parked**: `next_attempt_at` is set to the new `PARK_UNTIL` sentinel so
|
||||||
|
`Db::next_due_op` never selects it. Written bytes still ride on that create via
|
||||||
|
the existing `attach_blob_to_create`, whose `next_attempt_at` reset now
|
||||||
|
*preserves* a park (SQL `CASE … >= PARK_UNTIL`), so a growing download attaches
|
||||||
|
revision after revision without ever waking the drain. The finalize `rename`
|
||||||
|
(`foo.crdownload → foo`) goes through the existing `is_local_uid` branch
|
||||||
|
(`rewrite_op_target` renames the queued create); when the old name was transient
|
||||||
|
and the new one is not, it calls `Db::set_create_hold(uid, false)` and wakes the
|
||||||
|
drain, so the *completed* file uploads exactly once. Net: no partial and no
|
||||||
|
abandoned temp ever reaches Drive, and nothing forks. Reuses the whole
|
||||||
|
create/attach/rename/drain pipeline — the only new surface is the predicate, the
|
||||||
|
`PARK_UNTIL` sentinel, `set_create_hold`, a `hold` arg on `queue_local_node`,
|
||||||
|
and the attach-preserves-park `CASE`. Tests: `syncignore` predicate cases +
|
||||||
|
`db::a_parked_transient_create_stays_off_the_drain_until_finalized`. No SDK
|
||||||
|
dependency.
|
||||||
|
- *Not yet covered by A:* apps that write the final name in place with **no**
|
||||||
|
temp suffix (rare), and Firefox multi-connection `.part` files that it may
|
||||||
|
rename per-segment — those still rely on B. An abandoned transient file (a
|
||||||
|
cancelled download left on disk) now stays local-only forever rather than
|
||||||
|
polluting Drive; a later janitor could reclaim its staged blob.
|
||||||
|
- **B — don't self-conflict a single-writer sequential rewrite (done 2026-07-25,
|
||||||
|
no SDK dep).** The safety net for names A does not recognise (an app writing the
|
||||||
|
final name in place, Firefox per-segment `.part` renames, or any resume the
|
||||||
|
in-memory rebase machinery missed). The daemon now records the server revision
|
||||||
|
id it *itself* seals per node (`Core.own_sealed_revs`, written in
|
||||||
|
`refresh_after_upload` where the sealed node is already fetched, dropped on
|
||||||
|
trash). When a queued write drains and `revision_changed` fires,
|
||||||
|
`Core::revision_conflict` checks whether the remote sits at one of *our own*
|
||||||
|
sealed revisions (pure `is_own_self_supersede`): if so, no other device touched
|
||||||
|
the file — it is a single-writer stall→resume — so it chains (supersedes)
|
||||||
|
instead of forking a `(sync-conflict)` copy. Gated on `meta.complete`: an
|
||||||
|
incomplete blob's gaps still refer to the stale base, so it keeps the
|
||||||
|
non-destructive conflict-copy path. This is a superset guard over the existing
|
||||||
|
`refresh_after_upload`/`rebaseline_pending` rebasers, which only fire while a
|
||||||
|
handle/op is live; B catches the windows they miss (fresh open during the
|
||||||
|
earlier upload's flight, a stale inherited baseline). Tests: `is_own_self_supersede`
|
||||||
|
cases in `drain.rs`. *Residual:* the map is in-memory only, so a daemon restart
|
||||||
|
between the partial seal and the resume drops the record and that one write can
|
||||||
|
still fork — narrow, and B69's revision-id keying already covers the mtime-drift
|
||||||
|
half. A persisted own-seal ledger would close it.
|
||||||
|
|
||||||
|
**Triage of the copies already made is done (2026-07-25):** all completed files
|
||||||
|
were promoted back to their canonical names (metadata-only rename, no re-upload)
|
||||||
|
and the abandoned `.crdownload` stubs trashed.
|
||||||
|
|
||||||
|
## B71 — Conflict sweep is not production-ready (auto-trash loop, CRIT-13)
|
||||||
|
|
||||||
|
**Status:** Blockers 1–4 fixed in-tree 2026-07-26; items 5–10 and the remaining
|
||||||
|
cleanup still open. The sweep is safe to leave running (it is report-only by
|
||||||
|
default and cannot trash anything until explicitly switched to `enforce`).
|
||||||
|
**Where:** `crates/pdfs-fuse/src/sweep.rs`, spawn site `crates/pdfs-fuse/src/mount.rs`,
|
||||||
|
config surface `crates/pdfs-core/src/config.rs`.
|
||||||
|
|
||||||
|
The B69 fix (see above) shipped a `pdfs-conflict-sweep` thread that **trashes user
|
||||||
|
files on the real Drive** from a background loop. The identity check itself is
|
||||||
|
conservative (equal size *and* equal `content_sha1`, missing digest treated as
|
||||||
|
divergent), but the machinery around it is not yet safe to run unattended. Review
|
||||||
|
found the following, ordered by severity. Each is a separate defect; they are
|
||||||
|
grouped because they gate the same feature.
|
||||||
|
|
||||||
|
**Blockers (all fixed 2026-07-26 — the sweep may not trash anything without them):**
|
||||||
|
|
||||||
|
1. **Data-loss race — trash-then-discard (CRIT).** `remove_conflict_copy`
|
||||||
|
(`sweep.rs:137-155`) trashes the node remotely and *then* calls
|
||||||
|
`discard_queued_ops(uid)`, which deletes the node's queued ops **and discards
|
||||||
|
their staged blobs** (`lib.rs:1544-1553`). The decision was made from the
|
||||||
|
`load_all()` snapshot taken at `sweep.rs:77`, with network round trips in
|
||||||
|
between. A user edit landing in that window is destroyed — the queued write is
|
||||||
|
dropped and its staged bytes, which may be the only copy, are discarded. This
|
||||||
|
violates the invariant that `staging/` is never purged. *Fix:* re-read the node
|
||||||
|
from the DB and re-verify `active_revision_id` immediately before trashing;
|
||||||
|
skip the node outright if `Core.pending` or the op table has any entry for it;
|
||||||
|
never let the sweep discard staged bytes.
|
||||||
|
2. **No kill-switch (CRIT).** `mount.rs:234-239` spawns the loop unconditionally.
|
||||||
|
There is no `AppConfig` field and no environment override, so the only way to
|
||||||
|
stop a destructive background loop is to uninstall. *Fix:* add
|
||||||
|
`AppConfig.conflict_sweep: Option<bool>` (same `Option` idiom as `cache_budget`
|
||||||
|
/ `ignore_patterns`), a `PDFS_CONFLICT_SWEEP=off` override for support, and a
|
||||||
|
Settings toggle.
|
||||||
|
3. **Open handles ignored (HIGH).** Nothing checks for a live file handle before
|
||||||
|
`forget_or_unlink` + `evict_reader`, so the sweep can trash a node a reader
|
||||||
|
currently has open. *Fix:* skip nodes with open handles.
|
||||||
|
4. **Enforcing on day one (HIGH, process).** The sweep exists because B69 proved
|
||||||
|
the previous revision-identity check was wrong; betting deletion on the
|
||||||
|
*replacement* check in the same release, with no field evidence, is the wrong
|
||||||
|
order. *Fix:* ship report-only (log `would-trash` plus the existing
|
||||||
|
`ActivityKind::Conflict`), collect a release cycle of real logs, then flip to
|
||||||
|
enforcing.
|
||||||
|
|
||||||
|
**Fix for 1–4 (landed 2026-07-26):**
|
||||||
|
|
||||||
|
- **`SweepMode`** (`pdfs-core/src/config.rs`) — `Off` / `Report` / `Enforce`,
|
||||||
|
serialised into `AppConfig.conflict_sweep` (`#[serde(default)]`, so configs
|
||||||
|
predating the field still load). `Default` is **`Report`**, which is blocker 4:
|
||||||
|
an existing install that has never heard of the setting cannot get the
|
||||||
|
enforcing behaviour on upgrade. `AppConfig::resolved_conflict_sweep` layers
|
||||||
|
`PDFS_CONFLICT_SWEEP` over the stored value; the precedence is a pure
|
||||||
|
`resolve_sweep_mode(env, stored)` so it is testable without mutating process
|
||||||
|
state. An unparseable value falls through to the config rather than guessing a
|
||||||
|
mode in either direction.
|
||||||
|
- **Spawn gate** (`mount.rs`) — `SweepMode::Off` skips the thread entirely rather
|
||||||
|
than starting an idle one, so the setting is verifiable from outside the
|
||||||
|
process (`ls /proc/<pid>/task/*/comm`). Both branches log the resolved mode at
|
||||||
|
startup. `mount` grew past clippy's argument limit, so `username` +
|
||||||
|
`sweep_mode` moved into a `MountOptions` struct resolved by the caller —
|
||||||
|
`mount` still reads neither config nor environment.
|
||||||
|
- **Interlocks** (`sweep.rs::duplicate_still_removable`) — re-checks, immediately
|
||||||
|
before the destructive call, everything the pass decided from its stale
|
||||||
|
snapshot: no queued op (new `Db::has_any_op`, a cheap `COUNT` — a queued op
|
||||||
|
means bytes are owed an upload and `discard_queued_ops` would throw away the
|
||||||
|
staged blob holding them), no open write handle or shared scratch file
|
||||||
|
(`Core::is_busy`, checking `active_writes` + `handles` under one `State` lock),
|
||||||
|
and the node still at the same revision id, size, digest, name, parent and
|
||||||
|
untrashed state. Any doubt — including a failed DB read — means leave it alone:
|
||||||
|
a copy that survives to the next pass costs nothing, a wrongly removed one
|
||||||
|
costs data. This is blockers 1 and 3.
|
||||||
|
- **Testability** — the decision moved into a pure
|
||||||
|
`decide(node, sibling, base, mode) -> SweepAction`, the same extraction used
|
||||||
|
for `revision_changed` and `is_own_self_supersede`; everything that can fail or
|
||||||
|
race stays in the caller. Tests now cover removal-when-enforcing,
|
||||||
|
report-mode-never-removes, size-differs, digest-differs, missing-digest in both
|
||||||
|
directions, and orphaned copies. Gate: `cargo fmt` / `clippy -D warnings` clean,
|
||||||
|
`cargo test --workspace` 282 passed (was 270).
|
||||||
|
|
||||||
|
**Should fix:**
|
||||||
|
|
||||||
|
5. **`load_all()` every pass (MED).** Each 300 s pass pulls the entire node table
|
||||||
|
into a `Vec<StoredNode>` and builds a full `(parent, name)` `HashMap`. *Fix:*
|
||||||
|
query only conflict-named rows (`name LIKE '% (sync-conflict %'`) and look up
|
||||||
|
siblings per parent — O(conflicts), not O(drive).
|
||||||
|
6. **No batching or per-pass cap (MED).** The B69 incident produced 151 copies;
|
||||||
|
that is 151 sequential `trash_nodes` calls in one pass with no cap and no
|
||||||
|
backoff. `trash_nodes` takes a slice — batch it and cap per pass.
|
||||||
|
7. **`online` sampled once per pass (MED).** Read at `sweep.rs:100`, ahead of a
|
||||||
|
loop that does network I/O; going offline mid-pass yields a long run of failing
|
||||||
|
calls. *Fix:* bail on the first transport error.
|
||||||
|
8. **Ambiguous sibling index (MED).** `by_parent_name.insert` (`sweep.rs:97`) is
|
||||||
|
last-write-wins, so with duplicate `(parent, name)` rows the surviving entry
|
||||||
|
depends on `load_all` row order. *Fix:* make the choice deterministic or skip
|
||||||
|
ambiguous parents.
|
||||||
|
9. **Trashed ancestors (LOW).** A copy under a trashed folder finds no live
|
||||||
|
sibling and is flagged "orphaned", spamming the activity feed. *Fix:* skip
|
||||||
|
nodes with a trashed ancestor.
|
||||||
|
10. **SHA-1 provenance unverified (MED).** `is_identical` (`sweep.rs:181`)
|
||||||
|
compares digests without asserting they belong to the current
|
||||||
|
`active_revision_id`. A stale `XAttr` digest from an earlier revision plus a
|
||||||
|
size collision would delete real data. (The *missing* digest case is already
|
||||||
|
handled correctly.)
|
||||||
|
|
||||||
|
**Also required to close:**
|
||||||
|
|
||||||
|
- **Tests.** The *policy* is now covered via the pure `decide` (see above). Still
|
||||||
|
untested are the *interlocks*, which need a `Core`: pending-op→skip,
|
||||||
|
open-handle→skip, revision-moved→skip, offline→skip. Those want a test harness
|
||||||
|
that can build a `Core` against a temp DB and a stub client.
|
||||||
|
- **`conflict_notified` is unbounded.** `HashSet<NodeUid>` that only grows except
|
||||||
|
on trash (`sweep.rs:157,170`).
|
||||||
|
- **Docs.** `docs/ARCHITECTURE.md` thread map does not list `pdfs-conflict-sweep`;
|
||||||
|
`docs/RECOVERY.md` needs a "the sweep trashed a file, recover it from Proton
|
||||||
|
trash" path.
|
||||||
|
|
||||||
|
**Mitigating context from the 2026-07-26 pre-run snapshot:** the account had only
|
||||||
|
2 conflict copies left, both divergent in size *and* `content_sha1`, so the
|
||||||
|
correct behaviour for the first live pass is to trash nothing.
|
||||||
|
|
||||||
|
## B72 — `pdfs trash` never returns
|
||||||
|
|
||||||
|
**Status:** Hardened in-tree 2026-07-26 — the hang is gone by construction; the
|
||||||
|
underlying slowness of the first refresh is still unmeasured (live-pending).
|
||||||
|
**Found:** capturing a trash baseline before the B69/B70 live validation. `pdfs trash`
|
||||||
|
produced no output and was still running when killed at 120 s. Daemon was healthy
|
||||||
|
and every other CLI call (`pdfs sync list`, `pdfs --version`) returned promptly.
|
||||||
|
**Where:** `Core::list_trash` (`crates/pdfs-fuse/src/lib.rs`). Not the CLI: the
|
||||||
|
client's 120 s `READ_TIMEOUT` (`pdfs-core/src/control.rs`) is what ends the call,
|
||||||
|
and the daemon never writes a response line.
|
||||||
|
|
||||||
|
**Root cause.** The trash of this account had never been fetched successfully —
|
||||||
|
`trash` table empty, no `trash_synced_ms` in `sync_state`. `list_trash` therefore
|
||||||
|
always took its "never fetched → `rt.block_on(refresh_trash())`" branch, which:
|
||||||
|
|
||||||
|
- materialized the *entire* trash in one `enumerate_nodes` call (one S2K unlock
|
||||||
|
per node) before persisting anything, so nothing was ever saved and no later
|
||||||
|
request could be served from the DB;
|
||||||
|
- had no single-flight guard on that branch (only `spawn_trash_refresh` did), so
|
||||||
|
every attempt started another full refresh;
|
||||||
|
- was never cancelled when the requester timed out, because the control handler
|
||||||
|
sits in `block_on` on its own thread and nothing signals it.
|
||||||
|
|
||||||
|
So each look at the trash added a permanent, full-cost refresh to the daemon.
|
||||||
|
Measured on the live daemon after a handful of GUI/CLI attempts: **11 stale
|
||||||
|
`pdfs-control` threads**, RSS 2.9 GB. Concurrent with a drain backlog that was
|
||||||
|
uploading conflict copies continuously (1991 `Conflict` activity rows, multi-GB
|
||||||
|
uploads), none of the refreshes ever finished.
|
||||||
|
|
||||||
|
**Fix (in-tree).** `list_trash` never blocks on the whole refresh: it kicks the
|
||||||
|
single-flighted background refresh, waits at most `TRASH_FIRST_WAIT` (20 s, well
|
||||||
|
under the front-end read timeout) on a `Notify`, and answers with whatever has
|
||||||
|
materialized. `refresh_trash` now materializes in `TRASH_MATERIALIZE_CHUNK` (150)
|
||||||
|
batches, persisting the cumulative listing after each one, and logs the uid count
|
||||||
|
plus per-batch and total elapsed at INFO/DEBUG — previously the whole path was
|
||||||
|
silent, which is why the stall could not be located from the journal.
|
||||||
|
|
||||||
|
**Still open:** *why* one refresh is slow enough to matter. The new INFO lines
|
||||||
|
(`trash refresh: enumerated` / `batch` / `done`) answer that on the next run:
|
||||||
|
whether `enumerate_trash_node_uids` returns at all, how many nodes the trash
|
||||||
|
holds, and the per-batch cost. If the count is large, materializing with
|
||||||
|
`enumerate_nodes_light` (skips the per-file node-key S2K; `size` would fall back
|
||||||
|
to on-storage size) is the next lever.
|
||||||
|
|
||||||
|
**Impact:** the trash listing is the user's recovery path after any destructive
|
||||||
|
operation, including the B71 sweep. It needs to work before the sweep is allowed
|
||||||
|
to trash anything.
|
||||||
|
|
||||||
|
## B73 — Unimplemented FUSE handlers log a WARN per probe
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||||
|
expanded acceptance suite.
|
||||||
|
**Found:** `scripts/fuse-acceptance.sh --live` step "unsupported operations refuse
|
||||||
|
cleanly". Each probe produced a multi-line `fuser` warning in the daemon journal:
|
||||||
|
|
||||||
|
```
|
||||||
|
WARN fuser: [Not Implemented] symlink(parent: INodeNo(0x2d), link_name: "symlink", target: "link-target")
|
||||||
|
WARN fuser: [Not Implemented] link(ino: INodeNo(0x94), newparent: INodeNo(0x2d), newname: "hardlink")
|
||||||
|
WARN fuser: [Not Implemented] mknod(parent: INodeNo(0x2d), name: "fifo", mode: 4516, umask: 0x12, rdev: 0)
|
||||||
|
WARN fuser: [Not Implemented] setxattr(ino: INodeNo(0x91), name: "user.pdfs.probe", flags: 0x0, position: 0)
|
||||||
|
WARN fuser: [Not Implemented] lseek(ino: INodeNo(0x93), fh: 0, offset: 0, whence: 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** `ProtonFs` does not implement these callbacks, so `fuser`'s default
|
||||||
|
trait method answers `ENOSYS` *and logs at WARN*. The refusal is correct — the
|
||||||
|
acceptance suite accepts every one of them as a clean refusal — but the logging
|
||||||
|
is not conditional on anything the user did wrong.
|
||||||
|
**Impact:** cosmetic, but these are not exotic calls: `git` probes symlinks,
|
||||||
|
`cp -a` probes hard links, `tar` and `cp --sparse` probe `SEEK_HOLE`/`SEEK_DATA`
|
||||||
|
(`whence: 3` above is `SEEK_DATA`), and any xattr-aware tool probes `setxattr`.
|
||||||
|
Warnings that look like faults bury real ones and make both `--journal-check`
|
||||||
|
and manual triage noisier.
|
||||||
|
**Measured** across two full acceptance runs against one daemon lifetime (PID
|
||||||
|
887441, mount never remounted):
|
||||||
|
|
||||||
|
| op | warnings | behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `link` | 11 | repeats on every call |
|
||||||
|
| `symlink` | 4 | repeats on every call |
|
||||||
|
| `mknod` | 2 | repeats on every call |
|
||||||
|
| `setxattr` | 1 | kernel cached the `ENOSYS`; never asked again |
|
||||||
|
| `lseek` | 1 | cached |
|
||||||
|
| `copy_file_range` | 1 | cached |
|
||||||
|
| `fsyncdir` | 1 | cached |
|
||||||
|
|
||||||
|
So the FUSE kernel module remembers `ENOSYS` for the file-level operations and
|
||||||
|
stops issuing them for the lifetime of the mount, but not for the namespace
|
||||||
|
operations, which are re-issued forever.
|
||||||
|
|
||||||
|
`link` leads for a sharper reason than "tools probe it": git uses `link()` to
|
||||||
|
place **every loose object**, falling back to `rename()` when it fails. In one
|
||||||
|
workloads run, 9 of 10 `link` warnings carried a 38-hex-character name — the
|
||||||
|
`.git/objects/ab/<38 hex>` layout — from just 3 commits over 3 files. The volume
|
||||||
|
is therefore proportional to work done, not a fixed startup cost: a `git clone`
|
||||||
|
of a real repository emits one multi-line warning per object. The lone `symlink`
|
||||||
|
that is not the acceptance probe is git's `core.symlinks` detection at
|
||||||
|
`git init` (a random name pointing at `testing`).
|
||||||
|
|
||||||
|
That moves this from cosmetic to a genuine operational problem: ordinary use of
|
||||||
|
git inside a mount can bury every other log line.
|
||||||
|
|
||||||
|
**Fix (applied):** all seven callbacks are now implemented explicitly in
|
||||||
|
`crates/pdfs-fuse/src/filesystem.rs`, each answering with **the same errno
|
||||||
|
`fuser`'s default would** and nothing else — no WARN. Following `ioctl`
|
||||||
|
(which already used this pattern and says why in its comment), the errno
|
||||||
|
contract is unchanged, so the acceptance suite's clean-refusal assertions hold
|
||||||
|
byte for byte:
|
||||||
|
|
||||||
|
| op | errno | why that one |
|
||||||
|
|---|---|---|
|
||||||
|
| `symlink`, `link` | `EPERM` | meaningful operation, unrepresentable on Drive; git falls back to `rename()` |
|
||||||
|
| `mknod` | `ENOSYS` | no devices/FIFOs/sockets; regular files arrive via `create` |
|
||||||
|
| `setxattr` | `ENOSYS` | surfaces as `EOPNOTSUPP`; reads stay implemented for the thumbnail interface |
|
||||||
|
| `fsyncdir` | `ENOSYS` | kernel absorbs it and reports success; dir changes are already durable in SQLite |
|
||||||
|
| `lseek`, `copy_file_range` | `ENOSYS` | **must stay** `ENOSYS` — it is what makes the kernel emulate `SEEK_HOLE`/`SEEK_DATA` and coreutils fall back to read/write instead of failing |
|
||||||
|
|
||||||
|
Only the three namespace ops were strictly required (the other four silence
|
||||||
|
themselves once the kernel caches `ENOSYS`), but implementing all seven keeps the
|
||||||
|
refusal explicit and documented at the call site rather than inherited.
|
||||||
|
|
||||||
|
**Verified:** full live acceptance run after the fix — 19 pass / 1 skip, the
|
||||||
|
capability diff identical to the pre-fix run, and **0** `Not Implemented` lines
|
||||||
|
in the journal across the whole run (was 17, 10 of them `link`).
|
||||||
|
**Regression cover:** the acceptance suite's clean-refusal probes assert the
|
||||||
|
errno contract; they pass unchanged.
|
||||||
|
|
||||||
|
## B74 — A drained create never reaches a sync-folder mount's inode space, so the file reads as empty
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 by the
|
||||||
|
expanded acceptance suite (`regression B70` case), then isolated to a broader
|
||||||
|
trigger.
|
||||||
|
**Impact:** on a *sync folder* mount (not `~/ProtonDrive`), a file whose create
|
||||||
|
was still queued reads back as **0 bytes for as long as the daemon runs**, even
|
||||||
|
though the bytes are on Drive intact. This is the write-to-temp-then-rename
|
||||||
|
pattern used by every browser download, most editor saves, `rsync`, and `git`.
|
||||||
|
|
||||||
|
**Not data loss.** An earlier revision of this entry called it permanent data
|
||||||
|
loss; that was wrong and is corrected here. The uploaded revision is complete and
|
||||||
|
correct: for every "lost" file the node row carried the right `size` and
|
||||||
|
`active_revision_state: "Active"`, and a daemon restart made all of them read
|
||||||
|
back with the expected sha256. Nothing had to be recovered. The defect was
|
||||||
|
entirely local and entirely in the read path.
|
||||||
|
|
||||||
|
**Reproduction** (on an on-demand mount, per file: write, close, rename, wait for
|
||||||
|
`pdfs status` to report the queue drained, then read back):
|
||||||
|
|
||||||
|
| scenario | result |
|
||||||
|
|---|---|
|
||||||
|
| plain name, never renamed | content intact |
|
||||||
|
| plain name, renamed after close | **0 bytes until restart** |
|
||||||
|
| `.crdownload`, renamed after close | **0 bytes until restart** |
|
||||||
|
| `.crdownload`, never renamed | content intact |
|
||||||
|
| already-settled file, renamed | content intact |
|
||||||
|
| any of the above, after a daemon restart | content intact |
|
||||||
|
|
||||||
|
The gap between `close()` and `rename()` does not matter — 0 s, 2 s and 20 s all
|
||||||
|
behave the same. The boundary is not timing but state: the affected node is one
|
||||||
|
whose **create op was still queued**, on a mount that is not the primary one.
|
||||||
|
|
||||||
|
**Root cause — one drain thread, many inode spaces.** The daemon mounts
|
||||||
|
`~/ProtonDrive` plus one FUSE session per `ondemand` sync folder. Each of those
|
||||||
|
is a `Core::fork_state` clone with its **own** `State` — its own `entries`,
|
||||||
|
`by_uid`, `children`, `next_ino` — while sharing `db`, `cache`, `pending` and
|
||||||
|
`client`. There is exactly **one** `pdfs-drain` thread in the process, owned by
|
||||||
|
the primary mount, and it serves the whole shared `pending_op` table.
|
||||||
|
|
||||||
|
So when a create queued by a sync-folder mount lands, the drain calls
|
||||||
|
`adopt_real_uid` — which looked only at `self.state`, the *primary* mount's inode
|
||||||
|
space. The fork's entry keeps its `local~…` placeholder uid forever. And
|
||||||
|
`Core::read_range` (`crates/pdfs-fuse/src/reads.rs:314`) short-circuits a local
|
||||||
|
uid to an empty vec:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if is_local_uid(uid) { return Ok(Vec::new()); }
|
||||||
|
```
|
||||||
|
|
||||||
|
which is why the read is silent, instant, and zero-length. A restart rebuilds
|
||||||
|
every state from the DB, where `remap_local_uid` had already written the real
|
||||||
|
uid, so the file reads correctly from then on.
|
||||||
|
|
||||||
|
Confirmed live: `read handler uid=local~1785067300752-0 fsize=140000` three
|
||||||
|
seconds after `pending create landed`, and at the same moment
|
||||||
|
`adopt_real_uid … hit=None strays=[]` — the primary state had no entry for the
|
||||||
|
uid at all. `/proc/<pid>/task/*/comm` showed 1 `pdfs-drain` against 8 fuser
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
**Fix:** `Core` gained a `states: Arc<StateRegistry>` — a `Vec` of weakly-held
|
||||||
|
mounts that every mount publishes itself into (`Core::register_state`, called
|
||||||
|
from `mount.rs` for the primary and from `fork_state` for each fork), plus
|
||||||
|
`Core::for_each_state`, which walks every live inode space one lock at a time.
|
||||||
|
A uid is unique across mounts, so at most one state matches and the rest are
|
||||||
|
no-ops. Five drain sites were converted: `adopt_real_uid`, `adopt_drained_name`,
|
||||||
|
the conflict-copy `invalidate_listing`, the `active_writes` baseline rebase in
|
||||||
|
`refresh_after_upload`, and that function's closing `intern`.
|
||||||
|
|
||||||
|
**It was completely silent.** No `ERROR`, no `WARN`, no failed op, no conflict
|
||||||
|
copy, no retry — `pdfs status` reported the queue drained, because it had. Any
|
||||||
|
diagnosis that relies on the journal or on queue state will not see this.
|
||||||
|
|
||||||
|
**Regression cover:** `scripts/fuse-acceptance.py`, case
|
||||||
|
`regression B74: rename after close keeps its content` — the narrowest form
|
||||||
|
(plain names, no overwrite, no transient suffix). It failed in two consecutive
|
||||||
|
live runs before the fix and passes after it. The pre-existing B70 case failed on
|
||||||
|
the same cause and now passes too.
|
||||||
|
|
||||||
|
**Audit of the rest of the daemon.** Every background-thread `state.lock()` was
|
||||||
|
reviewed for the same shape. The rule that came out of it: **a uid is unique
|
||||||
|
across mounts, so uid-keyed work must be broadcast; an inode number is only
|
||||||
|
meaningful to the mount that minted it, so inode-keyed work must not be.** What
|
||||||
|
changed:
|
||||||
|
|
||||||
|
| file | site | why |
|
||||||
|
|---|---|---|
|
||||||
|
| `background.rs` | `apply_event` | the read-side twin of this bug — remote deletes, trashes and renames only ever reached the primary mount's tree |
|
||||||
|
| `sweep.rs` | conflict-copy `forget_or_unlink` | a conflict copy inside a sync folder lives in that fork's inode space |
|
||||||
|
| `sweep.rs` | `is_busy` | answered "idle" for every file open on a fork, letting the sweep delete one with a writer attached |
|
||||||
|
| `sharing.rs` | `rel_path_for_uid` | a share of a node in a sync folder could not be named |
|
||||||
|
| `drain.rs` | `node_place` | fell through to a DB lookup for fork-resident nodes |
|
||||||
|
| `lib.rs` | `queue_trash`, `rename`, `remove_replaced` (×2), `drop_local`, `restore` | uid-keyed `forget`/invalidate |
|
||||||
|
|
||||||
|
Deliberately left on `self.state`: `upload.rs` (`parent_ino`/`pino` are inode
|
||||||
|
numbers), the size-upgrade path in `lib.rs`, and `drain.rs`'s `root_uid`, which
|
||||||
|
is correctly primary-only. The 38 `filesystem.rs` sites are FUSE callbacks —
|
||||||
|
they already run on the right mount's session thread. `parent_is_gone` never
|
||||||
|
touches state at all; an earlier note here saying it did was wrong.
|
||||||
|
|
||||||
|
The registry is now a `StateRegistry` type with a unit test
|
||||||
|
(`state_registry_walks_every_live_mount_and_reaps_the_rest`) covering the case
|
||||||
|
this bug was: a fork that fails to appear in the walk, or one that lingers after
|
||||||
|
unmount.
|
||||||
|
|
||||||
|
**One regression came out of the audit, and is fixed.** Broadcasting
|
||||||
|
`apply_event`'s parent-listing invalidation meant forks started receiving
|
||||||
|
invalidations they had never received — and it invalidated a folder once per
|
||||||
|
event, while a burst of our own uploads produces one event per file all naming
|
||||||
|
the *same* folder. Every following lookup in that folder then re-enumerated it
|
||||||
|
from the API: quadratic in the folder's size.
|
||||||
|
|
||||||
|
Two mitigations, both in `background.rs`. A batch is collapsed to one
|
||||||
|
invalidation per folder — `apply_event` records the parent in `DirtyParents` and
|
||||||
|
`flush_dirty_parents` publishes it once after the batch loop (every `break` path
|
||||||
|
falls through to it). And a change the daemon made *itself* is recognised as its
|
||||||
|
own echo rather than treated as foreign: the drain records it
|
||||||
|
(`Core::note_self_change`, consumed once within `SELF_CHANGE_TTL_MS`), which
|
||||||
|
stops the feed's report of our own upload from evicting the content blob we just
|
||||||
|
sent. Neither weakens the result — the state after a batch is what it always was.
|
||||||
|
|
||||||
|
**The hang that showed up alongside this was a different bug, and this entry
|
||||||
|
originally misattributed it.** `readdir stability` at 133.94 s and a concurrency
|
||||||
|
case that blew past its timeout were blamed here on the invalidation storm. They
|
||||||
|
were not: the mount was freezing because network work runs on fuser's single
|
||||||
|
dispatch thread, which is B75. Fixing that took the concurrency case from two
|
||||||
|
timeouts in three runs to 16.9 / 24.1 / 29.1 s and `readdir stability` to 62.9 s.
|
||||||
|
The coalescing above is still worth having — it is a real amplification — but it
|
||||||
|
was never the stall.
|
||||||
|
|
||||||
|
**Note on B70:** B70's own fix was never implicated. The `.crdownload` park/un-park
|
||||||
|
behaves correctly in isolation; the empty read arrived with the rename, on the
|
||||||
|
same path a plain temp file takes. B70's live validation was blocked on this and
|
||||||
|
is now unblocked — its acceptance case passes.
|
||||||
|
|
||||||
|
## B75 — Network work on fuser's single dispatch thread freezes the whole mount
|
||||||
|
|
||||||
|
**Status:** Fixed in-tree 2026-07-26 — live-validated. Found 2026-07-26 while
|
||||||
|
chasing what was thought to be a B74 regression.
|
||||||
|
**Impact:** any burst of concurrent writes froze the entire mount — not the
|
||||||
|
files being written, the *mount*. `ls` on an unrelated directory of it timed out
|
||||||
|
for minutes. The daemon itself stayed healthy throughout: the primary mount and
|
||||||
|
the control socket answered normally while a sync-folder mount was unreachable.
|
||||||
|
|
||||||
|
**Root cause.** `mount.rs` and `devices.rs` both build their session from
|
||||||
|
`Config::default()`, which leaves `n_threads` unset, and fuser 0.17 defaults it
|
||||||
|
to 1 (`session.rs:254`). One dispatch thread per mount reads a request, runs the
|
||||||
|
handler to completion, and only then reads the next. Six handlers were handed to
|
||||||
|
the [`Workers`] pool — `lookup`, `getattr`, `readdir`, `read`, `getxattr` and the
|
||||||
|
slow `serve_lookup` — and everything else ran inline, including handlers that
|
||||||
|
block on the API:
|
||||||
|
|
||||||
|
| handler | blocking work |
|
||||||
|
|---|---|
|
||||||
|
| `create` | `upload_file` to mint the node, then `fetch_node` — two round trips |
|
||||||
|
| `mkdir` | `create_folder`, then `fetch_node` |
|
||||||
|
| `rename` | `rename_node`, then `move_node`, plus a trash on the replaced-destination path |
|
||||||
|
| `unlink` / `rmdir` | `trash_nodes`; `rmdir` enumerates the folder first |
|
||||||
|
| `setattr` (path truncate) | `queue_truncate` → `fill_gaps`, which reads the kept prefix from the remote |
|
||||||
|
|
||||||
|
So eight applications each creating a file served one create at a time, and
|
||||||
|
every `read`, `open` and `lookup` on that mount queued behind them.
|
||||||
|
|
||||||
|
**Measured**, acceptance suite's `independent and shared-file concurrency` (8
|
||||||
|
threads × 16 files), and a 3-second `ls` of the mount root sampled every 4 s
|
||||||
|
throughout:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| concurrency case | 200 s watchdog ×2, 37 s ×1 | 16.9 / 24.1 / 29.1 s, all pass |
|
||||||
|
| `ls` of the mount during the run | 33 of 55 probes timed out | 0 of 85 |
|
||||||
|
| `readdir stability` | 148.8 s | 62.9 s |
|
||||||
|
| `namespace operations` | 31.3 s | 15.7 s |
|
||||||
|
|
||||||
|
**Fix:** each of those handlers now parses its arguments inline — cheap, and it
|
||||||
|
keeps the error paths prompt — and hands the body to the worker pool as a
|
||||||
|
`serve_*` method, the pattern `lookup`/`readdir` already used. Metadata work
|
||||||
|
takes `Lane::Meta`; the truncate path takes `Lane::Transfer` because it can pull
|
||||||
|
a whole file.
|
||||||
|
|
||||||
|
**`release` was deliberately left inline, and this is load-bearing.** Moving it
|
||||||
|
too made all three concurrency runs fail with `concurrent file 1 mismatch`. The
|
||||||
|
kernel does not wait for a `release` reply before letting `close(2)` return, so
|
||||||
|
the only thing sequencing the staging of the written bytes against the `open` +
|
||||||
|
`read` an application issues immediately afterwards is that both are served by
|
||||||
|
the same dispatch loop. From a worker, the read overtakes the staging and is
|
||||||
|
answered from the remote's older revision. Taking `release` off the loop — worth
|
||||||
|
doing, since `queue_revision` gap-fills a partial write from the network — needs
|
||||||
|
a per-node "staging in flight" barrier that reads wait on, not the dispatch
|
||||||
|
loop's accidental ordering.
|
||||||
|
|
||||||
|
**Not fixed here.** `n_threads` is still 1; with the blocking handlers moved off,
|
||||||
|
the loop is short enough that raising it is a separate question. Related findings
|
||||||
|
from the same audit, still open: cache/reader validity is keyed on
|
||||||
|
`(mtime, size)` while the revision identity is `active_revision_id`
|
||||||
|
(`cache.rs:84` — two same-size revisions within one mtime second collide, which
|
||||||
|
is what a SQLite page rewrite looks like); `local_finish_scan` holds the daemon's
|
||||||
|
one SQLite connection across a full FTS5 trigram rebuild (`db/local.rs:79`);
|
||||||
|
`earliest_due_at` has no `<= now` filter where `next_due_op` does, so the drain
|
||||||
|
busy-spins when offline with a due op (`db/ops.rs:385` vs `:421`); and
|
||||||
|
`drain_local_node` deletes its op before the fallible `adopt_real_uid`
|
||||||
|
(`drain.rs:346`).
|
||||||
+4
-1
@@ -58,7 +58,9 @@ Derived from: [`BUGS.md`](BUGS.md)
|
|||||||
* **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains.
|
* **B56–B60 — fail-closed state and IPC fixes**: Future schemas, event cursor advancement, socket limits, private-directory validation, and atomic config saves are implemented. Malformed-schema, database-full, connection-flood, wrong-owner, and concurrent/faulted-save verification remains.
|
||||||
* **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart.
|
* **B61 — credential rotation persistence**: Surface and retry keyring persistence failures so a rotated single-use refresh token cannot be lost on restart.
|
||||||
* **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain.
|
* **B62–B65 — release assurance**: Clean-install package verification, complete version/protocol identity, recovery sign-off, advisory/license policy, SBOM, provenance, checksums, and signatures remain.
|
||||||
* **B66–B68 — lifecycle and recovery**: Add queue flush/shutdown protection, explicit device adoption during fresh-state restore, and move profile backup below a writable remote folder.
|
* **B66–B67 — lifecycle and recovery**: Add queue flush/shutdown protection and explicit device adoption during fresh-state restore.
|
||||||
|
* **B68/B78 — profile backup**: the destination is fixed and verified live — `profile.json` now lives in `.proton-drive-linux/` under the device root, since the API refuses file creation at the root itself. What remains from B68 is making backup *health* visible (today a failure is a `WARN` in the log and nothing in the UI) and re-running the replacement-machine restore end to end.
|
||||||
|
* **B79 — cross-mount permission classification**: fixed and verified live; the standing guard is the `regression B79` acceptance case. The second-account cases it neighbours (`regression B34`) still cannot run — see below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,6 +78,7 @@ Derived from: [`TESTING.md`](TESTING.md) and [`RECOVERY.md`](RECOVERY.md)
|
|||||||
3. Wipe the local state directory and SQLite cache.
|
3. Wipe the local state directory and SQLite cache.
|
||||||
4. Restart the daemon.
|
4. Restart the daemon.
|
||||||
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
5. Run `pdfs sync restore` and assert the recovered filesystem matches the source byte-for-byte.
|
||||||
|
* **Second-account sharing run (mount-architecture.md P7)**: The only outstanding verification for the mount/sharing work. One account covers `context_share_id`, the shared-with-me listing, sharing from device folders and the Locations page — all confirmed. A **second account** is required for the shared-tree rendering under a foreign volume, `accept_invitation`, `update_member_role`, the `regression B34` viewer cases (which skip cleanly today), and above all an **editor-role write into a foreign share**, the only thing that validates `membership_address_for`.
|
||||||
* **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions.
|
* **Managed live mode matrix**: Before release, run `scripts/fuse-acceptance.sh --managed-live <empty-a> <empty-b>` with `PDFS_ACCEPTANCE_ONLY` unset. It validates on-demand/on-demand, on-demand/mirror, mirror/on-demand, and mirror/mirror transitions.
|
||||||
* **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives.
|
* **Crash-consistency matrix**: Inject failure after scratch/sidecar sync, each staging rename, directory sync, pending-op insert/commit, and source deletion; prove that a complete discoverable copy and either the old or new queue record always survives.
|
||||||
|
|
||||||
|
|||||||
+124
-3
@@ -14,9 +14,12 @@ cargo clippy --workspace --all-targets -- -D warnings
|
|||||||
The account-free suite exercises the filesystem syscall contract against a
|
The account-free suite exercises the filesystem syscall contract against a
|
||||||
temporary local directory. This validates the runner and covers creation/open
|
temporary local directory. This validates the runner and covers creation/open
|
||||||
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
flags, positioned and vectored I/O, truncation and sparse files, `mmap`,
|
||||||
`sendfile`, durability barriers, namespace/error semantics, open-file lifetime,
|
`sendfile`, `copy_file_range`, durability barriers, namespace/error semantics,
|
||||||
names and enumeration, and concurrent I/O. It never reads application state or
|
`renameat2` flags, open-file lifetime, names and enumeration, readdir stability
|
||||||
contacts Proton:
|
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
|
```console
|
||||||
scripts/fuse-acceptance.sh --offline-only
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
@@ -34,6 +37,110 @@ Use a dedicated test account without irreplaceable data:
|
|||||||
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
scripts/fuse-acceptance.sh --live /mnt/on-demand-testmount
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The mount is diffed against an ordinary filesystem
|
||||||
|
|
||||||
|
The local reference run is not only a self-test of the runner. Every case
|
||||||
|
records the facts it established, and a live run is compared against those
|
||||||
|
recordings, so a case fails when the mount *differs* from an ordinary
|
||||||
|
filesystem — not only when it raises. The report names the exact key:
|
||||||
|
|
||||||
|
```
|
||||||
|
[DIVERGENCE] live FUSE /mnt/testmount does not match the reference filesystem:
|
||||||
|
! creation and open flags.truncated.size: live=6 reference=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Facts are recorded in one of two ways, chosen in the test body:
|
||||||
|
|
||||||
|
- **compared** — a claim about filesystem semantics. The mount must agree.
|
||||||
|
- **noted** — context that legitimately differs: inode numbers, block counts,
|
||||||
|
reported mode bits, and which optional syscalls exist at all. Reported under
|
||||||
|
`[capabilities]`, never a failure.
|
||||||
|
|
||||||
|
Because the choice is made at each call site, the set of accepted divergences
|
||||||
|
is visible in the test that accepts it. There is no separate allowlist to drift
|
||||||
|
out of date. When adding a case, prefer `record`; reach for `note` only when an
|
||||||
|
ordinary filesystem can do something a network filesystem genuinely cannot.
|
||||||
|
|
||||||
|
### Operations the filesystem does not implement
|
||||||
|
|
||||||
|
`git`, `rsync`, `cp -a`, `tar`, `df` and most installers probe for symlinks,
|
||||||
|
hard links, FIFOs, device nodes, `statfs`, file locks, `copy_file_range`,
|
||||||
|
`O_DIRECT` and `SEEK_HOLE` whether or not a filesystem offers them. The suite
|
||||||
|
asserts that each either works or is *refused cleanly* — a recognised errno,
|
||||||
|
never `EIO` and never a hang. A refusal that is merely unimplemented is fine;
|
||||||
|
one that is dirty breaks software that never asked for the feature.
|
||||||
|
|
||||||
|
### Regressions from the bug ledger
|
||||||
|
|
||||||
|
Cases named `regression B<n>` reproduce a bug from `docs/BUGS.md` so it stays
|
||||||
|
fixed. B69 (identical rewrite must not fork a `(sync-conflict …)` copy), B70 (an
|
||||||
|
in-flight `.crdownload` must not be sealed as a revision) and B74 (a file
|
||||||
|
renamed after close must keep its content) need a real mount and a reachable
|
||||||
|
daemon; they are skipped, not failed, without one.
|
||||||
|
|
||||||
|
B74 is currently **failing** — it is a live data-loss defect, not a regression
|
||||||
|
guard, and it was found by exactly this mechanism: the B70 case failed, and
|
||||||
|
narrowing it produced a smaller reproduction that has its own case now. Expect a
|
||||||
|
red run against a real mount until it is fixed.
|
||||||
|
|
||||||
|
**B79** (an on-demand device folder accepts writes) discovers its target from
|
||||||
|
`pdfs locations --json` and skips when this machine has no mounted on-demand
|
||||||
|
folder. It is the cheap standing guard against a *cross-mount* permission
|
||||||
|
regression: the primary mount holds device-folder nodes too, and the queue guard
|
||||||
|
intersects what every live inode space says about a uid, so a fail-closed
|
||||||
|
classification in one mount denies writes in another.
|
||||||
|
|
||||||
|
**B34** (a viewer-role share is read-only) and **B34b** (an editor-role share
|
||||||
|
still writes) locate their subject through `pdfs shared-with-me --json`, which
|
||||||
|
now carries `role` and a mount-relative `path`. Both skip when the account has no
|
||||||
|
accepted share at that role — B34 needs a **second account** to have shared a
|
||||||
|
folder read-only, which is the one part of the sharing suite that cannot be
|
||||||
|
self-served. B34b degrades gracefully in the other direction: a shared *file*
|
||||||
|
gets its mode bits and `W_OK` checked but is never written to, because it is
|
||||||
|
someone else's document.
|
||||||
|
|
||||||
|
B34's fourth assertion is the load-bearing one: `pending_uploads` and
|
||||||
|
`pending_changes` must be **unchanged** across the refused writes. Mode bits
|
||||||
|
alone would pass a fix that still admitted the write into `pending_op`, which is
|
||||||
|
the actual harm — a background drain failing 403 forever.
|
||||||
|
|
||||||
|
**Synthetic `Shared with me/` directory contract** asserts the virtual directory
|
||||||
|
enumerates, reports `0555`, and refuses `mkdir`/`rmdir`/`rename`. It skips when
|
||||||
|
the directory is absent (an account with no accepted shares).
|
||||||
|
|
||||||
|
A regression case asserts against the *remote* outcome, so it must wait for the
|
||||||
|
queue to drain before reading. Reading straight back through the mount proves
|
||||||
|
nothing: the local attribute and content caches will happily return the bytes
|
||||||
|
that were just written even when nothing reached Drive, which is precisely how
|
||||||
|
B74 stayed invisible.
|
||||||
|
|
||||||
|
### Timeouts, reports, and hung mounts
|
||||||
|
|
||||||
|
A wedged FUSE operation cannot be interrupted from Python, so each case runs
|
||||||
|
under a soft alarm (`--timeout`, default 180s) that reports the case and lets
|
||||||
|
cleanup run, backed by a hard stop that dumps every thread's stack and exits.
|
||||||
|
That backstop is the only way to learn *where* a mount hung. A timeout ends the
|
||||||
|
run: later cases against a wedged mount produce noise, not information.
|
||||||
|
|
||||||
|
```console
|
||||||
|
scripts/fuse-acceptance.sh --live /mnt/testmount \
|
||||||
|
--timeout 300 --report-junit results.xml --budget 30
|
||||||
|
```
|
||||||
|
|
||||||
|
`--report-json` also writes every recorded observation, which is what to attach
|
||||||
|
to a bug report. `--budget SECONDS` flags cases that pass but got slower —
|
||||||
|
B5 was a 186 ms-per-call regression that a pass/fail suite could not see.
|
||||||
|
`--list` prints every case and which targets it runs against, and `--fail-fast`
|
||||||
|
stops at the first failure.
|
||||||
|
|
||||||
|
`--journal-check` fails the run if `proton-drive.service` logged any error while
|
||||||
|
it ran. A suite can pass every assertion while the daemon logs failures behind
|
||||||
|
it; that has happened, and it was caught only by reading the journal by hand.
|
||||||
|
|
||||||
|
Abandoned `pdfs-acceptance-*` roots from a crashed run are removed at startup,
|
||||||
|
but only if they match the exact generated name and are over an hour old
|
||||||
|
(`PDFS_ACCEPTANCE_REAP_AGE`), so a concurrent run is never disturbed.
|
||||||
|
|
||||||
### Automated mode matrix
|
### Automated mode matrix
|
||||||
|
|
||||||
The managed live runner accepts two existing, empty, unmounted directories. It
|
The managed live runner accepts two existing, empty, unmounted directories. It
|
||||||
@@ -53,10 +160,24 @@ 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
|
timeouts and `PDFS_ACCEPTANCE_PDFS` to select a non-installed CLI binary, for
|
||||||
example `target/debug/pdfs`.
|
example `target/debug/pdfs`.
|
||||||
|
|
||||||
|
### Durability drill
|
||||||
|
|
||||||
|
`--durability` restarts `proton-drive.service` in the middle of the suite and
|
||||||
|
re-verifies the bytes written before it. `staging/` and `recovery/` can hold the
|
||||||
|
only copy of a file whose upload has not finished, so this is the case that
|
||||||
|
proves the queue is persistent rather than in-memory. It is opt-in because it
|
||||||
|
interrupts a running daemon; `PDFS_ACCEPTANCE_UNIT` selects a different unit.
|
||||||
|
|
||||||
|
### Selecting cases
|
||||||
|
|
||||||
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
During development, `PDFS_ACCEPTANCE_ONLY` runs tests whose descriptive name
|
||||||
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
contains the supplied text (for example `PDFS_ACCEPTANCE_ONLY=namespace`). A
|
||||||
release acceptance run must leave it unset so the complete contract executes.
|
release acceptance run must leave it unset so the complete contract executes.
|
||||||
|
|
||||||
|
A filter naming a live-only case (every `regression B<n>` is one) selects nothing
|
||||||
|
in the account-free reference run, which is not an error — that target simply has
|
||||||
|
nothing to do. Only a filter matching *no case at all* fails, as the typo it is.
|
||||||
|
|
||||||
Passing more paths runs the same filesystem suite independently against each.
|
Passing more paths runs the same filesystem suite independently against each.
|
||||||
The first path must be the FUSE mount under test; later paths may be another
|
The first path must be the FUSE mount under test; later paths may be another
|
||||||
FUSE mount or a normal local mirror filesystem:
|
FUSE mount or a normal local mirror filesystem:
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
# Build + install: cd packaging && makepkg -fi
|
# Build + install: cd packaging && makepkg -fi
|
||||||
|
|
||||||
pkgname=proton-drive-linux
|
pkgname=proton-drive-linux
|
||||||
pkgver=1.0.0
|
pkgver=1.3.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
+1652
-65
File diff suppressed because it is too large.
Load diff
+51
-16
@@ -4,44 +4,79 @@ set -Eeuo pipefail
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat >&2 <<'EOF'
|
cat >&2 <<'EOF'
|
||||||
usage: scripts/fuse-acceptance.sh [--offline-only]
|
usage: scripts/fuse-acceptance.sh [--offline-only] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...]
|
scripts/fuse-acceptance.sh --live MOUNTPOINT [MOUNTPOINT ...] [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR
|
scripts/fuse-acceptance.sh --managed-live EMPTY_DIR EMPTY_DIR [OPTION ...]
|
||||||
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
scripts/fuse-acceptance.sh MOUNTPOINT [MOUNTPOINT ...] # compatibility
|
||||||
|
|
||||||
The local reference suite always runs first and requires no account. --live then
|
The local reference suite always runs first and requires no account; the mount
|
||||||
runs the same contract on each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only
|
is then diffed against its recorded behaviour. --live runs the same contract on
|
||||||
when all live mountpoints show the same remote folder.
|
each mount. Set PDFS_ACCEPTANCE_CONVERGENCE=1 only when all live mountpoints
|
||||||
|
show the same remote folder.
|
||||||
|
|
||||||
|
Options are forwarded to the Python runner. The useful ones:
|
||||||
|
--list print every case and the targets it runs against
|
||||||
|
--timeout SECONDS per-case limit (default 180); a hung case dumps stacks
|
||||||
|
--fail-fast stop at the first failure instead of continuing
|
||||||
|
--report-json PATH machine-readable results and recorded observations
|
||||||
|
--report-junit PATH JUnit XML for CI
|
||||||
|
--budget SECONDS report any case slower than this
|
||||||
|
--journal-check fail if the daemon logs errors during the run
|
||||||
|
--durability restart the daemon mid-suite and re-verify bytes
|
||||||
EOF
|
EOF
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 2; }
|
||||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
runner=(python3 -u "$script_dir/fuse-acceptance.py")
|
||||||
|
|
||||||
|
mountpoints=()
|
||||||
|
passthrough=()
|
||||||
|
# Paths come first and options after them, so the first dash-prefixed argument
|
||||||
|
# ends the path list and everything from there is forwarded untouched.
|
||||||
|
collect_paths() {
|
||||||
|
while (( $# > 0 )) && [[ "$1" != -* ]]; do
|
||||||
|
mountpoints+=("$1")
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
passthrough=("$@")
|
||||||
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
-h|--help) usage ;;
|
-h|--help) usage ;;
|
||||||
--offline-only|"")
|
--offline-only|"")
|
||||||
(( $# <= 1 )) || usage
|
shift || true
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py"
|
exec "${runner[@]}" "$@"
|
||||||
|
;;
|
||||||
|
--live)
|
||||||
|
shift
|
||||||
|
collect_paths "$@"
|
||||||
|
(( ${#mountpoints[@]} > 0 )) || usage
|
||||||
;;
|
;;
|
||||||
--live) shift ; (( $# > 0 )) || usage ;;
|
|
||||||
--managed-live)
|
--managed-live)
|
||||||
shift
|
shift
|
||||||
(( $# == 2 )) || usage
|
(( $# >= 2 )) || usage
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --managed-live "$@"
|
first="$1"; second="$2"; shift 2
|
||||||
|
exec "${runner[@]}" --managed-live "$first" "$second" "$@"
|
||||||
|
;;
|
||||||
|
--*)
|
||||||
|
# Bare options with no mode: an offline run with those options.
|
||||||
|
exec "${runner[@]}" "$@"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
collect_paths "$@"
|
||||||
;;
|
;;
|
||||||
--*) usage ;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
command -v findmnt >/dev/null || { echo "findmnt is required for --live" >&2; exit 2; }
|
||||||
for mountpoint in "$@"; do
|
for mountpoint in "${mountpoints[@]}"; do
|
||||||
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
[[ -d "$mountpoint" ]] || { echo "not a directory: $mountpoint" >&2; exit 2; }
|
||||||
done
|
done
|
||||||
fstype="$(findmnt -T "$1" -n -o FSTYPE | head -n 1)"
|
fstype="$(findmnt -T "${mountpoints[0]}" -n -o FSTYPE | head -n 1)"
|
||||||
[[ "$fstype" == fuse* ]] || {
|
[[ "$fstype" == fuse* ]] || {
|
||||||
echo "refusing primary non-FUSE path $1 (type: ${fstype:-unknown})" >&2
|
echo "refusing primary non-FUSE path ${mountpoints[0]} (type: ${fstype:-unknown})" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
exec python3 -u "$script_dir/fuse-acceptance.py" --live "$@"
|
exec "${runner[@]}" --live "${mountpoints[@]}" "${passthrough[@]}"
|
||||||
Reference in new issue
Block a user