Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61266fc9b1 | ||
|
|
d18655538e | ||
|
|
da7ea0ec56 | ||
|
|
127b71efd0 | ||
|
|
2339b17d97 | ||
|
|
bd4f797d1f | ||
|
|
e872cad058 | ||
|
|
081c8fb889 | ||
|
|
da35a05b84 | ||
|
|
a90eeecda9 | ||
|
|
c98ed0d83b | ||
|
|
c46b2c1082 | ||
|
|
fde9c2d271 | ||
|
|
4fd14eb745 | ||
|
|
b3c03a7162 | ||
|
|
8749d6c2c6 | ||
|
|
4ee2aed29b | ||
|
|
753056418c | ||
|
|
2757977fd6 | ||
|
|
2f14c1688f | ||
|
|
7dbf818d83 | ||
|
|
c3ec82392a | ||
|
|
ce4656e46a | ||
|
|
557eeb5f1d | ||
|
|
b47e6c91a0 | ||
|
|
5c1da9d7a5 | ||
|
|
0135c29a4f | ||
|
|
0b81b76cb7 | ||
|
|
7470bd89f3 | ||
|
|
e6cb52339d | ||
|
|
912acc760a | ||
|
|
5109427c32 | ||
|
|
e2c4dac8df | ||
|
|
ebeb9cdbef | ||
|
|
82e96543ac | ||
|
|
96c77e96cb | ||
|
|
90e110e893 | ||
|
|
0b7236bd4a | ||
|
|
2fa5eaa75b | ||
|
|
dc9ea9f541 | ||
|
|
b13d9d9f38 | ||
|
|
b696adefc2 | ||
|
|
519ec1af07 | ||
|
|
4820ea1236 | ||
|
|
a9d6e0280d | ||
|
|
3e4c756361 | ||
|
|
38126ff2b3 | ||
|
|
9529f2124b | ||
|
|
77b8fd65be | ||
|
|
d9064ef880 | ||
|
|
66ab1f5b16 | ||
|
|
56161f649e | ||
|
|
69b5ccb28b | ||
|
|
af84ce2168 | ||
|
|
52651caf8e | ||
|
|
0b63a65661 | ||
|
|
7f84ff7dea | ||
|
|
15985f8684 | ||
|
|
dfc48375e2 | ||
|
|
2dde69525e | ||
|
|
f6136ce344 | ||
|
|
4fa61a14c3 | ||
|
|
fe8fb8cdcf | ||
|
|
f457fa68fc | ||
|
|
be8464f4a0 | ||
|
|
a5d29969dc | ||
|
|
43d1afe705 | ||
|
|
29e5769dec | ||
|
|
0fbc6421ba | ||
|
|
abf76cd5e8 | ||
|
|
8d0a575a7c | ||
|
|
91a5f21340 | ||
|
|
8351382ef2 | ||
|
|
b58b60010e | ||
|
|
a86c3e586d | ||
|
|
fdda35918f | ||
|
|
8bc52edeb4 | ||
|
|
792eee75a4 | ||
|
|
067561564e | ||
|
|
cf9e1665ca | ||
|
|
cc308ac25a | ||
|
|
690b91deab | ||
|
|
9cd8a196aa | ||
|
|
860d29cfe8 | ||
|
|
6cc8f72bab | ||
|
|
376f3edb28 | ||
|
|
09c0684a5e | ||
|
|
c1df3b6cc2 | ||
|
|
afd71713d5 | ||
|
|
c6bdba024f | ||
|
|
5a8e73657c | ||
|
|
cf0d495f8c | ||
|
|
bee15ee7cf | ||
|
|
f24ac8a429 | ||
|
|
f78f663740 | ||
|
|
470948c191 | ||
|
|
b095b9f6b0 | ||
|
|
f4d6580cd3 | ||
|
|
b2f62f70e1 | ||
|
|
dcb2ab7fac | ||
|
|
eb56f4a1e0 | ||
|
|
c28803b037 | ||
|
|
cd8474954b | ||
|
|
d7e07d042d | ||
|
|
45852082c0 | ||
|
|
99ad3838e1 | ||
|
|
637b512f6a | ||
|
|
16ad6548c8 | ||
|
|
847ece17d7 | ||
|
|
50d2acdaba | ||
|
|
53caf63ad4 | ||
|
|
5176dd91e1 | ||
|
|
e88903d5ff | ||
|
|
b3e6af1ae5 | ||
|
|
b0f9513915 | ||
|
|
91b0864fce | ||
|
|
8bd9481902 | ||
|
|
b2d5870834 | ||
|
|
078774f6a1 | ||
|
|
326a9b4e92 | ||
|
|
78dc72a34d | ||
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 | ||
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in the client misbehaves
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
|
||||||
|
tracked there with context.
|
||||||
|
|
||||||
|
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Distribution and desktop
|
||||||
|
placeholder: "Fedora 44, GNOME 48 (Wayland)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where does it happen
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount / file operations
|
||||||
|
- Sync or backed-up folders (Computers)
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Login / keyring
|
||||||
|
- Photos
|
||||||
|
- Packaging / installation
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: diagnose
|
||||||
|
attributes:
|
||||||
|
label: "`pdfs diagnose` output"
|
||||||
|
description: Redact paths or the account address if you prefer.
|
||||||
|
render: console
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Daemon logs
|
||||||
|
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
|
||||||
|
render: console
|
||||||
|
- type: checkboxes
|
||||||
|
id: dataloss
|
||||||
|
attributes:
|
||||||
|
label: Data
|
||||||
|
options:
|
||||||
|
- label: This bug involved missing, truncated, or overwritten file content.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||||
|
about: Report privately — never in a public issue.
|
||||||
|
- name: Known issues
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||||
|
about: The authoritative ledger of tracked bugs and their status.
|
||||||
|
- name: Proton Drive service problems
|
||||||
|
url: https://proton.me/support
|
||||||
|
about: This is an unofficial client. Account or service issues go to Proton AG.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a capability or an improvement
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that the client makes hard or impossible today?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What you'd like to see
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Which part of the client
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount
|
||||||
|
- Sync / Computers
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Photos
|
||||||
|
- Packaging
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you considered
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- The user-visible effect, in a sentence or two. -->
|
||||||
|
|
||||||
|
**Crates touched:**
|
||||||
|
**Related `docs/BUGS.md` entries:**
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
|
||||||
|
|
||||||
|
## Implications
|
||||||
|
|
||||||
|
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
|
||||||
|
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||||
|
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||||
|
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||||
|
- [ ] None of the above
|
||||||
|
|
||||||
|
<!-- Screenshots for GTK changes. -->
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: AUR
|
||||||
|
|
||||||
|
on:
|
||||||
|
# The -git package computes its pkgver from git at build time, so a new commit
|
||||||
|
# upstream needs no AUR push. Only a changed -git recipe is published.
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packaging/aur/proton-drive-for-linux-git/**'
|
||||||
|
# Called by release.yml once the GitHub release and its assets exist.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
secrets:
|
||||||
|
AUR_SSH_PRIVATE_KEY:
|
||||||
|
required: true
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release to publish, such as 2.1.0. Leave empty to publish only the -git package.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: aur
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Publish AUR packages
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
container: archlinux:base-devel
|
||||||
|
env:
|
||||||
|
# makepkg refuses to run as root, so the packaging steps run as `builder`.
|
||||||
|
HOME: /home/builder
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install packaging tools
|
||||||
|
run: pacman -Syu --noconfirm --needed git openssh pacman-contrib
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
# The updated recipes are committed back to main, not to the tag.
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Create build user
|
||||||
|
run: |
|
||||||
|
useradd --create-home builder
|
||||||
|
chown -R builder: "$GITHUB_WORKSPACE"
|
||||||
|
|
||||||
|
- name: Configure AUR access
|
||||||
|
env:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
run: |
|
||||||
|
install -d -m700 -o builder -g builder "$HOME/.ssh"
|
||||||
|
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur"
|
||||||
|
cat > "$HOME/.ssh/config" <<'EOF'
|
||||||
|
Host aur.archlinux.org
|
||||||
|
IdentityFile ~/.ssh/aur
|
||||||
|
User aur
|
||||||
|
EOF
|
||||||
|
# Pin the host key to the fingerprint published on aur.archlinux.org.
|
||||||
|
ssh-keyscan -t ed25519 aur.archlinux.org > "$HOME/.ssh/known_hosts"
|
||||||
|
ssh-keygen -lf "$HOME/.ssh/known_hosts" | grep -q 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4'
|
||||||
|
chown -R builder: "$HOME/.ssh"
|
||||||
|
chmod 600 "$HOME/.ssh/aur"
|
||||||
|
runuser -u builder -- git config --global user.name 'Nils Pukropp'
|
||||||
|
runuser -u builder -- git config --global user.email 'contact@narl.io'
|
||||||
|
|
||||||
|
- name: Update release packages
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: runuser -u builder -- packaging/aur/update.sh "${VERSION#v}"
|
||||||
|
|
||||||
|
- name: Refresh the -git metadata
|
||||||
|
if: env.VERSION == ''
|
||||||
|
working-directory: packaging/aur/proton-drive-for-linux-git
|
||||||
|
run: runuser -u builder -- sh -c 'makepkg --printsrcinfo > .SRCINFO'
|
||||||
|
|
||||||
|
- name: Publish to the AUR
|
||||||
|
run: |
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
runuser -u builder -- packaging/aur/push.sh
|
||||||
|
else
|
||||||
|
runuser -u builder -- packaging/aur/push.sh proton-drive-for-linux-git
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit the updated recipes to main
|
||||||
|
if: env.VERSION != ''
|
||||||
|
run: |
|
||||||
|
runuser -u builder -- git add packaging/aur
|
||||||
|
if ! runuser -u builder -- git diff --cached --quiet; then
|
||||||
|
runuser -u builder -- git commit -m "chore(aur): update packages to ${VERSION#v}"
|
||||||
|
runuser -u builder -- git push origin HEAD:main
|
||||||
|
fi
|
||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install native dependencies
|
- name: Install native dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -31,6 +31,7 @@ jobs:
|
|||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev \
|
libdbus-1-dev \
|
||||||
desktop-file-utils \
|
desktop-file-utils \
|
||||||
|
gettext \
|
||||||
rpm
|
rpm
|
||||||
|
|
||||||
- name: Set up Rust
|
- name: Set up Rust
|
||||||
@@ -84,6 +85,7 @@ jobs:
|
|||||||
cp target/release/pdfs-tray dist/binaries/
|
cp target/release/pdfs-tray dist/binaries/
|
||||||
cp target/release/pdfs-app dist/binaries/
|
cp target/release/pdfs-app dist/binaries/
|
||||||
cp target/release/pdfs-prompt dist/binaries/
|
cp target/release/pdfs-prompt dist/binaries/
|
||||||
|
po/build.sh dist/binaries/locale
|
||||||
|
|
||||||
# Create tarball
|
# Create tarball
|
||||||
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
tar -czf proton-drive-linux-${{ env.VERSION }}-x86_64.tar.gz -C dist/binaries .
|
||||||
@@ -110,6 +112,7 @@ jobs:
|
|||||||
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
cp packaging/io.narl.proton-drive-linux.svg deb/usr/share/icons/hicolor/scalable/apps/
|
||||||
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
cp packaging/proton-drive.service deb/usr/lib/systemd/user/
|
||||||
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
cp LICENSE deb/usr/share/licenses/proton-drive-linux/
|
||||||
|
po/build.sh deb/usr/share/locale
|
||||||
|
|
||||||
# Calculate Installed-Size in KB for the control file
|
# Calculate Installed-Size in KB for the control file
|
||||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||||
@@ -122,13 +125,14 @@ jobs:
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Nils Pukropp <contact@narl.io>
|
Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
|
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||||
Installed-Size: ${INSTALLED_SIZE}
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
Description: Proton Drive client for Linux
|
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||||
Proton Drive client for Linux featuring:
|
Description: Unofficial Proton Drive client for Linux
|
||||||
- FUSE files-on-demand mount
|
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||||
- Command-line interface (CLI)
|
- FUSE virtual mount with block-level caching
|
||||||
- GTK4 system tray, desktop application, and search launcher
|
- Scriptable command-line interface (pdfs)
|
||||||
|
- GTK4 desktop application, system tray, and search launcher
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Build Debian package
|
# Build Debian package
|
||||||
@@ -177,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Upload Workflow Artifacts
|
- name: Upload Workflow Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: proton-drive-linux-builds
|
name: proton-drive-linux-builds
|
||||||
path: |
|
path: |
|
||||||
@@ -188,7 +192,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
*.deb
|
*.deb
|
||||||
@@ -198,3 +202,13 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
aur:
|
||||||
|
name: Publish AUR packages
|
||||||
|
needs: build-release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: ./.github/workflows/aur.yml
|
||||||
|
with:
|
||||||
|
version: ${{ github.ref_name }}
|
||||||
|
secrets:
|
||||||
|
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our pledge
|
||||||
|
|
||||||
|
We want participation in this project to be a harassment-free experience for everyone, regardless
|
||||||
|
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||||
|
level of experience, education, socioeconomic status, nationality, personal appearance, race,
|
||||||
|
religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Standards
|
||||||
|
|
||||||
|
Examples of behavior that helps:
|
||||||
|
|
||||||
|
- being respectful of differing viewpoints and experience levels,
|
||||||
|
- giving and accepting constructive feedback gracefully,
|
||||||
|
- focusing on what is best for the project and its users.
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
- sexualized language or imagery, and unwelcome sexual attention,
|
||||||
|
- trolling, insulting or derogatory comments, and personal or political attacks,
|
||||||
|
- public or private harassment,
|
||||||
|
- publishing others' private information without explicit permission.
|
||||||
|
|
||||||
|
## Scope and enforcement
|
||||||
|
|
||||||
|
This applies in all project spaces — issues, pull requests, discussions — and when representing the
|
||||||
|
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
|
||||||
|
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
|
||||||
|
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
|
||||||
|
|
||||||
|
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
|
||||||
|
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
|
||||||
|
deliberately high — everything else is ordinary Rust review.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||||
|
together.
|
||||||
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
|
already be tracked there with context.
|
||||||
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
|
spend the time.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
|
cd proton-drive-linux
|
||||||
|
cargo build --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||||
|
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
|
||||||
|
version is 1.96.
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
CI runs exactly these, and they must pass with zero warnings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||||
|
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||||
|
use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
|
## Making changes
|
||||||
|
|
||||||
|
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
|
||||||
|
they speak the control protocol to the daemon.
|
||||||
|
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
|
||||||
|
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
|
||||||
|
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
|
||||||
|
control-socket work stays off GTK's main thread.
|
||||||
|
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||||
|
them opportunistically.
|
||||||
|
|
||||||
|
## Commits and pull requests
|
||||||
|
|
||||||
|
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
|
||||||
|
`fix(fuse,core): keep truncate composable with queued revisions`.
|
||||||
|
|
||||||
|
A pull request should state:
|
||||||
|
|
||||||
|
- the user-visible effect and which crates it touches,
|
||||||
|
- the `docs/BUGS.md` entries it fixes or affects,
|
||||||
|
- the commands you ran to verify it,
|
||||||
|
- screenshots for GTK changes,
|
||||||
|
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
|
||||||
@@ -3,15 +3,20 @@ 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.6.0"
|
version = "2.5.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"
|
||||||
|
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
homepage = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||||
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.5.1" }
|
proton-sdk = { version = "0.7.0" }
|
||||||
proton-drive-rs = { version = "0.5.1" }
|
proton-drive-rs = { version = "0.7.0" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -20,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
base64 = "0.23"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
@@ -29,18 +35,28 @@ anyhow = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "6"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
|
# directories and streams entries out without extracting to disk first.
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
|
# import has to speak SHA-1 whatever its age.
|
||||||
|
sha1 = "0.11"
|
||||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
|
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||||
|
keyring-core = "1"
|
||||||
|
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
fuser = "0.17"
|
fuser = "0.18"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -1,22 +1,81 @@
|
|||||||
# Proton Drive client for Linux (unofficial)
|
<div align="center">
|
||||||
|
|
||||||
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
|
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||||
|
|
||||||
|
# Proton Drive for Linux
|
||||||
|
|
||||||
|
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
|
||||||
|
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
|
||||||
|
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](#prerequisites)
|
||||||
|
|
||||||
|
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||||
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
|
> to describe what the software talks to. Use a test account first, and read
|
||||||
|
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install ./proton-drive-linux-*.rpm
|
||||||
|
|
||||||
|
# Arch Linux (AUR)
|
||||||
|
paru -S proton-drive-for-linux # or -git / -bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Packages for each release are attached to the [latest
|
||||||
|
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
||||||
|
[Installation & Packages](#installation--packages) for details and
|
||||||
|
[Building from Source](#building-from-source) to compile it yourself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
||||||
|
pdfs status # mount state, queued writes, cache usage
|
||||||
|
pdfs-app # the desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
||||||
|
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
||||||
|
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
||||||
|
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
||||||
|
|
||||||
|
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
||||||
|
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) · [Translating](docs/TRANSLATING.md) ·
|
||||||
|
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
||||||
|
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, 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.
|
||||||
|
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
||||||
- **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.
|
- **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. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
||||||
- **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).
|
||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||||
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
- **Photo Favourites**: Star a photo in the lightbox, filter the gallery to favourites, or use `pdfs favorite <uid>` / `pdfs photos --favorites`.
|
||||||
- **Photo Albums**: Browse your albums — including the ones shared with you — from the Albums view of the Photos page, or with `pdfs albums` / `pdfs album <uid>`. Album contents open in the same gallery as the timeline.
|
- **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.
|
||||||
|
- **Places**: The Places view of the Photos page groups photos by the town they were taken in, using the location stored with each photo, as cards or on a world map. Towns are looked up offline in [GeoNames](https://www.geonames.org/) data (CC BY 4.0), and the map is drawn offline from [Natural Earth](https://www.naturalearthdata.com/) land outlines and country borders (public domain). Each town is marked with one of its photos, and zoomed in, every photo sits where it was taken. A street map from [OpenFreeMap](https://openfreemap.org/) (© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors) can be turned on under Appearance; it is off by default because the tile server learns which parts of the world you look at. Install `iso-codes` for country names in your language.
|
||||||
- **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.
|
||||||
@@ -66,6 +125,51 @@ launcher command, set them in `config.json`:
|
|||||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||||
own prompt flag is appended.
|
own prompt flag is appended.
|
||||||
|
|
||||||
|
### Live launcher search (fzf)
|
||||||
|
|
||||||
|
The two-step above exists because no dmenu-style launcher can call back for new
|
||||||
|
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
||||||
|
exactly like the built-in window:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs-prompt --fzf # pinned files, then live results as you type
|
||||||
|
pdfs-prompt --fzf --query invoice # start on a search
|
||||||
|
```
|
||||||
|
|
||||||
|
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
||||||
|
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
||||||
|
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
||||||
|
do. Enter opens; Escape closes.
|
||||||
|
|
||||||
|
fzf is a terminal program, so when it is started without one — from a WM
|
||||||
|
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
||||||
|
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
||||||
|
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
||||||
|
Hyprland:
|
||||||
|
|
||||||
|
```
|
||||||
|
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||||
|
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||||
|
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||||
|
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it as the default front end, and pick the terminal explicitly, in
|
||||||
|
`config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": {
|
||||||
|
"mode": "fzf",
|
||||||
|
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
||||||
|
"menu_limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
||||||
|
without one it is appended, which is what every detected terminal expects.
|
||||||
|
|
||||||
## Choosing How Files Open
|
## Choosing How Files Open
|
||||||
|
|
||||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||||
@@ -245,11 +349,15 @@ Version 1.0 strengthens the boundaries around local-only data and destructive re
|
|||||||
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
<td align="center" width="50%"><img src="images/shared_with_me.png" alt="Shared with me" width="100%"><br><sub><b>Shared with me</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/computers.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
<td align="center" width="50%"><img src="images/restore_devices.png" alt="Local Backups (Computers)" width="100%"><br><sub><b>Local Backups (Computers)</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
<td align="center" width="50%"><img src="images/photos.png" alt="Photos Timeline" width="100%"><br><sub><b>Photos Timeline</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" width="50%"><img src="images/prompt.png" alt="Search Launcher Prompt" width="100%"><br><sub><b>Search Launcher Prompt</b></sub></td>
|
<td align="center" width="50%"><img src="images/prompt_gtk.png" alt="Search Launcher Prompt (GTK)" width="100%"><br><sub><b>Search Launcher Prompt (GTK)</b></sub></td>
|
||||||
|
<td align="center" width="50%"><img src="images/prompt_fzf.png" alt="Search Launcher Prompt (fzf)" width="100%"><br><sub><b>Search Launcher Prompt (fzf)</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="50%"><img src="images/activity.png" alt="Activity" width="100%"><br><sub><b>Activity</b></sub></td>
|
||||||
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
<td align="center" width="50%"><img src="images/settings.png" alt="Settings" width="100%"><br><sub><b>Settings</b></sub></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -270,19 +378,21 @@ sudo apt-get install -y \
|
|||||||
libadwaita-1-dev \
|
libadwaita-1-dev \
|
||||||
libwebkitgtk-6.0-dev \
|
libwebkitgtk-6.0-dev \
|
||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev
|
libdbus-1-dev \
|
||||||
|
libimage-exiftool-perl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
|
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora (44+)
|
### Fedora (44+)
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install -y \
|
sudo dnf install -y \
|
||||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
||||||
|
perl-Image-ExifTool cargo rust
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime extras (pick your desktop):
|
Runtime extras (pick your desktop):
|
||||||
@@ -302,7 +412,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
|
|||||||
|
|
||||||
1. Clone the repository and navigate into the project directory:
|
1. Clone the repository and navigate into the project directory:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/narl/proton-drive-linux.git
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
cd proton-drive-linux
|
cd proton-drive-linux
|
||||||
```
|
```
|
||||||
2. Build the workspace in release mode:
|
2. Build the workspace in release mode:
|
||||||
@@ -326,8 +436,24 @@ Install the debian package via `dpkg` or `apt`:
|
|||||||
sudo apt install ./proton-drive-linux_*.deb
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Arch Linux
|
### 2. Arch Linux (AUR)
|
||||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
Three AUR packages cover the usual choices; they conflict with each other, so
|
||||||
|
install one:
|
||||||
|
|
||||||
|
| Package | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | builds the latest tagged release from source |
|
||||||
|
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | builds `main` from git |
|
||||||
|
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | installs the release binaries, no compile |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S proton-drive-for-linux # or any AUR helper
|
||||||
|
```
|
||||||
|
|
||||||
|
The package sources live in `packaging/aur/`; see
|
||||||
|
[`packaging/aur/README.md`](packaging/aur/README.md) for how they are updated and
|
||||||
|
published. A local `PKGBUILD` that compiles the working tree in place is still
|
||||||
|
available for development builds:
|
||||||
```bash
|
```bash
|
||||||
cd packaging && makepkg -fi
|
cd packaging && makepkg -fi
|
||||||
```
|
```
|
||||||
@@ -372,6 +498,19 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||||
|
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
||||||
|
vulnerability privately.
|
||||||
|
|
||||||
|
The app, tray and search prompt are available in English, German, Spanish, French, Italian, Dutch,
|
||||||
|
Polish and Brazilian Portuguese. [docs/TRANSLATING.md](docs/TRANSLATING.md) explains how to improve
|
||||||
|
a translation or add a language.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||||
|
affiliation with or endorsement from Proton AG.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Report privately through GitHub's
|
||||||
|
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
|
||||||
|
or by email to <contact@narl.io>.
|
||||||
|
|
||||||
|
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
|
||||||
|
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
|
||||||
|
days; a fix ships in the next release, and you are credited in the advisory unless you ask
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This client holds Proton account credentials and decrypted file content on the local machine, so
|
||||||
|
the interesting boundaries are:
|
||||||
|
|
||||||
|
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
|
||||||
|
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
|
||||||
|
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
|
||||||
|
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
|
||||||
|
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
|
||||||
|
those checks are in scope.
|
||||||
|
- **Local cache and staging.** Decrypted block content and staged writes on disk.
|
||||||
|
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||||
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
|
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||||
|
require an attacker who already has your user account on the machine.
|
||||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "CLI for the Proton Drive Linux client"
|
description = "CLI for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "Core auth/session/config for the Proton Drive Linux client"
|
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -18,8 +20,11 @@ tracing.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
ignore.workspace = true
|
ignore.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring-core.workspace = true
|
||||||
|
dbus-secret-service-keyring-store.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
AD Andorra
|
||||||
|
AE United Arab Emirates
|
||||||
|
AF Afghanistan
|
||||||
|
AG Antigua and Barbuda
|
||||||
|
AI Anguilla
|
||||||
|
AL Albania
|
||||||
|
AM Armenia
|
||||||
|
AO Angola
|
||||||
|
AQ Antarctica
|
||||||
|
AR Argentina
|
||||||
|
AS American Samoa
|
||||||
|
AT Austria
|
||||||
|
AU Australia
|
||||||
|
AW Aruba
|
||||||
|
AX Åland Islands
|
||||||
|
AZ Azerbaijan
|
||||||
|
BA Bosnia and Herzegovina
|
||||||
|
BB Barbados
|
||||||
|
BD Bangladesh
|
||||||
|
BE Belgium
|
||||||
|
BF Burkina Faso
|
||||||
|
BG Bulgaria
|
||||||
|
BH Bahrain
|
||||||
|
BI Burundi
|
||||||
|
BJ Benin
|
||||||
|
BL Saint Barthélemy
|
||||||
|
BM Bermuda
|
||||||
|
BN Brunei Darussalam
|
||||||
|
BO Bolivia, Plurinational State of
|
||||||
|
BQ Bonaire, Sint Eustatius and Saba
|
||||||
|
BR Brazil
|
||||||
|
BS Bahamas
|
||||||
|
BT Bhutan
|
||||||
|
BV Bouvet Island
|
||||||
|
BW Botswana
|
||||||
|
BY Belarus
|
||||||
|
BZ Belize
|
||||||
|
CA Canada
|
||||||
|
CC Cocos (Keeling) Islands
|
||||||
|
CD Congo, The Democratic Republic of the
|
||||||
|
CF Central African Republic
|
||||||
|
CG Congo
|
||||||
|
CH Switzerland
|
||||||
|
CI Côte d'Ivoire
|
||||||
|
CK Cook Islands
|
||||||
|
CL Chile
|
||||||
|
CM Cameroon
|
||||||
|
CN China
|
||||||
|
CO Colombia
|
||||||
|
CR Costa Rica
|
||||||
|
CU Cuba
|
||||||
|
CV Cabo Verde
|
||||||
|
CW Curaçao
|
||||||
|
CX Christmas Island
|
||||||
|
CY Cyprus
|
||||||
|
CZ Czechia
|
||||||
|
DE Germany
|
||||||
|
DJ Djibouti
|
||||||
|
DK Denmark
|
||||||
|
DM Dominica
|
||||||
|
DO Dominican Republic
|
||||||
|
DZ Algeria
|
||||||
|
EC Ecuador
|
||||||
|
EE Estonia
|
||||||
|
EG Egypt
|
||||||
|
EH Western Sahara
|
||||||
|
ER Eritrea
|
||||||
|
ES Spain
|
||||||
|
ET Ethiopia
|
||||||
|
FI Finland
|
||||||
|
FJ Fiji
|
||||||
|
FK Falkland Islands (Malvinas)
|
||||||
|
FM Micronesia, Federated States of
|
||||||
|
FO Faroe Islands
|
||||||
|
FR France
|
||||||
|
GA Gabon
|
||||||
|
GB United Kingdom
|
||||||
|
GD Grenada
|
||||||
|
GE Georgia
|
||||||
|
GF French Guiana
|
||||||
|
GG Guernsey
|
||||||
|
GH Ghana
|
||||||
|
GI Gibraltar
|
||||||
|
GL Greenland
|
||||||
|
GM Gambia
|
||||||
|
GN Guinea
|
||||||
|
GP Guadeloupe
|
||||||
|
GQ Equatorial Guinea
|
||||||
|
GR Greece
|
||||||
|
GS South Georgia and the South Sandwich Islands
|
||||||
|
GT Guatemala
|
||||||
|
GU Guam
|
||||||
|
GW Guinea-Bissau
|
||||||
|
GY Guyana
|
||||||
|
HK Hong Kong
|
||||||
|
HM Heard Island and McDonald Islands
|
||||||
|
HN Honduras
|
||||||
|
HR Croatia
|
||||||
|
HT Haiti
|
||||||
|
HU Hungary
|
||||||
|
ID Indonesia
|
||||||
|
IE Ireland
|
||||||
|
IL Israel
|
||||||
|
IM Isle of Man
|
||||||
|
IN India
|
||||||
|
IO British Indian Ocean Territory
|
||||||
|
IQ Iraq
|
||||||
|
IR Iran, Islamic Republic of
|
||||||
|
IS Iceland
|
||||||
|
IT Italy
|
||||||
|
JE Jersey
|
||||||
|
JM Jamaica
|
||||||
|
JO Jordan
|
||||||
|
JP Japan
|
||||||
|
KE Kenya
|
||||||
|
KG Kyrgyzstan
|
||||||
|
KH Cambodia
|
||||||
|
KI Kiribati
|
||||||
|
KM Comoros
|
||||||
|
KN Saint Kitts and Nevis
|
||||||
|
KP Korea, Democratic People's Republic of
|
||||||
|
KR Korea, Republic of
|
||||||
|
KW Kuwait
|
||||||
|
KY Cayman Islands
|
||||||
|
KZ Kazakhstan
|
||||||
|
LA Lao People's Democratic Republic
|
||||||
|
LB Lebanon
|
||||||
|
LC Saint Lucia
|
||||||
|
LI Liechtenstein
|
||||||
|
LK Sri Lanka
|
||||||
|
LR Liberia
|
||||||
|
LS Lesotho
|
||||||
|
LT Lithuania
|
||||||
|
LU Luxembourg
|
||||||
|
LV Latvia
|
||||||
|
LY Libya
|
||||||
|
MA Morocco
|
||||||
|
MC Monaco
|
||||||
|
MD Moldova, Republic of
|
||||||
|
ME Montenegro
|
||||||
|
MF Saint Martin (French part)
|
||||||
|
MG Madagascar
|
||||||
|
MH Marshall Islands
|
||||||
|
MK North Macedonia
|
||||||
|
ML Mali
|
||||||
|
MM Myanmar
|
||||||
|
MN Mongolia
|
||||||
|
MO Macao
|
||||||
|
MP Northern Mariana Islands
|
||||||
|
MQ Martinique
|
||||||
|
MR Mauritania
|
||||||
|
MS Montserrat
|
||||||
|
MT Malta
|
||||||
|
MU Mauritius
|
||||||
|
MV Maldives
|
||||||
|
MW Malawi
|
||||||
|
MX Mexico
|
||||||
|
MY Malaysia
|
||||||
|
MZ Mozambique
|
||||||
|
NA Namibia
|
||||||
|
NC New Caledonia
|
||||||
|
NE Niger
|
||||||
|
NF Norfolk Island
|
||||||
|
NG Nigeria
|
||||||
|
NI Nicaragua
|
||||||
|
NL Netherlands
|
||||||
|
NO Norway
|
||||||
|
NP Nepal
|
||||||
|
NR Nauru
|
||||||
|
NU Niue
|
||||||
|
NZ New Zealand
|
||||||
|
OM Oman
|
||||||
|
PA Panama
|
||||||
|
PE Peru
|
||||||
|
PF French Polynesia
|
||||||
|
PG Papua New Guinea
|
||||||
|
PH Philippines
|
||||||
|
PK Pakistan
|
||||||
|
PL Poland
|
||||||
|
PM Saint Pierre and Miquelon
|
||||||
|
PN Pitcairn
|
||||||
|
PR Puerto Rico
|
||||||
|
PS Palestine, State of
|
||||||
|
PT Portugal
|
||||||
|
PW Palau
|
||||||
|
PY Paraguay
|
||||||
|
QA Qatar
|
||||||
|
RE Réunion
|
||||||
|
RO Romania
|
||||||
|
RS Serbia
|
||||||
|
RU Russian Federation
|
||||||
|
RW Rwanda
|
||||||
|
SA Saudi Arabia
|
||||||
|
SB Solomon Islands
|
||||||
|
SC Seychelles
|
||||||
|
SD Sudan
|
||||||
|
SE Sweden
|
||||||
|
SG Singapore
|
||||||
|
SH Saint Helena, Ascension and Tristan da Cunha
|
||||||
|
SI Slovenia
|
||||||
|
SJ Svalbard and Jan Mayen
|
||||||
|
SK Slovakia
|
||||||
|
SL Sierra Leone
|
||||||
|
SM San Marino
|
||||||
|
SN Senegal
|
||||||
|
SO Somalia
|
||||||
|
SR Suriname
|
||||||
|
SS South Sudan
|
||||||
|
ST Sao Tome and Principe
|
||||||
|
SV El Salvador
|
||||||
|
SX Sint Maarten (Dutch part)
|
||||||
|
SY Syrian Arab Republic
|
||||||
|
SZ Eswatini
|
||||||
|
TC Turks and Caicos Islands
|
||||||
|
TD Chad
|
||||||
|
TF French Southern Territories
|
||||||
|
TG Togo
|
||||||
|
TH Thailand
|
||||||
|
TJ Tajikistan
|
||||||
|
TK Tokelau
|
||||||
|
TL Timor-Leste
|
||||||
|
TM Turkmenistan
|
||||||
|
TN Tunisia
|
||||||
|
TO Tonga
|
||||||
|
TR Türkiye
|
||||||
|
TT Trinidad and Tobago
|
||||||
|
TV Tuvalu
|
||||||
|
TW Taiwan, Province of China
|
||||||
|
TZ Tanzania, United Republic of
|
||||||
|
UA Ukraine
|
||||||
|
UG Uganda
|
||||||
|
UM United States Minor Outlying Islands
|
||||||
|
US United States
|
||||||
|
UY Uruguay
|
||||||
|
UZ Uzbekistan
|
||||||
|
VA Holy See (Vatican City State)
|
||||||
|
VC Saint Vincent and the Grenadines
|
||||||
|
VE Venezuela, Bolivarian Republic of
|
||||||
|
VG Virgin Islands, British
|
||||||
|
VI Virgin Islands, U.S.
|
||||||
|
VN Viet Nam
|
||||||
|
VU Vanuatu
|
||||||
|
WF Wallis and Futuna
|
||||||
|
WS Samoa
|
||||||
|
XK Kosovo
|
||||||
|
YE Yemen
|
||||||
|
YT Mayotte
|
||||||
|
ZA South Africa
|
||||||
|
ZM Zambia
|
||||||
|
ZW Zimbabwe
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
||||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring_core::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
use proton_sdk::cache::EncryptedCacheRepository;
|
use proton_sdk::cache::EncryptedCacheRepository;
|
||||||
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry() -> Result<Entry> {
|
fn keyring_entry() -> Result<Entry> {
|
||||||
|
// Installed lazily rather than once at startup so a session bus that was not
|
||||||
|
// up yet on the first attempt is retried on the next one.
|
||||||
|
if keyring_core::get_default_store().is_none() {
|
||||||
|
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
||||||
|
}
|
||||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
|
|||||||
let entry = keyring_entry()?;
|
let entry = keyring_entry()?;
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ pub struct AppConfig {
|
|||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_budget: Option<u64>,
|
pub cache_budget: Option<u64>,
|
||||||
|
/// Upload cap in bytes per second shared by every upload. `None` or `0`
|
||||||
|
/// means no cap. Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub upload_limit: Option<u64>,
|
||||||
|
/// Download cap in bytes per second shared by every download, FUSE reads
|
||||||
|
/// included. `None` or `0` means no cap. Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub download_limit: Option<u64>,
|
||||||
/// Mountpoint the daemon mounts at. `None` means
|
/// Mountpoint the daemon mounts at. `None` means
|
||||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||||
/// path here. Defaulted for configs predating the field.
|
/// path here. Defaulted for configs predating the field.
|
||||||
@@ -119,6 +128,85 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
pub prompt: Option<PromptConfig>,
|
||||||
|
/// Paint the desktop app in Proton's colours instead of the system theme
|
||||||
|
/// and accent colour. `None` means "follow the system". Defaulted for
|
||||||
|
/// configs predating the field; also read from `proton_accent`, its name
|
||||||
|
/// while it only changed the accent.
|
||||||
|
#[serde(default, alias = "proton_accent")]
|
||||||
|
pub proton_theme: Option<bool>,
|
||||||
|
/// How the desktop app's My Files page lays out and orders a folder.
|
||||||
|
/// Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub files_view: FilesView,
|
||||||
|
/// The user hid the tray icon from its own menu. The tray exits at start
|
||||||
|
/// while this is set, and the app does not spawn it; Preferences turns it
|
||||||
|
/// back on. Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tray_hidden: bool,
|
||||||
|
/// Language for the desktop app, tray and search prompt, as a gettext code
|
||||||
|
/// such as `de` or `pt_BR`. `None` means "follow the system locale".
|
||||||
|
/// Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// Draw the Places map from OpenFreeMap's street map instead of the
|
||||||
|
/// built-in outline. Off by default: the tiles come from a third party,
|
||||||
|
/// which then learns which parts of the world are looked at. Defaulted for
|
||||||
|
/// configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub online_map: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The My Files page's view choices, remembered between runs.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FilesView {
|
||||||
|
/// Columns instead of the icon grid.
|
||||||
|
pub list: bool,
|
||||||
|
pub sort: FileSort,
|
||||||
|
pub descending: bool,
|
||||||
|
/// Folders ahead of files, whatever the sort.
|
||||||
|
pub folders_first: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilesView {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
list: false,
|
||||||
|
sort: FileSort::Name,
|
||||||
|
descending: false,
|
||||||
|
folders_first: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key a My Files listing is ordered by.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FileSort {
|
||||||
|
#[default]
|
||||||
|
Name,
|
||||||
|
Size,
|
||||||
|
Modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSort {
|
||||||
|
/// The name used in the desktop app's actions and the config file.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FileSort::Name => "name",
|
||||||
|
FileSort::Size => "size",
|
||||||
|
FileSort::Modified => "modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"name" => Some(FileSort::Name),
|
||||||
|
"size" => Some(FileSort::Size),
|
||||||
|
"modified" => Some(FileSort::Modified),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -127,12 +215,19 @@ impl Default for AppConfig {
|
|||||||
app_version: APP_VERSION.to_string(),
|
app_version: APP_VERSION.to_string(),
|
||||||
user_agent: USER_AGENT.to_string(),
|
user_agent: USER_AGENT.to_string(),
|
||||||
cache_budget: None,
|
cache_budget: None,
|
||||||
|
upload_limit: None,
|
||||||
|
download_limit: None,
|
||||||
mountpoint: None,
|
mountpoint: None,
|
||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_theme: None,
|
||||||
|
files_view: FilesView::default(),
|
||||||
|
tray_hidden: false,
|
||||||
|
language: None,
|
||||||
|
online_map: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,17 +566,54 @@ mod tests {
|
|||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: "test-agent/1.0".to_string(),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
|
upload_limit: Some(500_000),
|
||||||
|
download_limit: None,
|
||||||
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),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_theme: Some(true),
|
||||||
|
files_view: FilesView {
|
||||||
|
list: true,
|
||||||
|
sort: FileSort::Modified,
|
||||||
|
descending: true,
|
||||||
|
folders_first: false,
|
||||||
|
},
|
||||||
|
tray_hidden: true,
|
||||||
|
language: Some("de".to_string()),
|
||||||
|
online_map: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
|
assert!(decoded.tray_hidden);
|
||||||
|
assert!(decoded.online_map);
|
||||||
|
assert_eq!(decoded.language.as_deref(), Some("de"));
|
||||||
|
assert_eq!(decoded.upload_limit, Some(500_000));
|
||||||
|
assert_eq!(decoded.download_limit, None);
|
||||||
|
assert_eq!(decoded.files_view, config.files_view);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_config_without_a_files_view_gets_the_default() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b"}"#).unwrap();
|
||||||
|
assert_eq!(decoded.files_view, FilesView::default());
|
||||||
|
let partial: FilesView = serde_json::from_str(r#"{"sort":"size"}"#).unwrap();
|
||||||
|
assert_eq!(partial.sort, FileSort::Size);
|
||||||
|
assert!(partial.folders_first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_old_proton_accent_setting_turns_on_the_proton_theme() {
|
||||||
|
let decoded: AppConfig =
|
||||||
|
serde_json::from_str(r#"{"app_version":"a","user_agent":"b","proton_accent":true}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.proton_theme, Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -554,6 +686,11 @@ mod tests {
|
|||||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
assert!(decoded.ignore_patterns.is_none());
|
assert!(decoded.ignore_patterns.is_none());
|
||||||
|
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||||
|
assert!(
|
||||||
|
decoded.language.is_none(),
|
||||||
|
"an old config follows the system"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoded.resolved_ignore_patterns().len(),
|
decoded.resolved_ignore_patterns().len(),
|
||||||
DEFAULT_IGNORE_PATTERNS.len(),
|
DEFAULT_IGNORE_PATTERNS.len(),
|
||||||
|
|||||||
@@ -29,11 +29,22 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the kept log already holds an entry of `kind` for `target`. Lets
|
||||||
|
/// the daemon report something once across restarts rather than once per
|
||||||
|
/// run, without keeping a second table in step with the log.
|
||||||
|
pub fn activity_has(&self, kind: ActivityKind, target: &str) -> Result<bool> {
|
||||||
|
let kind = serde_json::to_string(&kind)?;
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT 1 FROM activity WHERE kind = ?1 AND target = ?2 LIMIT 1")?;
|
||||||
|
Ok(stmt.exists(params![kind, target])?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
/// The most recent activity, newest first, capped at `limit` entries. Rows
|
||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT time, kind, target, detail, ok FROM activity
|
"SELECT time, kind, target, detail, ok FROM activity
|
||||||
ORDER BY id DESC LIMIT ?1",
|
ORDER BY id DESC LIMIT ?1",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ impl Db {
|
|||||||
|
|
||||||
/// The persisted album listing, in stored order.
|
/// The persisted album listing, in stored order.
|
||||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||||
FROM albums ORDER BY seq",
|
FROM albums ORDER BY seq",
|
||||||
@@ -98,7 +98,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of albums in the persisted listing.
|
/// Number of albums in the persisted listing.
|
||||||
pub fn albums_count(&self) -> Result<usize> {
|
pub fn albums_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ impl Db {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||||
@@ -178,19 +178,36 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
|
||||||
|
/// which has to put the re-uploaded copy back into the albums the original
|
||||||
|
/// was in.
|
||||||
|
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([uid], |r| r.get(0))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many photos of `album_uid` are persisted.
|
/// How many photos of `album_uid` are persisted.
|
||||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row(
|
let n: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||||
[album_uid],
|
[album_uid],
|
||||||
@@ -207,7 +224,7 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
@@ -222,10 +239,14 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bump `last_accessed` for many keys in one transaction.
|
||||||
|
///
|
||||||
|
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||||
|
/// per 4 MiB block — and each of those was its own write transaction under
|
||||||
|
/// the single connection mutex, contending with every `lookup` and
|
||||||
|
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||||
|
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||||
|
if touches.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare_cached(
|
||||||
|
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||||
|
)?;
|
||||||
|
for (key, at) in touches {
|
||||||
|
stmt.execute(params![key, at])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a single cache-entry row (one evicted blob or block).
|
/// Drop a single cache-entry row (one evicted blob or block).
|
||||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -75,7 +99,7 @@ impl Db {
|
|||||||
/// because `enforce_block_budget` runs on every cached block — once per
|
/// because `enforce_block_budget` runs on every cached block — once per
|
||||||
/// 4 MiB of every cold read — while holding the shared connection.
|
/// 4 MiB of every cold read — while holding the shared connection.
|
||||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let total: i64 = conn.query_row(
|
let total: i64 = conn.query_row(
|
||||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||||
params![kind],
|
params![kind],
|
||||||
@@ -95,7 +119,7 @@ impl Db {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<(String, u64)>> {
|
) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||||
@@ -115,7 +139,7 @@ impl Db {
|
|||||||
/// (`clear_unpinned`); incremental eviction wants
|
/// (`clear_unpinned`); incremental eviction wants
|
||||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -41,32 +41,43 @@ impl Db {
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write one batch of walked entries under scan generation `generation`. The
|
/// Write one batch of walked entries under scan generation `generation`,
|
||||||
/// FTS index is *not* touched here — it is rebuilt once in
|
/// keeping the FTS index in step with them.
|
||||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
///
|
||||||
|
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||||
|
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||||
|
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||||
|
/// the single shared connection, which stalls the whole mount for as long as
|
||||||
|
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||||
|
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||||
|
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||||
|
/// content can never change. Only insertions and deletions touch the index;
|
||||||
|
/// a re-scan that finds the same files does no index work at all.
|
||||||
|
///
|
||||||
|
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||||
|
/// distinction between "already indexed" and "new row" is exactly what
|
||||||
|
/// decides whether the index needs a write.
|
||||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut update = tx.prepare_cached(
|
||||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
"UPDATE local_files
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
WHERE path = ?1",
|
||||||
name = excluded.name,
|
|
||||||
is_dir = excluded.is_dir,
|
|
||||||
size = excluded.size,
|
|
||||||
mtime = excluded.mtime,
|
|
||||||
scan_gen = excluded.scan_gen",
|
|
||||||
)?;
|
)?;
|
||||||
|
let mut insert = tx.prepare_cached(
|
||||||
|
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
)?;
|
||||||
|
let mut index =
|
||||||
|
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||||
for e in entries {
|
for e in entries {
|
||||||
stmt.execute(params![
|
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||||
e.path,
|
if update.execute(row)? == 0 {
|
||||||
e.name,
|
insert.execute(row)?;
|
||||||
e.is_dir as i64,
|
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||||
e.size,
|
}
|
||||||
e.mtime,
|
|
||||||
generation
|
|
||||||
])?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@@ -74,16 +85,25 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||||
/// and stamp the completion time. Returns the number of indexed entries.
|
/// completion time. Returns the number of indexed entries.
|
||||||
|
///
|
||||||
|
/// The FTS deletes name the old values explicitly, which is how an
|
||||||
|
/// external-content FTS5 table is told to retract a row — see
|
||||||
|
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||||
|
/// per-row instead of by rebuilding the index.
|
||||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||||
|
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||||
|
params![generation],
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||||
params![generation],
|
params![generation],
|
||||||
)?;
|
)?;
|
||||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -98,7 +118,7 @@ impl Db {
|
|||||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||||
/// needs an immediate rescan or can serve the existing index.
|
/// needs an immediate rescan or can serve the existing index.
|
||||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||||
@@ -118,7 +138,7 @@ impl Db {
|
|||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt;
|
let mut stmt;
|
||||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
@@ -157,7 +177,7 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||||
|
|||||||
@@ -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 = 21;
|
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -113,6 +113,225 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V21)?;
|
tx.execute_batch(MIGRATION_V21)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 22 {
|
||||||
|
// Older fixtures (and a database that never grew a table because
|
||||||
|
// its feature was never used) may be missing any of these, and an
|
||||||
|
// index on an absent table is a hard error rather than a no-op.
|
||||||
|
for (table, sql) in MIGRATION_V22 {
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch(sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `activity_time` indexes `time DESC` while both activity queries
|
||||||
|
// order by `id`: it has only ever cost writes.
|
||||||
|
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
|
||||||
|
}
|
||||||
|
if current < 23 {
|
||||||
|
// One last full rebuild of the local index, because from here on the
|
||||||
|
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||||
|
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||||
|
// Whatever it costs, it costs once, at startup, before the mount
|
||||||
|
// serves anything.
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 24 {
|
||||||
|
tx.execute_batch(MIGRATION_V24)?;
|
||||||
|
}
|
||||||
|
if current < 25 {
|
||||||
|
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||||
|
// and there is no path to materialise without it.
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes {
|
||||||
|
tx.execute_batch(MIGRATION_V25)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 26 {
|
||||||
|
// Same guard as V22: a fixture that never exercised the queue may
|
||||||
|
// not have `pending_op`, and altering an absent table is an error
|
||||||
|
// rather than a no-op.
|
||||||
|
// A fixture built from the current schema and then rewound to an
|
||||||
|
// older `schema_version` already has the column, and `ADD COLUMN`
|
||||||
|
// has no `IF NOT EXISTS`.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V26)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 27 {
|
||||||
|
// Same guards as V26, for the same two reasons: the table may not
|
||||||
|
// exist in an old fixture, and a fixture rewound from the current
|
||||||
|
// schema already has the column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
|
||||||
|
WHERE name = 'access_deferred_since'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V27)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 28 {
|
||||||
|
// Same guards as V26/V27, for the same two reasons.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||||
|
WHERE name = 'local_mtime_ns'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_entries: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_entries && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V28)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 29 {
|
||||||
|
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
|
||||||
|
// and a fixture rewound from the current schema already has the
|
||||||
|
// column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V29)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 30 {
|
||||||
|
// Same guards as V26-V29: the column may already be there on a
|
||||||
|
// fixture rewound from the current schema, and a very old fixture
|
||||||
|
// may not have `photos` at all.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V30)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 31 {
|
||||||
|
// Same guards as V26-V30.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 32 {
|
||||||
|
// Data only, so safe to repeat; each table is guarded for a very
|
||||||
|
// old fixture that never had it.
|
||||||
|
for table in ["photos", "album_photos"] {
|
||||||
|
let has_table: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_table {
|
||||||
|
tx.execute(&MIGRATION_V32.replace("{table}", table), [])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 33 {
|
||||||
|
// Same guards as V26-V31.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'place_id'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V33)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 34 {
|
||||||
|
// Same guards as V26-V33.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V34)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 35 {
|
||||||
|
// Same guards as V26-V34.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'trashed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V35)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
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",
|
||||||
@@ -527,7 +746,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
|||||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// V20: whether a photo is a favourite.
|
/// V20: whether a photo is a favorite.
|
||||||
///
|
///
|
||||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||||
@@ -583,3 +802,260 @@ SELECT n.rowid, n.name, paths.path
|
|||||||
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
||||||
WHERE paths.trashed = 0;
|
WHERE paths.trashed = 0;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V22: indexes for the lookups that were scanning, each paired with the table
|
||||||
|
/// it needs to exist for.
|
||||||
|
///
|
||||||
|
/// Each backs a query on a hot path that had no index to use:
|
||||||
|
///
|
||||||
|
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
|
||||||
|
/// local entry once per node, per pass.
|
||||||
|
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
|
||||||
|
/// after a local index scan.
|
||||||
|
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
|
||||||
|
/// index keeping it to the live rows a listing actually wants.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
|
||||||
|
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
|
||||||
|
/// first due row; offering the planner a `next_attempt_at` index makes it scan
|
||||||
|
/// that index and then sort the matches by `id` instead —
|
||||||
|
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
|
||||||
|
const MIGRATION_V22: [(&str, &str); 3] = [
|
||||||
|
(
|
||||||
|
"sync_entry",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"local_files",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nodes",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||||
|
/// the conflict hole B70 layer B closed.
|
||||||
|
///
|
||||||
|
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||||
|
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||||
|
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||||
|
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||||
|
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||||
|
///
|
||||||
|
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||||
|
/// a stall→resume is plausible are pruned on write.
|
||||||
|
const MIGRATION_V24: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
revision_id TEXT NOT NULL,
|
||||||
|
sealed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||||
|
/// walked.
|
||||||
|
///
|
||||||
|
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||||
|
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||||
|
/// per descendant of every folder upsert. The value only changes when a node's
|
||||||
|
/// name or parent changes, which is exactly when the write path is already
|
||||||
|
/// walking the subtree — so it belongs in a column.
|
||||||
|
///
|
||||||
|
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||||
|
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||||
|
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||||
|
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||||
|
///
|
||||||
|
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||||
|
/// so the recursive walk runs once instead of once per row.
|
||||||
|
const MIGRATION_V25: &str = "
|
||||||
|
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||||
|
|
||||||
|
INSERT INTO node_paths (uid, path)
|
||||||
|
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||||
|
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_uid IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.uid,
|
||||||
|
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||||
|
paths.depth + 1
|
||||||
|
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||||
|
WHERE paths.depth < 256
|
||||||
|
)
|
||||||
|
SELECT uid, path FROM paths;
|
||||||
|
|
||||||
|
UPDATE nodes
|
||||||
|
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||||
|
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||||
|
|
||||||
|
DROP TABLE node_paths;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V26: `pending_op.claimed_at` — the drain's claim column.
|
||||||
|
///
|
||||||
|
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
|
||||||
|
/// every queued rename and small write behind it for as long as it ran. Several
|
||||||
|
/// workers can share the queue only if a claim is visible to the others: this
|
||||||
|
/// column is what makes "already being drained by someone" a fact about the row
|
||||||
|
/// rather than about one thread's local variable.
|
||||||
|
///
|
||||||
|
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
|
||||||
|
/// claim is process-local state that outlives nothing — the single-writer lock
|
||||||
|
/// means a non-zero value found at open belongs to a crashed run, and
|
||||||
|
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
|
||||||
|
///
|
||||||
|
/// The index is partial so it costs nothing on the ordinary row: only the
|
||||||
|
/// handful of claimed rows are in it, which is exactly the set the claim query
|
||||||
|
/// has to subtract to keep two workers off one node.
|
||||||
|
const MIGRATION_V26: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||||
|
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v27: when an op was first deferred by the local access check.
|
||||||
|
///
|
||||||
|
/// An access deferral consumes no attempt and records no error — the op has not
|
||||||
|
/// failed remotely, it has not been sent at all. That is right for a permission
|
||||||
|
/// change the user is about to undo, and wrong for a condition that never
|
||||||
|
/// clears: the row is re-deferred every
|
||||||
|
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
|
||||||
|
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
|
||||||
|
/// empty, so a queue that can never drain is indistinguishable from a busy one.
|
||||||
|
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
|
||||||
|
/// 30 days, none of them visible anywhere.
|
||||||
|
///
|
||||||
|
/// `0` means "not currently access-deferred"; anything else is the ms at which
|
||||||
|
/// the first consecutive deferral happened, which is what
|
||||||
|
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
|
||||||
|
/// deferral against.
|
||||||
|
const MIGRATION_V27: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v28: sub-second precision for a mirror folder's local baseline.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides whether the local side of a path changed by
|
||||||
|
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
|
||||||
|
/// that comparison were whole seconds, so an edit that landed in the same second
|
||||||
|
/// as the last sync and left the file the same length — a fixed-size record
|
||||||
|
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||||
|
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||||
|
/// older content until something else about the file moved (bugs.md B25).
|
||||||
|
///
|
||||||
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
|
/// zero for it would make every already-synced file compare as changed and
|
||||||
|
/// re-upload an entire mirror on first start. `NULL` means "this row predates
|
||||||
|
/// the column", which the comparison reads as "fall back to whole seconds" —
|
||||||
|
/// exactly the behaviour that row was written under. Each row gains a real value
|
||||||
|
/// the next time its path is synced.
|
||||||
|
const MIGRATION_V28: &str = "
|
||||||
|
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||||
|
/// restore can reason about the shape of what it is putting back.
|
||||||
|
///
|
||||||
|
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||||
|
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||||
|
/// one of those in isolation is what the parent link fixes — a child restored
|
||||||
|
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||||
|
/// without its separately-trashed contents comes back empty.
|
||||||
|
///
|
||||||
|
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||||
|
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||||
|
/// did before.
|
||||||
|
const MIGRATION_V29: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||||
|
/// who took it, so the timeline records what ties them together.
|
||||||
|
///
|
||||||
|
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||||
|
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||||
|
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||||
|
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||||
|
/// its own uid.
|
||||||
|
///
|
||||||
|
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||||
|
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||||
|
/// the gallery behaved before this column existed.
|
||||||
|
const MIGRATION_V30: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v31: which photos a refresh still has to read from the server.
|
||||||
|
///
|
||||||
|
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||||
|
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||||
|
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||||
|
/// records that the work was already done, so a refresh only reads the photos it
|
||||||
|
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||||
|
///
|
||||||
|
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||||
|
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||||
|
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||||
|
/// ever learned about.
|
||||||
|
const MIGRATION_V31: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V32: give videos written off as un-thumbnailable another chance.
|
||||||
|
///
|
||||||
|
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||||
|
/// decoder, which could only fail, and that failure was recorded as the
|
||||||
|
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||||
|
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||||
|
|
||||||
|
/// Schema v33: where each photo was taken.
|
||||||
|
///
|
||||||
|
/// `latitude` and `longitude` come from the location other clients write into
|
||||||
|
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||||
|
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||||
|
///
|
||||||
|
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||||
|
/// the whole library once. That is the backfill: the metadata is on the node
|
||||||
|
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||||
|
const MIGRATION_V33: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||||
|
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||||
|
UPDATE photos SET resolved_at = NULL;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||||
|
///
|
||||||
|
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||||
|
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||||
|
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||||
|
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||||
|
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||||
|
const MIGRATION_V34: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v35: when each trashed node went to the trash.
|
||||||
|
///
|
||||||
|
/// The API does not tell this client, so `trashed_at` is when the daemon first
|
||||||
|
/// saw the node in the trash, in epoch seconds. `NULL` when it cannot say: rows
|
||||||
|
/// from before this version, and nodes found by a refresh that did not follow a
|
||||||
|
/// complete one, which may have been in the trash for a long time already.
|
||||||
|
const MIGRATION_V35: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN trashed_at INTEGER;
|
||||||
|
";
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||||
//!
|
//!
|
||||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||||
//! the same data through the control socket. The connection is wrapped in a
|
//! the same data through the control socket. There is one write connection
|
||||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||||
//! behind the `State` lock, so a connection pool would be overkill.
|
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||||
|
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||||
|
//!
|
||||||
|
//! The pool is what makes WAL worth having. The original justification for a
|
||||||
|
//! single connection was that "FUSE callbacks already serialize behind the
|
||||||
|
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||||
|
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||||
|
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||||
|
//! only thing left serialising them was this process's own mutex.
|
||||||
//!
|
//!
|
||||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||||
@@ -18,8 +26,10 @@
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::Result;
|
use crate::{Error, Result};
|
||||||
use std::path::Path;
|
use std::fs::File;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
mod albums;
|
mod albums;
|
||||||
@@ -46,10 +56,10 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{PublishedSharedRoot, 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, PARK_UNTIL,
|
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
pub use photos::{StoredPhoto, StoredPlace, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
pub use pins::PinRow;
|
pub use pins::PinRow;
|
||||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||||
pub use trash::StoredTrash;
|
pub use trash::StoredTrash;
|
||||||
@@ -66,18 +76,208 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
|||||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||||
|
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||||
|
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||||
|
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||||
|
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||||
|
|
||||||
|
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||||
|
/// any realistic index while keeping the mapping bounded.
|
||||||
|
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||||
|
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||||
|
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||||
|
|
||||||
/// Handle to the unified metadata database.
|
/// Handle to the unified metadata database.
|
||||||
///
|
///
|
||||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||||
/// the inner `Mutex<Connection>`.
|
/// the inner `Mutex<Connection>`.
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
|
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||||
|
/// database, where a second connection would open a different, empty one —
|
||||||
|
/// those fall back to the write connection.
|
||||||
|
readers: Option<ReadPool>,
|
||||||
|
/// The single-writer lock, held open for as long as this handle lives.
|
||||||
|
///
|
||||||
|
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||||
|
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||||
|
/// in-memory databases, which nothing else can reach.
|
||||||
|
_single_writer: Option<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||||
|
///
|
||||||
|
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||||
|
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||||
|
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||||
|
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||||
|
/// does not leave a connection per thread parked forever.
|
||||||
|
struct ReadPool {
|
||||||
|
path: PathBuf,
|
||||||
|
idle: Mutex<Vec<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many idle read connections to keep. Enough that the steady state never
|
||||||
|
/// reopens, small enough to be unremarkable.
|
||||||
|
const MAX_IDLE_READERS: usize = 4;
|
||||||
|
|
||||||
|
impl ReadPool {
|
||||||
|
fn take(&self) -> Option<Connection> {
|
||||||
|
if let Some(conn) = self.idle.lock().pop() {
|
||||||
|
return Some(conn);
|
||||||
|
}
|
||||||
|
match open_read_only(&self.path) {
|
||||||
|
Ok(conn) => Some(conn),
|
||||||
|
Err(error) => {
|
||||||
|
// Falling back to the write connection is correct, just slower,
|
||||||
|
// so this is a warning and not a failed read.
|
||||||
|
tracing::warn!(%error, "could not open a read-only connection");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&self, conn: Connection) {
|
||||||
|
let mut idle = self.idle.lock();
|
||||||
|
if idle.len() < MAX_IDLE_READERS {
|
||||||
|
idle.push(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||||
|
/// connection when there is no pool (in-memory) or opening one failed.
|
||||||
|
///
|
||||||
|
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||||
|
/// for `self.read()` and nothing else.
|
||||||
|
pub(super) struct Reader<'a> {
|
||||||
|
pooled: Option<Connection>,
|
||||||
|
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||||
|
pool: Option<&'a ReadPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for Reader<'_> {
|
||||||
|
type Target = Connection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Connection {
|
||||||
|
self.pooled
|
||||||
|
.as_ref()
|
||||||
|
.or(self.writer.as_deref())
|
||||||
|
.expect("a Reader always holds one of the two")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Reader<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||||
|
pool.put(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a second connection to the same file for reads only.
|
||||||
|
///
|
||||||
|
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||||
|
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||||
|
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||||
|
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||||
|
/// writer's.
|
||||||
|
///
|
||||||
|
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||||
|
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||||
|
/// it anyway.
|
||||||
|
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||||
|
use rusqlite::OpenFlags;
|
||||||
|
let conn = Connection::open_with_flags(
|
||||||
|
path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
)?;
|
||||||
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
conn.pragma_update(None, "query_only", true)?;
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||||
/// the schema up to [`SCHEMA_VERSION`].
|
/// the schema up to [`SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Takes the single-instance lock first (see
|
||||||
|
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||||
|
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let single_writer = acquire_single_writer_lock(path)?;
|
||||||
|
let conn = match Self::open_configured(path) {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) if is_corrupt(&e) => {
|
||||||
|
// Every byte in here is derived from the account or from the
|
||||||
|
// content cache directory, so the recovery is to start over —
|
||||||
|
// loudly, and keeping the damaged file for a post-mortem rather
|
||||||
|
// than deleting evidence.
|
||||||
|
tracing::error!(
|
||||||
|
db = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"cache database is corrupt — moving it aside and rebuilding \
|
||||||
|
(metadata will be re-fetched; no user data is stored here)"
|
||||||
|
);
|
||||||
|
quarantine_corrupt_db(path)?;
|
||||||
|
Self::open_configured(path)?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
// No pool for `:memory:`. A second connection to that name is a
|
||||||
|
// second, empty database rather than another view of this one, so
|
||||||
|
// every routed read would find a schema that does not exist.
|
||||||
|
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
idle: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
_single_writer: single_writer,
|
||||||
|
};
|
||||||
|
// A drain claim names a worker thread in the process that took it, and
|
||||||
|
// the single-writer lock above says that process is not running. Any
|
||||||
|
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||||
|
// hide those ops from every worker for good.
|
||||||
|
match db.clear_op_claims() {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||||
|
}
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection for a `SELECT`-only method.
|
||||||
|
///
|
||||||
|
/// Reads see the last committed state, which is the same thing they saw
|
||||||
|
/// through the write connection: every method here commits before it
|
||||||
|
/// returns, and nothing holds a transaction open across calls.
|
||||||
|
pub(super) fn read(&self) -> Reader<'_> {
|
||||||
|
if let Some(pool) = &self.readers
|
||||||
|
&& let Some(conn) = pool.take()
|
||||||
|
{
|
||||||
|
return Reader {
|
||||||
|
pooled: Some(conn),
|
||||||
|
writer: None,
|
||||||
|
pool: Some(pool),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Reader {
|
||||||
|
pooled: None,
|
||||||
|
writer: Some(self.conn.lock()),
|
||||||
|
pool: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||||
|
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||||
|
fn open_configured(path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
// WAL: readers never block the single writer. NORMAL sync is the
|
// WAL: readers never block the single writer. NORMAL sync is the
|
||||||
// standard durability/throughput tradeoff for WAL.
|
// standard durability/throughput tradeoff for WAL.
|
||||||
@@ -102,12 +302,39 @@ impl Db {
|
|||||||
// (Bare SQLite does default to 0, which is where the belief that this
|
// (Bare SQLite does default to 0, which is where the belief that this
|
||||||
// was unset came from. That default is not what we get.)
|
// was unset came from. That default is not what we get.)
|
||||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||||
|
// stores a JSON blob per node — a listing of a few thousand files
|
||||||
|
// evicts itself while it is being read.
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
// Sorts and recursive CTEs materialise their intermediate results.
|
||||||
|
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||||
|
// spills those to a file in `/tmp` by default; they are small and
|
||||||
|
// short-lived, so memory is both faster and less surprising.
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
// Read the database through the page cache of the OS rather than
|
||||||
|
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||||
|
// does not track the database size.
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
// Checkpointing is synchronous in whichever thread happens to commit
|
||||||
|
// the transaction that crosses the threshold — often a FUSE callback.
|
||||||
|
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||||
|
// rather than rare and multi-second.
|
||||||
|
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db.into_conn())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap the connection back out of a temporary handle. Only
|
||||||
|
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||||
|
/// (which is written against `&self`) before the real handle exists.
|
||||||
|
fn into_conn(self) -> Connection {
|
||||||
|
self.conn.into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database. For tests.
|
/// Open an in-memory database. For tests.
|
||||||
@@ -120,6 +347,8 @@ impl Db {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
@@ -133,5 +362,102 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `path` names the in-memory database rather than a file. Both
|
||||||
|
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||||
|
/// corruption story.
|
||||||
|
fn is_in_memory(path: &Path) -> bool {
|
||||||
|
let p = path.as_os_str();
|
||||||
|
p == ":memory:" || p.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the single-instance write lock for the database at `path`.
|
||||||
|
///
|
||||||
|
/// The daemon is the only writer by design — the front-ends go through the
|
||||||
|
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||||
|
/// alongside the systemd unit, at which point two processes own the same inode
|
||||||
|
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||||
|
/// locking makes each *statement* safe and does nothing about that.
|
||||||
|
///
|
||||||
|
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||||
|
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||||
|
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||||
|
/// the process, however it exits.
|
||||||
|
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||||
|
if is_in_memory(path) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let lock_path = lock_path_for(path);
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(&lock_path)?;
|
||||||
|
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||||
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||||
|
if rc != 0 {
|
||||||
|
let e = std::io::Error::last_os_error();
|
||||||
|
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||||
|
return Err(Error::Other(format!(
|
||||||
|
"another Proton Drive daemon is already using {} — \
|
||||||
|
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(Error::Io(e));
|
||||||
|
}
|
||||||
|
Ok(Some(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_path_for(path: &Path) -> PathBuf {
|
||||||
|
let mut name = path.as_os_str().to_owned();
|
||||||
|
name.push(".lock");
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||||
|
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||||
|
/// permission problem or a newer-than-supported schema must all still fail.
|
||||||
|
fn is_corrupt(e: &Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||||
|
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||||
|
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||||
|
/// open starts from empty.
|
||||||
|
///
|
||||||
|
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||||
|
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||||
|
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut from = path.as_os_str().to_owned();
|
||||||
|
from.push(suffix);
|
||||||
|
let from = PathBuf::from(from);
|
||||||
|
if !from.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut to = path.as_os_str().to_owned();
|
||||||
|
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||||
|
let to = PathBuf::from(to);
|
||||||
|
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||||
|
// WAL whose database header no longer matches — but failing to move the
|
||||||
|
// database itself is not, because the retry would hit the same file.
|
||||||
|
match std::fs::rename(&from, &to) {
|
||||||
|
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||||
|
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||||
|
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -74,7 +74,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every local location, with device fields joined from `sync_folder`.
|
/// Every local location, with device fields joined from `sync_folder`.
|
||||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
m.id,
|
m.id,
|
||||||
@@ -171,6 +171,7 @@ impl Db {
|
|||||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(mounts)
|
Ok(mounts)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use crate::{Access, Result};
|
|||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
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::{
|
||||||
|
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct StoredNode {
|
pub struct StoredNode {
|
||||||
pub node: Node,
|
pub node: Node,
|
||||||
@@ -59,7 +61,7 @@ impl Db {
|
|||||||
/// flag. Synthetic shared listings use this to serve the last completed
|
/// flag. Synthetic shared listings use this to serve the last completed
|
||||||
/// snapshot while offline, including after a live event expired its TTL.
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json FROM nodes
|
"SELECT node_json FROM nodes
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||||
@@ -285,7 +287,7 @@ impl Db {
|
|||||||
/// 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();
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
||||||
params![uid_str],
|
params![uid_str],
|
||||||
@@ -294,6 +296,25 @@ impl Db {
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node's mountpoint-relative path, or `None` when it is not cached.
|
||||||
|
///
|
||||||
|
/// One indexed read of the stored `path`. Callers that need to relate many
|
||||||
|
/// nodes to a handful of roots — search decorating each hit with its
|
||||||
|
/// mountpoint — resolve the roots once through this and do the rest as
|
||||||
|
/// string arithmetic, instead of a `path_relative_to` walk per pair.
|
||||||
|
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let exists: Option<i64> = conn
|
||||||
|
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(path_of(&conn, uid)?))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve `uid` relative to an ancestor node.
|
/// Resolve `uid` relative to an ancestor node.
|
||||||
///
|
///
|
||||||
/// This is used when a remote subtree has its own local sync mount: the
|
/// This is used when a remote subtree has its own local sync mount: the
|
||||||
@@ -302,7 +323,7 @@ impl Db {
|
|||||||
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
||||||
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
||||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
@@ -334,19 +355,36 @@ impl Db {
|
|||||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||||
/// nothing below 3 chars.
|
/// nothing below 3 chars.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||||
|
self.search_in(query, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`search`](Self::search) restricted to the subtree under the
|
||||||
|
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||||
|
/// everything.
|
||||||
|
pub fn search_in(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
scope: Option<&str>,
|
||||||
|
) -> Result<Vec<SearchHit>> {
|
||||||
|
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||||
|
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||||
|
// checked once their path is walked below.
|
||||||
|
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
|
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -356,18 +394,29 @@ impl Db {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
for (json, uid) in rows {
|
for (json, uid, path) in rows {
|
||||||
let node: Node = serde_json::from_str(&json)?;
|
let node: Node = serde_json::from_str(&json)?;
|
||||||
let path = path_of(&conn, &uid)?;
|
let path = match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
};
|
||||||
|
if let Some(scope) = scope
|
||||||
|
&& !path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
@@ -382,26 +431,26 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<(String, String)> = if terms.is_empty() {
|
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
let expression = terms.join(" OR ");
|
let expression = terms.join(" OR ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
// Trigram rank does not know that a name *starts* with the query, and a
|
// Trigram rank does not know that a name *starts* with the query, and a
|
||||||
// short substitution can destroy every trigram outright (`vedio` vs
|
// short substitution can destroy every trigram outright (`vedio` vs
|
||||||
@@ -411,7 +460,7 @@ impl Db {
|
|||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||||
AND trashed = 0 AND node_json IS NOT NULL
|
AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||||
@@ -421,23 +470,26 @@ impl Db {
|
|||||||
format!("{}%", like_escape(query)),
|
format!("{}%", like_escape(query)),
|
||||||
format!("{}%", like_escape(&first.to_string())),
|
format!("{}%", like_escape(&first.to_string())),
|
||||||
] {
|
] {
|
||||||
extra.extend(collect_pairs(
|
extra.extend(collect_hits(
|
||||||
stmt.query_map(params![pattern, lane_limit as i64], pair)?,
|
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
let trigram = std::mem::take(&mut rows);
|
let trigram = std::mem::take(&mut rows);
|
||||||
for row in extra.into_iter().chain(trigram) {
|
for row in extra.into_iter().chain(trigram) {
|
||||||
if !rows.iter().any(|(_, uid)| uid == &row.1) {
|
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.truncate(limit);
|
rows.truncate(limit);
|
||||||
}
|
}
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(json, uid)| {
|
.map(|(json, uid, path)| {
|
||||||
Ok(SearchHit {
|
Ok(SearchHit {
|
||||||
node: serde_json::from_str(&json)?,
|
node: serde_json::from_str(&json)?,
|
||||||
path: path_of(&conn, &uid)?,
|
path: match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -457,7 +509,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt =
|
let mut stmt =
|
||||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
@@ -478,7 +530,7 @@ impl Db {
|
|||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||||
/// (offline.md Phase 1).
|
/// (offline.md Phase 1).
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||||
@@ -496,7 +548,7 @@ impl Db {
|
|||||||
/// The daemon resumes from this on restart instead of reseeding to the
|
/// The daemon resumes from this on restart instead of reseeding to the
|
||||||
/// server head, so changes made while unmounted are still applied (P2).
|
/// server head, so changes made while unmounted are still applied (P2).
|
||||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let listed: Option<i64> = conn
|
let listed: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||||
@@ -529,13 +581,57 @@ 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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a node's row looked like before this upsert, for the fields that decide
|
||||||
|
/// whether the subtree below it has to be touched at all.
|
||||||
|
struct PriorRow {
|
||||||
|
parent_uid: Option<String>,
|
||||||
|
name: String,
|
||||||
|
path: Option<String>,
|
||||||
|
trashed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
let json = serde_json::to_string(node)?;
|
let json = serde_json::to_string(node)?;
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
|
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||||
|
|
||||||
|
let prior: Option<PriorRow> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| {
|
||||||
|
Ok(PriorRow {
|
||||||
|
parent_uid: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
trashed: row.get::<_, i64>(3)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
// A node's path is its parent's path plus its own name — one indexed lookup,
|
||||||
|
// where resolving it from scratch is a recursive walk to the root. A node
|
||||||
|
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||||
|
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||||
|
let path = match &parent_uid {
|
||||||
|
None => String::new(),
|
||||||
|
Some(parent) => {
|
||||||
|
let parent_path: Option<Option<String>> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT path FROM nodes WHERE uid = ?1",
|
||||||
|
params![parent],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO nodes
|
"INSERT INTO nodes
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
parent_uid = excluded.parent_uid,
|
parent_uid = excluded.parent_uid,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
@@ -543,65 +639,116 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
trashed = excluded.trashed,
|
trashed = excluded.trashed,
|
||||||
node_json = excluded.node_json",
|
node_json = excluded.node_json,
|
||||||
|
path = excluded.path",
|
||||||
params![
|
params![
|
||||||
uid,
|
uid,
|
||||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
parent_uid,
|
||||||
node.name,
|
node.name,
|
||||||
node.is_folder() as i64,
|
node.is_folder() as i64,
|
||||||
node_size(node),
|
node_size(node),
|
||||||
node.modification_time,
|
node.modification_time,
|
||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
json,
|
json,
|
||||||
|
path,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
// 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(
|
let rowid: i64 = tx.query_row(
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
let mut affected = vec![rowid];
|
|
||||||
if node.is_folder() {
|
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||||
// The depth cap is a liveness guard, not a policy: `UNION ALL` over a
|
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||||
// parent cycle (a's parent is b, b's parent is a — corrupt data, but the
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
// API can hand it to us) never terminates, and this runs inside the
|
if indexable {
|
||||||
// write transaction that holds the daemon's only SQLite connection.
|
|
||||||
// Real Drive trees are nowhere near this deep.
|
|
||||||
let mut stmt = tx.prepare(
|
|
||||||
"WITH RECURSIVE descendants(rowid, uid, depth) AS (
|
|
||||||
SELECT rowid, uid, 0 FROM nodes WHERE parent_uid = ?1
|
|
||||||
UNION ALL
|
|
||||||
SELECT n.rowid, n.uid, d.depth + 1 FROM nodes n
|
|
||||||
JOIN descendants d ON n.parent_uid = d.uid
|
|
||||||
WHERE d.depth < 256
|
|
||||||
) 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(
|
tx.execute(
|
||||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||||
params![affected_rowid],
|
params![rowid, node.name, path],
|
||||||
)?;
|
)?;
|
||||||
let indexed: Option<(String, String)> = tx
|
}
|
||||||
.query_row(
|
|
||||||
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
// Everything below only matters when the subtree's paths or reachability
|
||||||
params![affected_rowid],
|
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
// full descendant walk on each of those was most of what a large tree spent
|
||||||
)
|
// its time on.
|
||||||
.optional()?;
|
let subtree_moved = prior.is_none_or(|prior| {
|
||||||
if let Some((name, indexed_uid)) = indexed
|
prior.parent_uid != parent_uid
|
||||||
&& node_is_indexable_tx(tx, &indexed_uid)?
|
|| prior.name != node.name
|
||||||
{
|
|| prior.path.as_deref() != Some(path.as_str())
|
||||||
let path = path_of(tx, &indexed_uid)?;
|
|| prior.trashed != node.trashed
|
||||||
tx.execute(
|
});
|
||||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
if node.is_folder() && subtree_moved {
|
||||||
params![affected_rowid, name, path],
|
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||||
)?;
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite every descendant's stored path and search-index row after `folder`
|
||||||
|
/// moved, was renamed, or changed reachability.
|
||||||
|
///
|
||||||
|
/// One recursive walk carries both the new path and whether anything on the way
|
||||||
|
/// down is trashed, so each descendant costs two trivial statements instead of a
|
||||||
|
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
|
||||||
|
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
|
||||||
|
/// subtree is the same for every node in it.
|
||||||
|
///
|
||||||
|
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
|
||||||
|
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
|
||||||
|
/// it to us) never terminates, and this runs inside the write transaction that
|
||||||
|
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
|
||||||
|
/// this deep.
|
||||||
|
fn reindex_subtree_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
folder: &str,
|
||||||
|
folder_path: &str,
|
||||||
|
folder_indexable: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let descendants: Vec<(i64, String, bool)> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||||
|
n.trashed, 0
|
||||||
|
FROM nodes n WHERE n.parent_uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
sub.path || '/' || n.name,
|
||||||
|
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||||
|
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||||
|
WHERE sub.depth < 256
|
||||||
|
)
|
||||||
|
SELECT rowid, path, blocked FROM sub",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![folder, folder_path], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i64>(2)? != 0,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
if descendants.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||||
|
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||||
|
let mut index = tx.prepare(
|
||||||
|
"INSERT INTO nodes_fts (rowid, name, path)
|
||||||
|
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||||
|
)?;
|
||||||
|
for (rowid, path, blocked) in descendants {
|
||||||
|
set_path.execute(params![rowid, path])?;
|
||||||
|
unindex.execute(params![rowid])?;
|
||||||
|
if folder_indexable && !blocked {
|
||||||
|
index.execute(params![rowid, path])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ pub const LOCAL_VOLUME: &str = "local";
|
|||||||
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||||
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
|
/// How long a create may stay parked before the park is treated as permanent
|
||||||
|
/// and the bytes are let through anyway (ms).
|
||||||
|
///
|
||||||
|
/// The park has exactly one exit: a rename from the transient name to the
|
||||||
|
/// finished one. Every writer that reaches that rename does so in seconds — an
|
||||||
|
/// atomic-write scratch file immediately, a download when it completes. A park
|
||||||
|
/// still standing an hour later belongs to a writer that will never perform
|
||||||
|
/// that rename: an editor that deletes its swap file instead, a download the
|
||||||
|
/// user abandoned, or a file that simply *is* named `report.tmp` and is never
|
||||||
|
/// going to be called anything else.
|
||||||
|
///
|
||||||
|
/// Before this existed such a row sat at [`PARK_UNTIL`] for the life of the
|
||||||
|
/// database, holding the only copy of the file's bytes in `staging/` and
|
||||||
|
/// counting against the queue while promising progress that could not come. The
|
||||||
|
/// sweep un-parks it instead of dropping it: bytes the user can see in the mount
|
||||||
|
/// are bytes the user expects on Drive, and invariant 1 of the drain (a staged
|
||||||
|
/// blob is dropped only once its op has landed) admits no other answer.
|
||||||
|
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// A mutation that has been accepted locally but not yet performed against the
|
||||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||||
///
|
///
|
||||||
@@ -119,15 +138,32 @@ pub struct PendingOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How much work the queue owes the server, by whether it carries bytes.
|
/// How much work the queue owes the server, by whether it carries bytes.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PendingCounts {
|
pub struct PendingCounts {
|
||||||
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
||||||
pub uploads: i64,
|
pub uploads: i64,
|
||||||
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
||||||
/// told about.
|
/// told about.
|
||||||
pub changes: i64,
|
pub changes: i64,
|
||||||
|
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
|
||||||
|
/// the rename that gives it its finished name. They are queued but will
|
||||||
|
/// never drain on their own, so counting them as "waiting to upload" would
|
||||||
|
/// have the front-end promise progress that is not coming.
|
||||||
|
pub parked: i64,
|
||||||
|
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||||
|
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||||
|
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||||
|
pub failing: i64,
|
||||||
|
/// The most recent error text from a failing op, for the front-end to show
|
||||||
|
/// instead of the bare count.
|
||||||
|
pub last_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failed attempts after which an op is reported as failing rather than merely
|
||||||
|
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
|
||||||
|
/// is retrying at the slowest rate it ever will and has been for minutes.
|
||||||
|
pub const FAILING_ATTEMPTS: i64 = 6;
|
||||||
|
|
||||||
/// The outcome of folding freshly written bytes into a queued create.
|
/// The outcome of folding freshly written bytes into a queued create.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AttachedBlob {
|
pub struct AttachedBlob {
|
||||||
@@ -140,7 +176,7 @@ pub struct AttachedBlob {
|
|||||||
impl Db {
|
impl Db {
|
||||||
/// Whether a specific desired-state operation is still queued for a node.
|
/// Whether a specific desired-state operation is still queued for a node.
|
||||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||||
@@ -153,7 +189,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read an existing operation's metadata before a superseding enqueue.
|
/// Read an existing operation's metadata before a superseding enqueue.
|
||||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||||
params![uid, kind],
|
params![uid, kind],
|
||||||
@@ -352,7 +388,7 @@ impl Db {
|
|||||||
|
|
||||||
/// 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.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
||||||
params![uid, OP_CREATE, OP_MKDIR],
|
params![uid, OP_CREATE, OP_MKDIR],
|
||||||
@@ -368,7 +404,7 @@ impl Db {
|
|||||||
/// bytes are still owed an upload, and dropping it would discard the staged
|
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||||
/// blob that holds them (`docs/BUGS.md` B71).
|
/// blob that holds them (`docs/BUGS.md` B71).
|
||||||
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
@@ -464,7 +500,7 @@ impl Db {
|
|||||||
/// sort, and no separate index to maintain. The common case (something is
|
/// sort, and no separate index to maintain. The common case (something is
|
||||||
/// due) exits on the first row.
|
/// due) exits on the first row.
|
||||||
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(&format!(
|
let mut stmt = conn.prepare_cached(&format!(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -495,17 +531,115 @@ impl Db {
|
|||||||
Ok(op)
|
Ok(op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the oldest due op for this worker, marking it claimed so no other
|
||||||
|
/// drain worker takes it, or `None` when there is nothing to do.
|
||||||
|
///
|
||||||
|
/// The drain is several workers over one queue, which is only safe because
|
||||||
|
/// the claim is a column: a row is offered to exactly one worker, and the
|
||||||
|
/// worker that took it is the only one that may retire, defer or fail it.
|
||||||
|
///
|
||||||
|
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
|
||||||
|
///
|
||||||
|
/// * `claimed_at = 0` — not already in flight somewhere else.
|
||||||
|
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
|
||||||
|
/// node, but there it has to hold absolutely: a queued rename and a queued
|
||||||
|
/// revision of one file must land in the order they were made, and two
|
||||||
|
/// workers on one node would also race over its staged blob and its
|
||||||
|
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
|
||||||
|
/// order for free — the older op is claimed first and the newer one is
|
||||||
|
/// invisible until it retires.
|
||||||
|
///
|
||||||
|
/// Select and mark are one transaction, so two workers cannot both see the
|
||||||
|
/// row as unclaimed.
|
||||||
|
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let op = {
|
||||||
|
let mut stmt = tx.prepare_cached(&format!(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at <= ?1
|
||||||
|
AND claimed_at = 0
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
|
||||||
|
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||||
|
ORDER BY id LIMIT 1",
|
||||||
|
v = LOCAL_VOLUME,
|
||||||
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
|
))?;
|
||||||
|
stmt.query_row(params![now], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
if let Some(op) = op.as_ref() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
|
||||||
|
params![op.id, now.max(1)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give a claimed op back to the queue, whatever became of the attempt.
|
||||||
|
///
|
||||||
|
/// Called on every path out of a drain attempt rather than only the failing
|
||||||
|
/// ones: a handler that retires its own row leaves nothing to release (the
|
||||||
|
/// `UPDATE` matches nothing), and a handler that returns without retiring
|
||||||
|
/// *and* without failing would otherwise leave the row claimed by a worker
|
||||||
|
/// that has moved on — invisible to every worker, forever.
|
||||||
|
pub fn release_op_claim(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every claim in the table.
|
||||||
|
///
|
||||||
|
/// A claim describes a worker in *this* process, so one surviving on disk
|
||||||
|
/// means the previous run died holding it. The single-writer lock
|
||||||
|
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
|
||||||
|
/// draining this queue while we are opening it.
|
||||||
|
pub fn clear_op_claims(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
|
||||||
|
[],
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
||||||
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
|
///
|
||||||
|
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||||
|
/// not work this one is waiting for, and counting them would have an idle
|
||||||
|
/// worker spin on a row it cannot have.
|
||||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||||
WHERE parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~'",
|
WHERE claimed_at = 0 \
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')",
|
||||||
v = LOCAL_VOLUME,
|
v = LOCAL_VOLUME,
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
),
|
),
|
||||||
@@ -520,7 +654,7 @@ impl Db {
|
|||||||
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
||||||
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
||||||
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -546,6 +680,40 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every parked create/mkdir, oldest first, for the park sweep.
|
||||||
|
///
|
||||||
|
/// Parked rows are invisible to [`next_due_op`](Self::next_due_op) by
|
||||||
|
/// construction, so nothing else in the drain ever looks at them. This is
|
||||||
|
/// the one query that does.
|
||||||
|
pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3)
|
||||||
|
ORDER BY created_at",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many queued ops there are of each kind, for `Response::Status`.
|
/// How many queued ops there are of each kind, for `Response::Status`.
|
||||||
///
|
///
|
||||||
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
||||||
@@ -553,7 +721,7 @@ impl Db {
|
|||||||
/// mount owes the server, but it carries no bytes and reporting it as an
|
/// mount owes the server, but it carries no bytes and reporting it as an
|
||||||
/// upload is simply untrue.
|
/// upload is simply untrue.
|
||||||
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let uploads = conn.query_row(
|
let uploads = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
@@ -564,7 +732,33 @@ impl Db {
|
|||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(PendingCounts { uploads, changes })
|
let parked = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
|
||||||
|
params![PARK_UNTIL],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let failing = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let last_error = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_error FROM pending_op
|
||||||
|
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||||
|
ORDER BY attempts DESC LIMIT 1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten();
|
||||||
|
Ok(PendingCounts {
|
||||||
|
uploads,
|
||||||
|
changes,
|
||||||
|
parked,
|
||||||
|
failing,
|
||||||
|
last_error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||||
@@ -578,13 +772,43 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
|
||||||
|
/// left alone: they wait for a rename, not for time, and un-parking one would
|
||||||
|
/// upload a file its writer has not finished. Returns whether a row changed.
|
||||||
|
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
|
||||||
|
params![id, now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||||
|
/// least once. Returns how many rows changed.
|
||||||
|
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?1
|
||||||
|
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||||
|
params![now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||||
/// the staged bytes are still the only copy of the user's write.
|
/// the staged bytes are still the only copy of the user's write.
|
||||||
|
///
|
||||||
|
/// Clears any access-deferral window: whatever the op was waiting for has
|
||||||
|
/// now been reported, so the next uncleared deferral starts its own window
|
||||||
|
/// rather than escalating again on the following recheck.
|
||||||
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pending_op
|
"UPDATE pending_op
|
||||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
|
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
|
||||||
|
access_deferred_since = 0
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![id, error, next_attempt_at],
|
params![id, error, next_attempt_at],
|
||||||
)?;
|
)?;
|
||||||
@@ -609,4 +833,132 @@ impl Db {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defer an op the local access check refused, and report how long it has
|
||||||
|
/// been refused for.
|
||||||
|
///
|
||||||
|
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
|
||||||
|
/// on the retry clock, plus the one thing that was missing: the first
|
||||||
|
/// deferral of a run stamps `access_deferred_since`, and every deferral
|
||||||
|
/// returns that stamp. A caller can therefore tell a deferral that is one
|
||||||
|
/// recheck old from one that has been repeating unchanged for days, which is
|
||||||
|
/// the difference between a permission the user is mid-way through changing
|
||||||
|
/// and a queue that will never drain.
|
||||||
|
///
|
||||||
|
/// Returns the ms at which the current run of deferrals began — `now` on the
|
||||||
|
/// first one.
|
||||||
|
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op
|
||||||
|
SET next_attempt_at = CASE
|
||||||
|
WHEN next_attempt_at >= ?4 THEN next_attempt_at
|
||||||
|
ELSE ?3
|
||||||
|
END,
|
||||||
|
access_deferred_since = CASE
|
||||||
|
WHEN access_deferred_since = 0 THEN ?2
|
||||||
|
ELSE access_deferred_since
|
||||||
|
END
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, now, next_attempt_at, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
let since = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
// A row that drained out from under this call leaves nothing to measure;
|
||||||
|
// reporting `now` says "this run just started", which is the reading that
|
||||||
|
// never escalates something that no longer exists.
|
||||||
|
Ok(since.filter(|since| *since != 0).unwrap_or(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget an op's access-deferral window, after an attempt the access check
|
||||||
|
/// let through. The op is no longer blocked, so a later deferral is the
|
||||||
|
/// start of a new run rather than the continuation of an old one.
|
||||||
|
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET access_deferred_since = 0
|
||||||
|
WHERE id = ?1 AND access_deferred_since <> 0",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every staged blob the queue still refers to.
|
||||||
|
///
|
||||||
|
/// The startup staging reconcile subtracts this from what is actually in
|
||||||
|
/// `staging/`: the difference is bytes the kernel was told were written and
|
||||||
|
/// that nothing is going to upload.
|
||||||
|
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut out = std::collections::HashSet::new();
|
||||||
|
for row in rows {
|
||||||
|
out.insert(row?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
|
||||||
|
/// past [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// One row per node, overwritten as the node advances. See
|
||||||
|
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
|
||||||
|
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
revision_id = excluded.revision_id,
|
||||||
|
sealed_at = excluded.sealed_at",
|
||||||
|
params![uid, revision_id, now_ms],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
|
||||||
|
params![now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last revision of `uid` this daemon sealed itself, if it is still
|
||||||
|
/// within [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// The drain asks before treating a moved-on remote as a conflict: a
|
||||||
|
/// revision we sealed means one writer stalled and resumed, which
|
||||||
|
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
|
||||||
|
/// B70 layer B).
|
||||||
|
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT revision_id FROM own_sealed_rev
|
||||||
|
WHERE uid = ?1 AND sealed_at >= ?2",
|
||||||
|
params![uid, now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget what we sealed on `uid` — it has been trashed, so there is no
|
||||||
|
/// revision left to chain onto.
|
||||||
|
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a revision this daemon sealed stays recognisable as its own.
|
||||||
|
///
|
||||||
|
/// Bounds a table that would otherwise grow with every write for the life of
|
||||||
|
/// the install. A week is far longer than any stall→resume — a browser download
|
||||||
|
/// that pauses and continues, an editor that reopens its handle — and short
|
||||||
|
/// enough that the table tracks recent work rather than accumulating history.
|
||||||
|
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -34,67 +34,380 @@ pub struct StoredPhoto {
|
|||||||
pub kind: crate::control::PhotoKind,
|
pub kind: crate::control::PhotoKind,
|
||||||
/// Whether the photo carries Proton's `Favorite` tag.
|
/// Whether the photo carries Proton's `Favorite` tag.
|
||||||
pub favorite: bool,
|
pub favorite: bool,
|
||||||
|
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
|
||||||
|
/// two files and one group. `1` for a photo that stands on its own.
|
||||||
|
pub group_size: usize,
|
||||||
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
|
pub has_raw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
/// many photos it holds, and the newest of them.
|
||||||
/// `None` keeps whatever is already stored rather than clearing it.
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
pub struct StoredPlace {
|
||||||
|
pub id: u32,
|
||||||
|
pub count: usize,
|
||||||
|
pub cover: StoredPhoto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||||
|
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||||
|
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||||
|
/// and the capture time and resolving the node can fail.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct TimelineRow {
|
||||||
|
pub uid: String,
|
||||||
|
pub capture_time: i64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
pub favorite: Option<bool>,
|
||||||
|
/// The server's duplicate-detection hash for the plaintext.
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||||
|
/// sibling), when the server says it belongs to one.
|
||||||
|
pub main_uid: Option<String>,
|
||||||
|
/// Where the photo was taken, as `(latitude, longitude)` in decimal degrees.
|
||||||
|
pub location: Option<(f64, f64)>,
|
||||||
|
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||||
|
/// makes every other field authoritative — including a `None` that means
|
||||||
|
/// "the server no longer reports one" — while `None` means the refresh
|
||||||
|
/// skipped this photo and whatever is stored must be kept.
|
||||||
|
pub resolved_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineRow {
|
||||||
|
/// A row carrying only what the timeline listing itself gives.
|
||||||
|
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
uid: uid.into(),
|
||||||
|
capture_time,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a previous refresh learned about a photo and a new one must not lose.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct Learned {
|
||||||
|
name: Option<String>,
|
||||||
|
ratio: Option<f64>,
|
||||||
|
thumb_state: i64,
|
||||||
|
media_type: Option<String>,
|
||||||
|
favorite: bool,
|
||||||
|
content_hash: Option<String>,
|
||||||
|
main_uid: Option<String>,
|
||||||
|
location: Option<(f64, f64)>,
|
||||||
|
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||||
|
similar_hash: Option<i64>,
|
||||||
|
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||||
|
/// does not make it look unresolved again.
|
||||||
|
resolved_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
|
||||||
|
/// Grouping by day rather than by exact second is deliberate: a camera writes
|
||||||
|
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
|
||||||
|
/// subject on different days are not one photo.
|
||||||
|
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
|
||||||
|
params![capture_time],
|
||||||
|
|r| r.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
|
||||||
|
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
|
||||||
|
/// marker removed, so the two files a raw capture writes share a stem too.
|
||||||
|
fn name_stem(name: Option<&str>) -> Option<String> {
|
||||||
|
let name = name?;
|
||||||
|
let stem = match name.rsplit_once('.') {
|
||||||
|
Some((stem, _)) if !stem.is_empty() => stem,
|
||||||
|
_ => name,
|
||||||
|
};
|
||||||
|
let stem = stem.to_ascii_lowercase();
|
||||||
|
Some(strip_shot_marker(&stem).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a `.RAW-NN.ROLE` marker from a stem.
|
||||||
|
///
|
||||||
|
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
|
||||||
|
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
|
||||||
|
/// stems that differ in exactly the part naming which file of the shot this is.
|
||||||
|
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
|
||||||
|
/// on purpose — digits, then a single role word — so an ordinary name that
|
||||||
|
/// happens to contain `.raw-` keeps its stem.
|
||||||
|
fn strip_shot_marker(stem: &str) -> &str {
|
||||||
|
let Some(at) = stem.rfind(".raw-") else {
|
||||||
|
return stem;
|
||||||
|
};
|
||||||
|
let rest = &stem[at + ".raw-".len()..];
|
||||||
|
let Some((index, role)) = rest.split_once('.') else {
|
||||||
|
return stem;
|
||||||
|
};
|
||||||
|
let marked = !index.is_empty()
|
||||||
|
&& index.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
&& !role.is_empty()
|
||||||
|
&& role.bytes().all(|b| b.is_ascii_alphabetic());
|
||||||
|
if marked { &stem[..at] } else { stem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
|
||||||
|
/// representative needs.
|
||||||
|
struct Grouped {
|
||||||
|
uid: String,
|
||||||
|
kind: crate::control::PhotoKind,
|
||||||
|
main_uid: Option<String>,
|
||||||
|
day: Option<String>,
|
||||||
|
stem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which group each photo belongs to, and which member of it the gallery shows.
|
||||||
|
///
|
||||||
|
/// Precedence, as agreed:
|
||||||
|
///
|
||||||
|
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
|
||||||
|
/// photo's group. The server knows about live photos and bursts; we do not
|
||||||
|
/// have to guess at them.
|
||||||
|
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
|
||||||
|
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
|
||||||
|
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
|
||||||
|
/// to share a name apart.
|
||||||
|
/// 3. Otherwise the photo is its own group.
|
||||||
|
///
|
||||||
|
/// The representative is the non-raw member when the group has one — a JPEG
|
||||||
|
/// decodes in milliseconds and is what the person expects to see — and the
|
||||||
|
/// earliest member in server order otherwise.
|
||||||
|
fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
||||||
|
let mut parent: Vec<usize> = (0..entries.len()).collect();
|
||||||
|
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
let union = |parent: &mut [usize], a: usize, b: usize| {
|
||||||
|
let (a, b) = (find(parent, a), find(parent, b));
|
||||||
|
if a != b {
|
||||||
|
parent[b] = a;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let index: HashMap<&str, usize> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| (e.uid.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
if let Some(main) = entry.main_uid.as_deref()
|
||||||
|
&& let Some(&j) = index.get(main)
|
||||||
|
{
|
||||||
|
union(&mut parent, j, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
|
||||||
|
// the same bucket, and the raw / non-raw split is checked pairwise there.
|
||||||
|
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
|
||||||
|
by_name.entry((day, stem)).or_default().push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
|
||||||
|
for bucket in by_name.values() {
|
||||||
|
for (n, &i) in bucket.iter().enumerate() {
|
||||||
|
for &j in &bucket[n + 1..] {
|
||||||
|
if is_raw(i) != is_raw(j) {
|
||||||
|
union(&mut parent, i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Representative per root, then the key every member carries.
|
||||||
|
let mut representative: HashMap<usize, usize> = HashMap::new();
|
||||||
|
for i in 0..entries.len() {
|
||||||
|
let root = find(&mut parent, i);
|
||||||
|
match representative.get(&root).copied() {
|
||||||
|
Some(current) if !is_raw(current) || is_raw(i) => {}
|
||||||
|
_ => {
|
||||||
|
representative.insert(root, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0..entries.len())
|
||||||
|
.map(|i| {
|
||||||
|
let root = find(&mut parent, i);
|
||||||
|
entries[representative[&root]].uid.clone()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The columns every photo read selects, with the group aggregates joined in.
|
||||||
|
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
|
||||||
|
/// camera raw file — one query rather than a second lookup per tile.
|
||||||
|
///
|
||||||
|
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
|
||||||
|
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||||
|
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||||
|
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||||
|
p.kind, p.favorite, g.n, g.raw \
|
||||||
|
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||||
|
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||||
|
|
||||||
|
/// Read one row of [`PHOTO_SELECT`].
|
||||||
|
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||||
|
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)?),
|
||||||
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
|
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||||
|
has_raw: r.get::<_, i64>(8)? != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Replace the timeline wholesale. Each item is
|
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
/// read from the server in this pass, so its fields are taken as they are —
|
||||||
/// when the refresh could not resolve that photo's node, in which case the
|
/// a `None` there means the server no longer reports that value. A row whose
|
||||||
/// flag already stored is kept rather than silently cleared.
|
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
|
||||||
|
/// whatever is already stored rather than having it silently cleared.
|
||||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
// daemon may not know them when it replaces the timeline — because the
|
||||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
// refresh skipped an already-resolved photo, or because resolving it
|
||||||
// refresh instead of collapsing back to name-extension guesses.
|
// failed. Keep any previously learned value so the tab split and the
|
||||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||||
let mut stmt =
|
// both are derived from the name. The photo relation is kept for the same
|
||||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
// reason — losing it would break a group up until the next resolve.
|
||||||
|
let learned: HashMap<String, Learned> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||||
|
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, String>(0)?,
|
r.get::<_, String>(0)?,
|
||||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
Learned {
|
||||||
|
name: r.get(1)?,
|
||||||
|
ratio: r.get(2)?,
|
||||||
|
thumb_state: r.get(3)?,
|
||||||
|
media_type: r.get(4)?,
|
||||||
|
favorite: r.get::<_, i64>(5)? != 0,
|
||||||
|
content_hash: r.get(6)?,
|
||||||
|
main_uid: r.get(7)?,
|
||||||
|
resolved_at: r.get(8)?,
|
||||||
|
location: r
|
||||||
|
.get::<_, Option<f64>>(9)?
|
||||||
|
.zip(r.get::<_, Option<f64>>(10)?),
|
||||||
|
similar_hash: r.get(11)?,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everything the insert and the grouping need, resolved once per photo.
|
||||||
|
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
|
||||||
|
thumb_state: THUMB_UNKNOWN,
|
||||||
|
..Learned::default()
|
||||||
|
});
|
||||||
|
if row.resolved_at.is_some() {
|
||||||
|
// This pass read the node itself, so what it says is the
|
||||||
|
// truth — including what it no longer says. A photo unlinked
|
||||||
|
// from its main on another device loses its `main_uid` here,
|
||||||
|
// which a carry could never express.
|
||||||
|
carried.name = row.name.clone();
|
||||||
|
carried.media_type = row.media_type.clone();
|
||||||
|
carried.favorite = row.favorite.unwrap_or(false);
|
||||||
|
// New bytes may show another picture: hash it again.
|
||||||
|
if row.content_hash != carried.content_hash {
|
||||||
|
carried.similar_hash = None;
|
||||||
|
}
|
||||||
|
carried.content_hash = row.content_hash.clone();
|
||||||
|
carried.main_uid = row.main_uid.clone();
|
||||||
|
carried.location = row.location;
|
||||||
|
} else {
|
||||||
|
carried.name = row.name.clone().or(carried.name);
|
||||||
|
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||||
|
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||||
|
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||||
|
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||||
|
carried.location = row.location.or(carried.location);
|
||||||
|
}
|
||||||
|
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||||
|
// The tab this photo lands in is derived here, once, so a page or
|
||||||
|
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||||
|
// reclassification of every row.
|
||||||
|
let kind = crate::control::PhotoKind::classify(
|
||||||
|
carried.name.as_deref(),
|
||||||
|
carried.media_type.as_deref(),
|
||||||
|
);
|
||||||
|
(row, carried, kind)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let entries: Vec<Grouped> = resolved
|
||||||
|
.iter()
|
||||||
|
.map(|(row, carried, kind)| Grouped {
|
||||||
|
uid: row.uid.clone(),
|
||||||
|
kind: *kind,
|
||||||
|
main_uid: carried.main_uid.clone(),
|
||||||
|
day: capture_day(&tx, row.capture_time),
|
||||||
|
stem: name_stem(carried.name.as_deref()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let group_keys = group_photos(&entries);
|
||||||
|
|
||||||
tx.execute("DELETE FROM photos", [])?;
|
tx.execute("DELETE FROM photos", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||||
|
place_id, similar_hash)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||||
|
?16, ?17)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
.get(uid)
|
{
|
||||||
.cloned()
|
|
||||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
|
||||||
let media_type = media_type.clone().or(learned_media);
|
|
||||||
let favorite = favorite.unwrap_or(learned_favorite);
|
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
|
||||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
|
||||||
// reclassification of every row.
|
|
||||||
let kind =
|
|
||||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
uid,
|
row.uid,
|
||||||
capture_time,
|
row.capture_time,
|
||||||
name,
|
carried.name,
|
||||||
ratio,
|
carried.ratio,
|
||||||
thumb_state,
|
carried.thumb_state,
|
||||||
seq as i64,
|
seq as i64,
|
||||||
media_type,
|
carried.media_type,
|
||||||
kind.as_i64(),
|
kind.as_i64(),
|
||||||
favorite as i64,
|
carried.favorite as i64,
|
||||||
|
carried.content_hash,
|
||||||
|
carried.main_uid,
|
||||||
|
group_key,
|
||||||
|
carried.resolved_at,
|
||||||
|
carried.location.map(|(lat, _)| lat),
|
||||||
|
carried.location.map(|(_, lon)| lon),
|
||||||
|
carried
|
||||||
|
.location
|
||||||
|
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||||
|
.map(|city| city.id),
|
||||||
|
carried.similar_hash,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +415,93 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The uids whose node metadata is already resolved, so a refresh can skip
|
||||||
|
/// reading their nodes again. Everything else — a photo never seen before, or
|
||||||
|
/// one a remote event marked stale — is resolved. That is what keeps a
|
||||||
|
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
|
||||||
|
pub fn photos_resolved(&self) -> Result<Vec<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
uids.push(row?);
|
||||||
|
}
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark photos as needing a fresh metadata resolve, and report how many rows
|
||||||
|
/// that matched. This is how a remote change is recorded: the event feed says
|
||||||
|
/// *which* node changed but never *what* changed about it, so the uid is
|
||||||
|
/// written down and the next refresh reads the node.
|
||||||
|
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut marked = 0;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
|
||||||
|
for uid in uids {
|
||||||
|
marked += stmt.execute(params![uid])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(marked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same for the whole timeline: every photo is resolved again on the next
|
||||||
|
/// refresh. For a continuity loss, where the daemon cannot know what it
|
||||||
|
/// missed, and for an explicit `refresh photos --full`.
|
||||||
|
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget photos that have been trashed, so the gallery does not show them
|
||||||
|
/// until the next timeline refresh would have caught up.
|
||||||
|
///
|
||||||
|
/// Album membership goes with them: a trashed photo is gone from every album
|
||||||
|
/// that held it, and leaving the rows behind would keep it on the album
|
||||||
|
/// pages and in the album covers.
|
||||||
|
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut removed = 0;
|
||||||
|
{
|
||||||
|
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
|
||||||
|
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
|
||||||
|
for uid in uids {
|
||||||
|
removed += photos.execute([uid])?;
|
||||||
|
albums.execute([uid])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A group whose representative went is left pointing at nothing, which
|
||||||
|
// drops its other files off every page. Make them stand alone until the
|
||||||
|
// next refresh groups them again.
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
|
||||||
|
AND group_key NOT IN (SELECT uid FROM photos)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
/// One page of the persisted timeline, newest first. `kind`, when set,
|
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
/// `favorites` restricts the page to favourited photos.
|
/// `favorites` restricts the page to favorited photos, and `not_in_album`
|
||||||
|
/// to the shots no album holds any file of.
|
||||||
|
///
|
||||||
|
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||||
|
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||||
|
/// that tab exists to answer "which raws do I have".
|
||||||
pub fn photos_page(
|
pub fn photos_page(
|
||||||
&self,
|
&self,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
@@ -114,27 +509,38 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
|
not_in_album: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
// query rather than a statement per case.
|
// query rather than a statement per case.
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(PHOTO_SELECT);
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
|
||||||
);
|
|
||||||
let mut binds: Vec<i64> = Vec::new();
|
let mut binds: Vec<i64> = Vec::new();
|
||||||
let mut conds: Vec<String> = Vec::new();
|
let mut conds: Vec<String> = Vec::new();
|
||||||
|
if kind != Some(crate::control::PhotoKind::Raw) {
|
||||||
|
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
|
||||||
|
}
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
binds.push(k.as_i64());
|
binds.push(k.as_i64());
|
||||||
conds.push(format!("kind = ?{}", binds.len()));
|
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if let Some((from, to)) = range {
|
if let Some((from, to)) = range {
|
||||||
binds.push(from);
|
binds.push(from);
|
||||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||||
binds.push(to);
|
binds.push(to);
|
||||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if favorites {
|
if favorites {
|
||||||
conds.push("favorite = 1".to_string());
|
conds.push("p.favorite = 1".to_string());
|
||||||
|
}
|
||||||
|
// By group, not by file: a shot filed as its JPEG is filed, even though
|
||||||
|
// its RAW was never added anywhere.
|
||||||
|
if not_in_album {
|
||||||
|
conds.push(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM album_photos a JOIN photos m ON m.uid = a.uid \
|
||||||
|
WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid))"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if !conds.is_empty() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
@@ -145,22 +551,41 @@ impl Db {
|
|||||||
binds.push(offset as i64);
|
binds.push(offset as i64);
|
||||||
let offset_pos = binds.len();
|
let offset_pos = binds.len();
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||||
Ok(StoredPhoto {
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
uid: r.get(0)?,
|
Ok(rows)
|
||||||
capture_time: r.get(1)?,
|
}
|
||||||
name: r.get(2)?,
|
|
||||||
ratio: r.get(3)?,
|
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||||
thumb_state: r.get(4)?,
|
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
/// switches between these, and deleting a tile trashes all of them.
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
|
||||||
})
|
let conn = self.read();
|
||||||
})?
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
|
||||||
|
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![uid], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole persisted timeline, in stored order — every file, not one row
|
||||||
|
/// per group. Only for whole-library passes (the photo re-date); the gallery
|
||||||
|
/// pages with [`Db::photos_page`].
|
||||||
|
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], stored_photo)?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -174,10 +599,16 @@ impl Db {
|
|||||||
&self,
|
&self,
|
||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
|
// The scrubber has to agree with the page it scrubs, so it counts what
|
||||||
|
// the page shows: groups everywhere except on the Raw tab.
|
||||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||||
None => ("", Vec::new()),
|
Some(k) => (
|
||||||
|
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
|
||||||
|
vec![k.as_i64()],
|
||||||
|
),
|
||||||
|
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||||
@@ -198,11 +629,267 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos taken on `today`'s calendar day in earlier years, newest first —
|
||||||
|
/// the "On this day" strip. `today` is epoch seconds: its local month and day
|
||||||
|
/// pick the date, and its own year is left out, because today's photos are
|
||||||
|
/// what the timeline already opens on. One row per group, like the timeline.
|
||||||
|
pub fn photos_on_this_day(&self, today: i64, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) \
|
||||||
|
AND strftime('%m-%d', p.capture_time, 'unixepoch', 'localtime') \
|
||||||
|
= strftime('%m-%d', ?1, 'unixepoch', 'localtime') \
|
||||||
|
AND strftime('%Y', p.capture_time, 'unixepoch', 'localtime') \
|
||||||
|
< strftime('%Y', ?1, 'unixepoch', 'localtime') \
|
||||||
|
ORDER BY p.seq LIMIT ?2"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![today, limit as i64], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every town photos were taken in, with how many and the newest one as its
|
||||||
|
/// cover, most photographed first. Counted per group, like the timeline;
|
||||||
|
/// photos without a location, or taken far from any town, are in none.
|
||||||
|
pub fn photo_places(&self) -> Result<Vec<StoredPlace>> {
|
||||||
|
let rows: Vec<(u32, usize, String)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
// SQLite fills the bare `uid` from the row `MIN(seq)` picked, which
|
||||||
|
// is the newest photo of the place.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT place_id, COUNT(*), uid, MIN(seq) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) AND place_id IS NOT NULL \
|
||||||
|
GROUP BY place_id ORDER BY COUNT(*) DESC, MIN(seq)",
|
||||||
|
)?;
|
||||||
|
stmt.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as usize, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = rows.iter().map(|(_, _, uid)| uid.clone()).collect();
|
||||||
|
let mut covers: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, count, uid)| {
|
||||||
|
Some(StoredPlace {
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
cover: covers.remove(&uid)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of the photos taken in a town, newest first, one per group.
|
||||||
|
pub fn photos_at_place(
|
||||||
|
&self,
|
||||||
|
id: u32,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE p.uid = COALESCE(p.group_key, p.uid) AND p.place_id = ?1 \
|
||||||
|
ORDER BY p.seq LIMIT ?2 OFFSET ?3"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![id, limit as i64, offset as i64], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every photo taken in a town, one per group as in [`Self::photos_at_place`],
|
||||||
|
/// with where exactly it was taken. A group's own location is that of the
|
||||||
|
/// photo standing for it.
|
||||||
|
pub fn photo_locations_at_place(&self, id: u32) -> Result<Vec<(StoredPhoto, (f64, f64))>> {
|
||||||
|
let photos = self.photos_at_place(id, 0, usize::MAX >> 1)?;
|
||||||
|
let located: HashMap<String, (f64, f64)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT uid, latitude, longitude FROM photos \
|
||||||
|
WHERE place_id = ?1 AND latitude IS NOT NULL AND longitude IS NOT NULL",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![id], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?))))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
Ok(photos
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|photo| {
|
||||||
|
let at = *located.get(&photo.uid)?;
|
||||||
|
Some((photo, at))
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of photos with the same content hash, each set ordered with the
|
||||||
|
/// copy worth keeping first, and the sets newest first.
|
||||||
|
///
|
||||||
|
/// The copy to keep is the one in the most albums, then the one that is part
|
||||||
|
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||||
|
/// not), then the earliest in server order. At most one file per group is
|
||||||
|
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||||
|
/// set left with one member is no set.
|
||||||
|
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, String, String)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||||
|
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||||
|
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||||
|
ORDER BY p.content_hash, \
|
||||||
|
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||||
|
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||||
|
p.seq",
|
||||||
|
)?;
|
||||||
|
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||||
|
let mut stored: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||||
|
let mut hash_of_set: Option<&str> = None;
|
||||||
|
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||||
|
for (uid, hash, group) in &rows {
|
||||||
|
if hash_of_set != Some(hash.as_str()) {
|
||||||
|
hash_of_set = Some(hash);
|
||||||
|
groups_in_set.clear();
|
||||||
|
sets.push(Vec::new());
|
||||||
|
}
|
||||||
|
if !groups_in_set.insert(group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||||
|
set.push(photo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sets.retain(|set| set.len() > 1);
|
||||||
|
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||||
|
Ok(sets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||||
|
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||||
|
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||||
|
/// any of its files is, since they all show one picture.
|
||||||
|
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let uids: Vec<String> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||||
|
GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NULL \
|
||||||
|
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||||
|
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(
|
||||||
|
params![
|
||||||
|
crate::control::PhotoKind::Video.as_i64(),
|
||||||
|
THUMB_NONE,
|
||||||
|
limit as i64
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
self.photos_by_uid(&uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the look of photo `uid`: its difference hash, or
|
||||||
|
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||||
|
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||||
|
params![uid, hash as i64],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many shots have been hashed, out of how many could be.
|
||||||
|
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||||
|
let conn = self.read();
|
||||||
|
let (hashed, total): (i64, i64) = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||||
|
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||||
|
GROUP BY COALESCE(group_key, uid) \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL \
|
||||||
|
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||||
|
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||||
|
/// shot in capture order, and the sets newest first. A set whose photos
|
||||||
|
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||||
|
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||||
|
let rows: Vec<(String, u64, Option<String>)> = {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||||
|
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||||
|
FROM photos GROUP BY k \
|
||||||
|
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||||
|
)?;
|
||||||
|
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||||
|
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<_>>()?
|
||||||
|
};
|
||||||
|
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||||
|
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|set| {
|
||||||
|
let first = &rows[set[0]].2;
|
||||||
|
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||||
|
let mut stored: HashMap<String, StoredPhoto> = self
|
||||||
|
.photos_by_uid(&uids)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|photo| (photo.uid.clone(), photo))
|
||||||
|
.collect();
|
||||||
|
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||||
|
.iter()
|
||||||
|
.map(|set| {
|
||||||
|
let mut photos: Vec<StoredPhoto> = set
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||||
|
.collect();
|
||||||
|
photos.sort_by_key(|p| p.capture_time);
|
||||||
|
photos
|
||||||
|
})
|
||||||
|
.filter(|set| set.len() > 1)
|
||||||
|
.collect();
|
||||||
|
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||||
|
Ok(sets)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
// Photos and Videos count groups — one shot is one entry, however many
|
||||||
|
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||||
|
// them individually.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT kind, COUNT(*) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -223,23 +910,11 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
WHERE uid IN ({placeholders})"
|
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||||
))?;
|
|
||||||
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)?),
|
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
photos.push(row?);
|
photos.push(row?);
|
||||||
@@ -247,7 +922,7 @@ impl Db {
|
|||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a photo's favourite flag locally, after the server accepted the
|
/// Record a photo's favorite flag locally, after the server accepted the
|
||||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||||
/// someone else's volume is never in our own `photos` table.
|
/// someone else's volume is never in our own `photos` table.
|
||||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||||
@@ -273,7 +948,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of photos in the persisted timeline.
|
/// Number of photos in the persisted timeline.
|
||||||
pub fn photos_count(&self) -> Result<usize> {
|
pub fn photos_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use rusqlite::{OptionalExtension, params};
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
|
use super::utils::MAX_PATH_DEPTH;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||||
@@ -39,7 +40,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every directly-pinned entry, ordered by uid.
|
/// Every directly-pinned entry, ordered by uid.
|
||||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||||
// NULL for a pin whose node was never cached.
|
// NULL for a pin whose node was never cached.
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -66,8 +67,13 @@ impl Db {
|
|||||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||||
|
///
|
||||||
|
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
||||||
|
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||||
|
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||||
|
/// that runs per cached read.
|
||||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let direct: Option<i64> = conn
|
let direct: Option<i64> = conn
|
||||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||||
r.get(0)
|
r.get(0)
|
||||||
@@ -78,14 +84,18 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let anc: Option<i64> = conn
|
let anc: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid) AS (
|
&format!(
|
||||||
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
|
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
||||||
UNION ALL
|
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
||||||
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
UNION ALL
|
||||||
)
|
SELECT n.uid, n.parent_uid, anc.depth + 1
|
||||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||||
WHERE a.uid != ?1 AND p.recursive = 1
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
LIMIT 1",
|
)
|
||||||
|
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||||
|
WHERE a.uid != ?1 AND p.recursive = 1
|
||||||
|
LIMIT 1"
|
||||||
|
),
|
||||||
params![uid],
|
params![uid],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -97,7 +107,7 @@ impl Db {
|
|||||||
/// every descendant of a recursively-pinned folder. Used to build the
|
/// every descendant of a recursively-pinned folder. Used to build the
|
||||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM pins WHERE recursive = 1
|
SELECT uid FROM pins WHERE recursive = 1
|
||||||
@@ -119,7 +129,7 @@ impl Db {
|
|||||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ impl Db {
|
|||||||
/// remains the restart/offline authority and every explicit role change is
|
/// remains the restart/offline authority and every explicit role change is
|
||||||
/// written through before this cache is updated.
|
/// written through before this cache is updated.
|
||||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
@@ -42,7 +42,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read the effective access recorded for a shared-tree root.
|
/// Read the effective access recorded for a shared-tree root.
|
||||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let value = conn
|
let value = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||||
@@ -96,7 +96,7 @@ impl Db {
|
|||||||
/// share root. `None` means the node itself is not persisted, which callers
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
/// must not treat as owned: stale handles need to fail closed.
|
/// must not treat as owned: stale handles need to fail closed.
|
||||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let exists: bool = conn.query_row(
|
let exists: bool = conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||||
[uid.to_string()],
|
[uid.to_string()],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::Result;
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||||
@@ -32,7 +32,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read a `sync_state` value as a string.
|
/// Read a `sync_state` value as a string.
|
||||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
@@ -66,7 +66,7 @@ impl Db {
|
|||||||
/// dropping it would leave a file that exists nowhere but this machine with
|
/// dropping it would leave a file that exists nowhere but this machine with
|
||||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v: Option<String> = conn
|
let v: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
|||||||
pub remote_uid: Option<String>,
|
pub remote_uid: Option<String>,
|
||||||
pub local_mtime: i64,
|
pub local_mtime: i64,
|
||||||
pub local_size: i64,
|
pub local_size: i64,
|
||||||
|
/// The same modification time to nanosecond precision, when it is known.
|
||||||
|
///
|
||||||
|
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||||
|
/// second as the last sync — and left the file the same length — from no
|
||||||
|
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||||
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
|
pub local_mtime_ns: Option<i64>,
|
||||||
pub remote_rev: Option<String>,
|
pub remote_rev: Option<String>,
|
||||||
pub remote_hash: Option<String>,
|
pub remote_hash: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -57,7 +65,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every synced folder, oldest first.
|
/// Every synced folder, oldest first.
|
||||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder ORDER BY id",
|
FROM sync_folder ORDER BY id",
|
||||||
@@ -83,7 +91,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Look up one synced folder by id.
|
/// Look up one synced folder by id.
|
||||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder WHERE id = ?1",
|
FROM sync_folder WHERE id = ?1",
|
||||||
@@ -166,9 +174,10 @@ impl Db {
|
|||||||
|
|
||||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||||
|
local_mtime_ns
|
||||||
FROM sync_entry WHERE folder_id = ?1",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![folder_id], |r| {
|
let rows = stmt.query_map(params![folder_id], |r| {
|
||||||
@@ -179,6 +188,7 @@ impl Db {
|
|||||||
local_size: r.get(3)?,
|
local_size: r.get(3)?,
|
||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
|
local_mtime_ns: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -196,7 +206,7 @@ impl Db {
|
|||||||
/// `sync_folder` and descendants come from the last successful
|
/// `sync_folder` and descendants come from the last successful
|
||||||
/// `sync_entry` baseline.
|
/// `sync_entry` baseline.
|
||||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -216,14 +226,16 @@ impl Db {
|
|||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_entry
|
"INSERT INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
remote_hash, local_mtime_ns)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||||
remote_uid = excluded.remote_uid,
|
remote_uid = excluded.remote_uid,
|
||||||
local_mtime = excluded.local_mtime,
|
local_mtime = excluded.local_mtime,
|
||||||
local_size = excluded.local_size,
|
local_size = excluded.local_size,
|
||||||
remote_rev = excluded.remote_rev,
|
remote_rev = excluded.remote_rev,
|
||||||
remote_hash = excluded.remote_hash",
|
remote_hash = excluded.remote_hash,
|
||||||
|
local_mtime_ns = excluded.local_mtime_ns",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
e.rel_path,
|
e.rel_path,
|
||||||
@@ -232,6 +244,7 @@ impl Db {
|
|||||||
e.local_size,
|
e.local_size,
|
||||||
e.remote_rev,
|
e.remote_rev,
|
||||||
e.remote_hash,
|
e.remote_hash,
|
||||||
|
e.local_mtime_ns,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
//! Trashed nodes, persisted so the trash view opens without a round-trip.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
@@ -11,6 +13,13 @@ pub struct StoredTrash {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
pub mtime: i64,
|
pub mtime: i64,
|
||||||
|
/// The folder this node was trashed from, when it is known. `None` for a
|
||||||
|
/// row written before schema v29, and for a node whose parent the server
|
||||||
|
/// did not report.
|
||||||
|
pub parent_uid: Option<String>,
|
||||||
|
/// When the daemon first saw the node in the trash, epoch seconds. `None`
|
||||||
|
/// when it cannot say (see schema v35).
|
||||||
|
pub trashed_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -20,7 +29,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid, trashed_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -28,7 +38,9 @@ impl Db {
|
|||||||
item.name,
|
item.name,
|
||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime
|
item.mtime,
|
||||||
|
item.parent_uid,
|
||||||
|
item.trashed_at
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,9 +51,9 @@ impl Db {
|
|||||||
/// The persisted trash listing, folders first then by name — the order the
|
/// The persisted trash listing, folders first then by name — the order the
|
||||||
/// Trash page shows it in.
|
/// Trash page shows it in.
|
||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid, trashed_at FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -51,6 +63,8 @@ impl Db {
|
|||||||
is_dir: r.get::<_, i64>(2)? != 0,
|
is_dir: r.get::<_, i64>(2)? != 0,
|
||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
|
parent_uid: r.get(5)?,
|
||||||
|
trashed_at: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -60,5 +74,33 @@ impl Db {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every trashed node paired with the folder it was trashed from, for the
|
||||||
|
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
|
||||||
|
/// restore wants only the links, not the names, sizes or ordering.
|
||||||
|
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
pairs.push(row?);
|
||||||
|
}
|
||||||
|
Ok(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When each node now in the trash was first seen there, keyed by uid, so
|
||||||
|
/// a refresh can carry the times over into the listing it writes.
|
||||||
|
pub fn trash_seen(&self) -> Result<HashMap<String, Option<i64>>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, trashed_at FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut seen = HashMap::new();
|
||||||
|
for row in rows {
|
||||||
|
let (uid, at) = row?;
|
||||||
|
seen.insert(uid, at);
|
||||||
|
}
|
||||||
|
Ok(seen)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync (devices.md) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Small query helpers shared across the table modules.
|
//! Small query helpers shared across the table modules.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
@@ -8,14 +8,19 @@ use crate::Result;
|
|||||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||||
|
|
||||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
/// only for a row written before the `path` column existed, which
|
||||||
|
/// [`path_of`] repairs by walking.
|
||||||
|
pub(super) type HitRow = (String, String, Option<String>);
|
||||||
|
|
||||||
|
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||||
pub(super) fn collect_pairs(
|
pub(super) fn collect_hits(
|
||||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||||
) -> Result<Vec<(String, String)>> {
|
) -> Result<Vec<HitRow>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
out.push(r?);
|
out.push(r?);
|
||||||
@@ -35,18 +40,44 @@ pub(super) fn like_escape(s: &str) -> String {
|
|||||||
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||||
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||||
/// directly and so never passed through the index's cycle check.
|
/// directly and so never passed through the index's cycle check.
|
||||||
const MAX_PATH_DEPTH: usize = 256;
|
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||||
|
|
||||||
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
|
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||||
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
|
/// whose name is the mount and so contributes nothing.
|
||||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||||
|
if parent_path.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent_path}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||||
|
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||||
|
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||||
|
///
|
||||||
|
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||||
|
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||||
|
/// column or was never written through — a search serving a keystroke used to
|
||||||
|
/// run that walk once per candidate, which is the reason the column exists.
|
||||||
|
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
|
let stored: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
match stored.flatten() {
|
||||||
|
Some(path) => Ok(path),
|
||||||
|
None => walk_path_of(conn, uid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
||||||
///
|
///
|
||||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
||||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||||
/// and returns the truncated path rather than hanging the caller. That caller is
|
/// and returns the truncated path rather than hanging the caller.
|
||||||
/// usually a search serving a keystroke, holding the daemon's only SQLite
|
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
/// connection.
|
|
||||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub enum Error {
|
|||||||
Proton(#[from] proton_sdk::ProtonError),
|
Proton(#[from] proton_sdk::ProtonError),
|
||||||
|
|
||||||
#[error("keyring: {0}")]
|
#[error("keyring: {0}")]
|
||||||
Keyring(#[from] keyring::Error),
|
Keyring(#[from] keyring_core::Error),
|
||||||
|
|
||||||
#[error("serialization: {0}")]
|
#[error("serialization: {0}")]
|
||||||
Serde(#[from] serde_json::Error),
|
Serde(#[from] serde_json::Error),
|
||||||
@@ -45,6 +45,41 @@ pub enum Error {
|
|||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||||
|
/// other I/O failure.
|
||||||
|
///
|
||||||
|
/// Worth distinguishing because it is the one storage failure the user can
|
||||||
|
/// act on, and because the caller's response differs: a full disk is met by
|
||||||
|
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||||
|
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||||
|
/// compares the raw code.
|
||||||
|
pub fn is_disk_full(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||||
|
// SQLite reports it as its own code rather than surfacing the errno.
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||||
|
e.code == rusqlite::ErrorCode::DiskFull
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||||
|
/// daemon is running but stuck, as opposed to not running at all.
|
||||||
|
pub fn is_timeout(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Error::Io(e) if matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ pub mod localindex;
|
|||||||
pub mod menu;
|
pub mod menu;
|
||||||
pub mod mounts;
|
pub mod mounts;
|
||||||
pub mod opener;
|
pub mod opener;
|
||||||
|
pub mod places;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sdkcache;
|
pub mod sdkcache;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod similar;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
pub mod takeout;
|
||||||
|
pub mod tray;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
|||||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||||
/// dirs, which hold blobs no user searches for by name.
|
/// dirs, which hold blobs no user searches for by name.
|
||||||
|
///
|
||||||
|
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||||
|
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||||
vec![
|
vec![
|
||||||
mountpoint.to_path_buf(),
|
mountpoint.to_path_buf(),
|
||||||
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||||
|
///
|
||||||
|
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||||
|
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||||
|
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||||
|
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||||
|
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||||
|
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||||
|
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||||
|
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||||
|
/// (docs/BUGS.md B90).
|
||||||
|
///
|
||||||
|
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||||
|
/// change what ends up indexed: a nested mount is a different device, so
|
||||||
|
/// `same_file_system(true)` was already going to skip it.
|
||||||
|
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
parse_nested_mount_points(&mounts, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||||
|
/// be tested without a real `/proc`.
|
||||||
|
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_whitespace().nth(1))
|
||||||
|
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||||
|
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||||
|
// whole scan.
|
||||||
|
.filter(|path| path.starts_with(root) && path != root)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||||
|
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||||
|
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||||
|
fn unescape_mount_field(field: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(field.len());
|
||||||
|
let mut rest = field;
|
||||||
|
while let Some(at) = rest.find('\\') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
let escape = rest[at + 1..].get(..3);
|
||||||
|
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||||
|
Some(byte) => {
|
||||||
|
out.push(byte as char);
|
||||||
|
rest = &rest[at + 4..];
|
||||||
|
}
|
||||||
|
// Not an octal escape after all — keep the backslash verbatim.
|
||||||
|
None => {
|
||||||
|
out.push('\\');
|
||||||
|
rest = &rest[at + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -268,4 +331,42 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||||
|
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||||
|
/// being a mountpoint must not exclude the entire walk.
|
||||||
|
#[test]
|
||||||
|
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||||
|
let mounts = "\
|
||||||
|
/dev/sda2 /home ext4 rw 0 0
|
||||||
|
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||||
|
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||||
|
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||||
|
";
|
||||||
|
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||||
|
assert_eq!(
|
||||||
|
nested,
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/home/narl/remote/narl.io"),
|
||||||
|
PathBuf::from("/home/narl/remote/gdrive"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||||
|
// scan. `/run/user/1000` is unrelated.
|
||||||
|
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||||
|
/// mount whose path contains one back into the walk.
|
||||||
|
#[test]
|
||||||
|
fn mount_paths_are_unescaped() {
|
||||||
|
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||||
|
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||||
|
);
|
||||||
|
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||||
|
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,25 @@ const KNOWN_MENUS: [MenuFlavor; 6] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first.
|
||||||
|
///
|
||||||
|
/// The fzf front end is a terminal program, so a keybinding that has no tty
|
||||||
|
/// needs one spawned for it. Each entry sets a stable app-id/class
|
||||||
|
/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this
|
||||||
|
/// window, and ends with the flag after which the command to run follows.
|
||||||
|
const KNOWN_TERMINALS: [&[&str]; 6] = [
|
||||||
|
&["foot", "--app-id=pdfs-prompt", "--"],
|
||||||
|
&["ghostty", "--class=pdfs-prompt", "-e"],
|
||||||
|
&["kitty", "--class", "pdfs-prompt"],
|
||||||
|
&["alacritty", "--class", "pdfs-prompt", "-e"],
|
||||||
|
&["wezterm", "start", "--class", "pdfs-prompt", "--"],
|
||||||
|
&["xterm", "-class", "pdfs-prompt", "-e"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a
|
||||||
|
/// user writes for one terminal keeps working if they switch.
|
||||||
|
pub const TERMINAL_APP_ID: &str = "pdfs-prompt";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PromptFlag {
|
enum PromptFlag {
|
||||||
/// `--prompt=TEXT`
|
/// `--prompt=TEXT`
|
||||||
@@ -105,6 +124,13 @@ pub struct PromptConfig {
|
|||||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub menu: Option<Vec<String>>,
|
pub menu: Option<Vec<String>>,
|
||||||
|
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
|
||||||
|
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
|
||||||
|
/// anywhere in the argv is replaced by the command to run; without one the
|
||||||
|
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
|
||||||
|
/// expects.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terminal: Option<Vec<String>>,
|
||||||
/// How many results to feed the launcher. Unset means
|
/// How many results to feed the launcher. Unset means
|
||||||
/// [`DEFAULT_MENU_LIMIT`].
|
/// [`DEFAULT_MENU_LIMIT`].
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -124,6 +150,8 @@ pub enum PromptMode {
|
|||||||
Gtk,
|
Gtk,
|
||||||
/// The user's own launcher, driven over stdin/stdout.
|
/// The user's own launcher, driven over stdin/stdout.
|
||||||
Dmenu,
|
Dmenu,
|
||||||
|
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
|
||||||
|
Fzf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||||
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
|||||||
Some(argv)
|
Some(argv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
|
||||||
|
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
|
||||||
|
/// including the "run this command" flag — the caller appends the command, or
|
||||||
|
/// substitutes it for a `{cmd}` token, via [`with_command`].
|
||||||
|
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||||
|
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||||
|
return Some(argv.clone());
|
||||||
|
}
|
||||||
|
let flavor = KNOWN_TERMINALS
|
||||||
|
.iter()
|
||||||
|
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
|
||||||
|
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
|
||||||
|
/// user wrote one, otherwise appended.
|
||||||
|
///
|
||||||
|
/// The token exists for terminals that want the command in the middle of their
|
||||||
|
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
|
||||||
|
/// last element is the flag the command follows.
|
||||||
|
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
|
||||||
|
if !argv.iter().any(|arg| arg == "{cmd}") {
|
||||||
|
let mut argv = argv.to_vec();
|
||||||
|
argv.extend_from_slice(command);
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(argv.len() + command.len());
|
||||||
|
for arg in argv {
|
||||||
|
if arg == "{cmd}" {
|
||||||
|
out.extend_from_slice(command);
|
||||||
|
} else {
|
||||||
|
out.push(arg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||||
///
|
///
|
||||||
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||||
@@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
|||||||
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_path(program: &str) -> bool {
|
/// Whether `program` is an executable file on `PATH`.
|
||||||
|
pub fn on_path(program: &str) -> bool {
|
||||||
let Some(path) = std::env::var_os("PATH") else {
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -408,6 +474,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_configured_terminal_is_used_verbatim() {
|
||||||
|
let configured = vec!["my-term".to_string(), "-e".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_terminal(Some(&configured)),
|
||||||
|
Some(configured.clone())
|
||||||
|
);
|
||||||
|
// An empty list is "not configured", not "run nothing".
|
||||||
|
assert_eq!(
|
||||||
|
resolve_terminal(Some(&Vec::new())),
|
||||||
|
resolve_terminal(None),
|
||||||
|
"an empty terminal list must fall back to detection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_is_appended_unless_the_user_placed_it() {
|
||||||
|
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
with_command(&["foot".into(), "--".into()], &command),
|
||||||
|
vec!["foot", "--", "pdfs-prompt", "--fzf"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
with_command(
|
||||||
|
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
|
||||||
|
&command
|
||||||
|
),
|
||||||
|
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_known_terminal_sets_the_same_app_id() {
|
||||||
|
for argv in KNOWN_TERMINALS {
|
||||||
|
assert!(
|
||||||
|
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
|
||||||
|
"{argv:?} must be floatable by one window rule"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_configured_launcher_is_used_verbatim() {
|
fn a_configured_launcher_is_used_verbatim() {
|
||||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
|||||||
/// Live device sync progress, absent when no pass is running.
|
/// Live device sync progress, absent when no pass is running.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub progress: Option<SyncProgress>,
|
pub progress: Option<SyncProgress>,
|
||||||
|
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
|||||||
spawn(&argv, path);
|
spawn(&argv, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch `argv`, detached from whatever is opening it.
|
||||||
|
///
|
||||||
|
/// The child gets its own process group and no inherited stdio, because the
|
||||||
|
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
|
||||||
|
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
|
||||||
|
/// closes the moment the selection is made. A child left in the terminal's
|
||||||
|
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
|
||||||
|
/// "open this folder in a terminal" would open a window that dies immediately
|
||||||
|
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
|
||||||
|
/// first place: every `argv` is another application, and a terminal one is
|
||||||
|
/// wrapped in its own emulator by the `terminal` rule flag.
|
||||||
fn spawn(argv: &[String], path: &Path) {
|
fn spawn(argv: &[String], path: &Path) {
|
||||||
let Some((program, args)) = argv.split_first() else {
|
let Some((program, args)) = argv.split_first() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||||
if let Err(e) = Command::new(program).args(args).spawn() {
|
let spawned = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.process_group(0)
|
||||||
|
.spawn();
|
||||||
|
if let Err(e) = spawned {
|
||||||
tracing::error!(command = ?argv, "open failed: {e}");
|
tracing::error!(command = ?argv, "open failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
//! Offline reverse geocoding: the town a photo was taken in, from its GPS
|
||||||
|
//! position, without asking any service.
|
||||||
|
//!
|
||||||
|
//! The towns are GeoNames' `cities15000` dump (every place with more than
|
||||||
|
//! 15,000 people), trimmed to id, name, position and country in
|
||||||
|
//! `data/cities15000.tsv` by `scripts/geonames-cities.py`. The script drops the
|
||||||
|
//! parts of a city, such as Kreuzberg or "Paris 15 Vaugirard", so that a photo
|
||||||
|
//! lands in the town people would name. GeoNames publishes the dump under
|
||||||
|
//! CC BY 4.0, which is why the About dialog credits it.
|
||||||
|
//!
|
||||||
|
//! `data/countries.tsv` maps each country code to its ISO 3166-1 English name,
|
||||||
|
//! as the `iso-codes` project spells it, so a front end can translate it
|
||||||
|
//! through that project's `iso_3166-1` gettext domain.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
/// How far from the nearest town a photo may be and still count as taken
|
||||||
|
/// there, in km. Farther out (at sea, in the wilderness) it has no place.
|
||||||
|
pub const MAX_DISTANCE_KM: f64 = 50.0;
|
||||||
|
|
||||||
|
/// Mean Earth radius in km, for the haversine distance.
|
||||||
|
const EARTH_RADIUS_KM: f64 = 6371.0;
|
||||||
|
|
||||||
|
const CITIES: &str = include_str!("../data/cities15000.tsv");
|
||||||
|
const COUNTRIES: &str = include_str!("../data/countries.tsv");
|
||||||
|
|
||||||
|
/// One town of the table.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct City {
|
||||||
|
/// The GeoNames id, stable across dumps.
|
||||||
|
pub id: u32,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
/// ISO 3166-1 alpha-2 country code.
|
||||||
|
pub country: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The towns, and an index of them by whole-degree cell.
|
||||||
|
struct Table {
|
||||||
|
cities: Vec<City>,
|
||||||
|
cells: HashMap<(i32, i32), Vec<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table() -> &'static Table {
|
||||||
|
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||||
|
TABLE.get_or_init(|| {
|
||||||
|
let cities: Vec<City> = CITIES.lines().filter_map(parse_city).collect();
|
||||||
|
let mut cells: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||||
|
for (index, city) in cities.iter().enumerate() {
|
||||||
|
cells
|
||||||
|
.entry(cell(city.latitude, city.longitude))
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
Table { cities, cells }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_city(line: &'static str) -> Option<City> {
|
||||||
|
let mut fields = line.split('\t');
|
||||||
|
Some(City {
|
||||||
|
id: fields.next()?.parse().ok()?,
|
||||||
|
name: fields.next()?,
|
||||||
|
latitude: fields.next()?.parse().ok()?,
|
||||||
|
longitude: fields.next()?.parse().ok()?,
|
||||||
|
country: fields.next()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell(latitude: f64, longitude: f64) -> (i32, i32) {
|
||||||
|
(latitude.floor() as i32, longitude.floor() as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Great-circle distance between two positions, in km.
|
||||||
|
pub fn distance_km(a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let (lat1, lat2) = (a.0.to_radians(), b.0.to_radians());
|
||||||
|
let dlat = lat2 - lat1;
|
||||||
|
let dlon = (b.1 - a.1).to_radians();
|
||||||
|
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
||||||
|
2.0 * EARTH_RADIUS_KM * h.sqrt().min(1.0).asin()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town nearest to a position, if one lies within [`MAX_DISTANCE_KM`].
|
||||||
|
pub fn nearest(latitude: f64, longitude: f64) -> Option<&'static City> {
|
||||||
|
if !latitude.is_finite() || !longitude.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let table = table();
|
||||||
|
// A degree of latitude is about 111 km everywhere; a degree of longitude
|
||||||
|
// shrinks towards the poles, so more cells are needed to cover the radius.
|
||||||
|
let lat_span = (MAX_DISTANCE_KM / 111.0).ceil() as i32;
|
||||||
|
let lon_span = {
|
||||||
|
let scale = latitude.to_radians().cos().abs().max(0.01);
|
||||||
|
((MAX_DISTANCE_KM / (111.0 * scale)).ceil() as i32).min(180)
|
||||||
|
};
|
||||||
|
let (row, column) = cell(latitude, longitude);
|
||||||
|
let mut best: Option<(f64, &City)> = None;
|
||||||
|
for dlat in -lat_span..=lat_span {
|
||||||
|
for dlon in -lon_span..=lon_span {
|
||||||
|
// Longitude wraps at the antimeridian: -181 is 179.
|
||||||
|
let lon_cell = (column + dlon + 180).rem_euclid(360) - 180;
|
||||||
|
let Some(indices) = table.cells.get(&(row + dlat, lon_cell)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for &index in indices {
|
||||||
|
let city = &table.cities[index];
|
||||||
|
let d = distance_km((latitude, longitude), (city.latitude, city.longitude));
|
||||||
|
if d <= MAX_DISTANCE_KM && best.is_none_or(|(bd, _)| d < bd) {
|
||||||
|
best = Some((d, city));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, city)| city)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The town with this GeoNames id.
|
||||||
|
pub fn city(id: u32) -> Option<&'static City> {
|
||||||
|
let cities = &table().cities;
|
||||||
|
// The table is sorted by id.
|
||||||
|
cities
|
||||||
|
.binary_search_by_key(&id, |city| city.id)
|
||||||
|
.ok()
|
||||||
|
.map(|index| &cities[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The English ISO 3166-1 name of a country code, such as "Germany" for "DE".
|
||||||
|
pub fn country_name(code: &str) -> Option<&'static str> {
|
||||||
|
COUNTRIES.lines().find_map(|line| {
|
||||||
|
let (c, name) = line.split_once('\t')?;
|
||||||
|
(c == code).then_some(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_position_in_a_town_resolves_to_that_town() {
|
||||||
|
let berlin = nearest(52.52, 13.405).expect("Berlin is a town");
|
||||||
|
assert_eq!(berlin.name, "Berlin");
|
||||||
|
assert_eq!(berlin.country, "DE");
|
||||||
|
assert_eq!(city(berlin.id), Some(berlin));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_open_sea_has_no_town() {
|
||||||
|
assert_eq!(nearest(0.0, -30.0), None);
|
||||||
|
assert_eq!(nearest(f64::NAN, 0.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_town_across_the_antimeridian_is_still_found() {
|
||||||
|
// Just east of 180°, the nearest towns in Fiji are west of it.
|
||||||
|
let town = nearest(-16.8, 179.99);
|
||||||
|
assert!(town.is_none_or(|t| t.country == "FJ"));
|
||||||
|
let suva = nearest(-18.14, 178.44).expect("Suva is a town");
|
||||||
|
assert_eq!(suva.country, "FJ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_town_has_a_named_country() {
|
||||||
|
for city in &table().cities {
|
||||||
|
assert!(
|
||||||
|
country_name(city.country).is_some(),
|
||||||
|
"{} has no country name for {}",
|
||||||
|
city.name,
|
||||||
|
city.country
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(country_name("DE"), Some("Germany"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn distances_are_great_circle_km() {
|
||||||
|
let d = distance_km((52.52, 13.405), (48.137, 11.575));
|
||||||
|
assert!(
|
||||||
|
(d - 504.0).abs() < 5.0,
|
||||||
|
"Berlin to Munich is about 504 km, got {d}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||||
/// successful login.
|
/// Called on successful login.
|
||||||
|
///
|
||||||
|
/// `restart` rather than `enable --now`: the unit may already be active but
|
||||||
|
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
|
||||||
|
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||||
|
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||||
|
/// Restarting makes the new session take effect immediately, and starts the
|
||||||
|
/// unit when it is not running at all.
|
||||||
pub fn enable_start() -> bool {
|
pub fn enable_start() -> bool {
|
||||||
systemctl(&["enable", "--now"])
|
let enabled = systemctl(&["enable"]);
|
||||||
|
systemctl(&["restart"]) && enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the running mount without disabling it. Called by the tray's
|
/// Stop the running mount without disabling it. Called by the tray's
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Telling photos apart by what they show rather than by their bytes.
|
||||||
|
//!
|
||||||
|
//! The server's content hash only matches byte-identical files. A photo that
|
||||||
|
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||||
|
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||||
|
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||||
|
//! copies of one picture land a few bits apart, and different pictures about
|
||||||
|
//! half of the bits apart.
|
||||||
|
|
||||||
|
/// Hashes this many bits apart or fewer show the same picture.
|
||||||
|
pub const SIMILAR_BITS: u32 = 6;
|
||||||
|
|
||||||
|
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||||
|
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||||
|
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||||
|
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||||
|
pub const NO_HASH: u64 = 0;
|
||||||
|
|
||||||
|
/// Samples of the grid that differ by less than this in brightness, all of
|
||||||
|
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||||
|
/// noise and would match every other flat picture.
|
||||||
|
const FLAT: u8 = 8;
|
||||||
|
|
||||||
|
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||||
|
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||||
|
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||||
|
if width == 0 || height == 0 || luma.len() < width * height {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||||
|
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||||
|
let mut grid = [[0u8; 9]; 8];
|
||||||
|
for (gy, row) in grid.iter_mut().enumerate() {
|
||||||
|
let (y0, y1) = span(gy, 8, height);
|
||||||
|
for (gx, cell) in row.iter_mut().enumerate() {
|
||||||
|
let (x0, x1) = span(gx, 9, width);
|
||||||
|
let mut sum = 0u64;
|
||||||
|
for y in y0..y1 {
|
||||||
|
sum += luma[y * width + x0..y * width + x1]
|
||||||
|
.iter()
|
||||||
|
.map(|&v| u64::from(v))
|
||||||
|
.sum::<u64>();
|
||||||
|
}
|
||||||
|
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (min, max) = grid
|
||||||
|
.iter()
|
||||||
|
.flatten()
|
||||||
|
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||||
|
if max - min < FLAT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for row in &grid {
|
||||||
|
for pair in row.windows(2) {
|
||||||
|
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(hash != NO_HASH).then_some(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||||
|
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||||
|
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||||
|
let from = (i * len / cells).min(len - 1);
|
||||||
|
let to = ((i + 1) * len / cells).max(from + 1);
|
||||||
|
(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two hashes show the same picture.
|
||||||
|
pub fn alike(a: u64, b: u64) -> bool {
|
||||||
|
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||||
|
/// and the sets by their first index. A hash alike to none other is in no
|
||||||
|
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||||
|
/// from the last is one set.
|
||||||
|
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||||
|
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||||
|
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||||
|
// byte are compared, not every pair of the library.
|
||||||
|
const _: () = assert!(SIMILAR_BITS < 8);
|
||||||
|
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||||
|
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
for byte in 0..8 {
|
||||||
|
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||||
|
for (i, &hash) in hashes.iter().enumerate() {
|
||||||
|
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||||
|
}
|
||||||
|
for bucket in &buckets {
|
||||||
|
for (n, &a) in bucket.iter().enumerate() {
|
||||||
|
for &b in &bucket[n + 1..] {
|
||||||
|
if alike(hashes[a], hashes[b]) {
|
||||||
|
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||||
|
if ra != rb {
|
||||||
|
parent[ra.max(rb)] = ra.min(rb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||||
|
for i in 0..hashes.len() {
|
||||||
|
let r = root(&mut parent, i);
|
||||||
|
by_root.entry(r).or_default().push(i);
|
||||||
|
}
|
||||||
|
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A 90×80 picture: a bright disc on a gradient.
|
||||||
|
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||||
|
let mut luma = Vec::with_capacity(width * height);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||||
|
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||||
|
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||||
|
luma.push(v as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
luma
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resized_copy_hashes_alike() {
|
||||||
|
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||||
|
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||||
|
assert!(
|
||||||
|
alike(big, small),
|
||||||
|
"{} bits apart",
|
||||||
|
(big ^ small).count_ones()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_picture_hashes_apart() {
|
||||||
|
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||||
|
let mirrored: Vec<u8> = picture(90, 80)
|
||||||
|
.chunks(90)
|
||||||
|
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||||
|
.collect();
|
||||||
|
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||||
|
assert!(!alike(one, other));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_picture_has_no_hash() {
|
||||||
|
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||||
|
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||||
|
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||||
|
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||||
|
assert!(difference_hash(4, 3, &luma).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alike_hashes_form_sets_and_catch_on() {
|
||||||
|
let a = 0x0123_4567_89ab_cdef_u64;
|
||||||
|
let hashes = [
|
||||||
|
a,
|
||||||
|
!a,
|
||||||
|
a ^ 0b111,
|
||||||
|
a ^ 0b111 ^ (0b1111 << 40),
|
||||||
|
0x5555_0000_ffff_aaaa,
|
||||||
|
];
|
||||||
|
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||||
|
// One bit apart in each of six bytes: six bits in all, and only the
|
||||||
|
// top two bytes agree.
|
||||||
|
let a = 1u64 << 63;
|
||||||
|
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||||
|
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|||||||
"Thumbs.db",
|
"Thumbs.db",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
||||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
/// writing it gives it this name, only while the write is in flight, and the
|
||||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
/// finished article always arrives under a different one. On an on-demand mount
|
||||||
/// revision — the app renames the finished file to its real name, and only that
|
/// these should never seal a remote revision — the app renames the finished file
|
||||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
|
||||||
|
/// and docs/BUGS.md B70.
|
||||||
|
///
|
||||||
|
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
|
||||||
|
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
|
||||||
|
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
|
||||||
|
/// disk, and a later pass picks it up if the rules change. Parking one on the
|
||||||
|
/// on-demand mount is irreversible in practice: the only exit is a rename to a
|
||||||
|
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
|
||||||
|
/// template, a tool's output — never gets one. It then never reaches Drive at
|
||||||
|
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
|
||||||
|
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
|
||||||
|
/// forever. The generic-scratch case they were added for renames within
|
||||||
|
/// milliseconds, so it never needed the park; the download suffixes below, which
|
||||||
|
/// stay in flight for minutes, are what B70 was actually about.
|
||||||
const TRANSIENT_SUFFIXES: &[&str] = &[
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||||
".crdownload", // Chromium/Brave/Edge
|
".crdownload", // Chromium/Brave/Edge
|
||||||
".part", // Firefox, wget, aria2c
|
".part", // Firefox, wget, aria2c
|
||||||
".partial", // some downloaders / older IE
|
".partial", // some downloaders / older IE
|
||||||
".download", // Safari / macOS
|
".download", // Safari / macOS
|
||||||
".tmp", // generic scratch
|
|
||||||
".temp", // generic scratch
|
|
||||||
".swp", // vim swap
|
".swp", // vim swap
|
||||||
".swx", // vim swap (secondary)
|
".swx", // vim swap (secondary)
|
||||||
];
|
];
|
||||||
@@ -84,6 +96,26 @@ pub fn is_transient_name(name: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `name` is scratch another program writes and throws away: the
|
||||||
|
/// [`is_transient_name`] set, plus the save-by-rename temporaries of GTK, Qt,
|
||||||
|
/// Chromium and Electron, generic `.tmp` files and Emacs lock files. None of
|
||||||
|
/// them is a file the user made, so a front-end can hide them from a history
|
||||||
|
/// of what happened to the user's files.
|
||||||
|
pub fn is_scratch_name(name: &str) -> bool {
|
||||||
|
if is_transient_name(name) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".tmp")
|
||||||
|
|| lower.ends_with(".temp")
|
||||||
|
|| name.starts_with(".goutputstream-")
|
||||||
|
|| name.starts_with(".org.chromium.")
|
||||||
|
|| name.starts_with(".com.google.chrome.")
|
||||||
|
|| name.starts_with(".#")
|
||||||
|
|| (name.starts_with('#') && name.ends_with('#') && name.len() > 1)
|
||||||
|
|| (name.starts_with('.') && name.contains(".tmp."))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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"];
|
||||||
|
|
||||||
@@ -319,8 +351,6 @@ mod tests {
|
|||||||
"movie.mkv.part",
|
"movie.mkv.part",
|
||||||
"big.iso.partial",
|
"big.iso.partial",
|
||||||
"photo.jpg.download",
|
"photo.jpg.download",
|
||||||
"export.xml.tmp",
|
|
||||||
"notes.TEMP",
|
|
||||||
".notes.txt.swp",
|
".notes.txt.swp",
|
||||||
"doc.txt~",
|
"doc.txt~",
|
||||||
".~lock.report.odt#",
|
".~lock.report.odt#",
|
||||||
@@ -331,6 +361,25 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_by_rename_temporaries_are_scratch() {
|
||||||
|
for name in [
|
||||||
|
".goutputstream-4XK2B2",
|
||||||
|
".org.chromium.Chromium.aBc123",
|
||||||
|
"~$report.docx",
|
||||||
|
".~lock.budget.ods#",
|
||||||
|
".#notes.org",
|
||||||
|
"#notes.org#",
|
||||||
|
"cache.tmp",
|
||||||
|
"page.html.crdownload",
|
||||||
|
] {
|
||||||
|
assert!(is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in ["report.docx", "notes.org", "#1 hits.txt", ".bashrc"] {
|
||||||
|
assert!(!is_scratch_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finished_names_are_not_transient() {
|
fn finished_names_are_not_transient() {
|
||||||
for name in [
|
for name in [
|
||||||
@@ -340,6 +389,10 @@ mod tests {
|
|||||||
"archive.partly.zip", // ".partly" is not ".part"
|
"archive.partly.zip", // ".partly" is not ".part"
|
||||||
"template.dotx",
|
"template.dotx",
|
||||||
"readme",
|
"readme",
|
||||||
|
// A file that is *named* `.tmp` and never renamed: parking it would
|
||||||
|
// keep it off Drive for good.
|
||||||
|
"index.tmp",
|
||||||
|
"notes.TEMP",
|
||||||
] {
|
] {
|
||||||
assert!(!is_transient_name(name), "{name} should not be transient");
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//! Talking to a running `pdfs-tray`.
|
||||||
|
//!
|
||||||
|
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
|
||||||
|
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
|
||||||
|
//! away when the user hides it or stops Proton Drive.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
use crate::config::AppDirs;
|
||||||
|
|
||||||
|
/// The line that asks the tray to exit.
|
||||||
|
pub const QUIT: &str = "quit";
|
||||||
|
|
||||||
|
/// Ask a running tray to exit. False when no tray answers.
|
||||||
|
pub fn quit(dirs: &AppDirs) -> bool {
|
||||||
|
UnixStream::connect(dirs.tray_socket())
|
||||||
|
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "FUSE on-demand filesystem for Proton Drive"
|
description = "FUSE on-demand filesystem for Proton Drive"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pdfs-core.workspace = true
|
pdfs-core.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -16,6 +20,7 @@ tracing.workspace = true
|
|||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
use pdfs_core::control::{AlbumInfo, PhotoItem};
|
use pdfs_core::control::{AlbumInfo, PhotoItem, Topic};
|
||||||
use pdfs_core::db::StoredAlbum;
|
use pdfs_core::db::StoredAlbum;
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
|
||||||
@@ -137,10 +137,12 @@ impl Core {
|
|||||||
}
|
}
|
||||||
let core = self.clone();
|
let core = self.clone();
|
||||||
self.rt.spawn(async move {
|
self.rt.spawn(async move {
|
||||||
if let Err(e) = core.refresh_albums().await {
|
let refreshed = core.refresh_albums().await;
|
||||||
warn!(error = %e, "background album refresh failed");
|
|
||||||
}
|
|
||||||
core.albums_refreshing.store(false, Ordering::SeqCst);
|
core.albums_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Photos]),
|
||||||
|
Err(e) => warn!(error = %e, "background album refresh failed"),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +177,40 @@ impl Core {
|
|||||||
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
Ok(page.into_iter().map(|p| self.photo_item(p)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make sure every album of ours has its contents on disk, for a filter
|
||||||
|
/// that asks which photos no album holds. Contents are otherwise fetched
|
||||||
|
/// the first time an album is opened, so an album never opened would read
|
||||||
|
/// as empty and its photos as unfiled.
|
||||||
|
///
|
||||||
|
/// Shared-with-me albums are skipped: their photos live on the sharer's
|
||||||
|
/// volume and are never in our timeline. The freshness stamps are no guide
|
||||||
|
/// here — any album change clears all of them — so only an album whose
|
||||||
|
/// server count disagrees with an empty local copy is waited for.
|
||||||
|
pub(crate) fn ensure_album_contents(&self) -> CoreResult<()> {
|
||||||
|
if self.db.albums_count().map_err(CoreError::from)? == 0
|
||||||
|
&& !self.rt.block_on(self.refresh_albums())?
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let albums = self.db.albums_list().map_err(CoreError::from)?;
|
||||||
|
for album in albums.iter().filter(|a| !a.shared && a.photo_count > 0) {
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.album_photos_count(&album.uid)
|
||||||
|
.map_err(CoreError::from)?;
|
||||||
|
if stored > 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(uid) = parse_uid(&album.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_album(&uid)) {
|
||||||
|
warn!(album = %album.uid, %error, "fetching an album's contents failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-enumerate one album's photos and replace its persisted contents.
|
/// Re-enumerate one album's photos and replace its persisted contents.
|
||||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||||
let photos = self.photos();
|
let photos = self.photos();
|
||||||
@@ -221,6 +257,84 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an album and return its uid. The listing is refreshed before
|
||||||
|
/// answering, so the next [`Core::albums`] already shows it.
|
||||||
|
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||||
|
let uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().create_album(name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(uid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().rename_album(album, name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||||
|
/// which only matters for photos that exist in the album alone.
|
||||||
|
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().delete_album(album, delete_photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||||
|
let key = album.to_string();
|
||||||
|
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||||
|
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||||
|
}
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add photos to an album, per photo: `(added, failed)`.
|
||||||
|
pub(crate) fn add_to_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||||
|
/// stay in the timeline.
|
||||||
|
pub(crate) fn remove_from_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||||
|
/// date after a change made here, so the reply is followed by a listing
|
||||||
|
/// that shows it. A failed refresh only leaves them stale: the change
|
||||||
|
/// itself went through.
|
||||||
|
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||||
|
self.invalidate_albums();
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||||
|
warn!(%error, "album listing refresh after a change failed");
|
||||||
|
}
|
||||||
|
if let Some(album) = album
|
||||||
|
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||||
|
{
|
||||||
|
warn!(%album, %error, "album refresh after a change failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
/// to the timeline can just as easily be a change to an album.
|
/// to the timeline can just as easily be a change to an album.
|
||||||
@@ -230,6 +344,20 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Photos an album change applied to, and the ones it failed for with why.
|
||||||
|
pub(crate) type AlbumOutcome = (Vec<String>, Vec<(String, String)>);
|
||||||
|
|
||||||
|
fn album_outcome(outcomes: Vec<pdfs_core::batch::Outcome>) -> AlbumOutcome {
|
||||||
|
let (done, failed) = pdfs_core::batch::split(outcomes);
|
||||||
|
(
|
||||||
|
done.iter().map(|uid| uid.to_string()).collect(),
|
||||||
|
failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, error)| (uid.to_string(), error.to_string()))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Project a persisted album into the wire item a front-end paints.
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
AlbumInfo {
|
AlbumInfo {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Remote-event invalidation and local-index background workers.
|
//! Remote-event invalidation and local-index background workers.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::shutdown::Shutdown;
|
||||||
|
|
||||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
@@ -8,35 +9,20 @@ use super::*;
|
|||||||
///
|
///
|
||||||
/// Returns whether this mount actually held the node, so the caller can evict
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
/// its content blob once if *any* mount did.
|
/// its content blob once if *any* mount did.
|
||||||
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let Some((parent, name)) = st.forget(uid) else {
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if let Some(notifier) = notifier {
|
match child {
|
||||||
match child {
|
Some(child) => notify.delete(parent, child, name),
|
||||||
Some(child) => {
|
None => notify.inval_entry(parent, name),
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
fn hide_foreign_deleted_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) {
|
||||||
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 child = st.by_uid.get(uid).copied();
|
||||||
let dentry = child.and_then(|ino| {
|
let dentry = child.and_then(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -45,9 +31,9 @@ fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>,
|
|||||||
});
|
});
|
||||||
let changed = st.downgrade_shared_subtree(uid);
|
let changed = st.downgrade_shared_subtree(uid);
|
||||||
st.hide_foreign_subtree(uid);
|
st.hide_foreign_subtree(uid);
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
if let (Some((parent, name)), Some(child)) = (dentry, child) {
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
notify.delete(parent, child, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,18 +118,18 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
&& foreign_delete
|
&& foreign_delete
|
||||||
{
|
{
|
||||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
hide_foreign_deleted_and_notify(st, notifier, uid);
|
hide_foreign_deleted_and_notify(st, notify, uid);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
apply_access_downgrade(&core.db, event, |root| {
|
apply_access_downgrade(&core.db, event, |root| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let changed = match root {
|
let changed = match root {
|
||||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||||
None => st.downgrade_known_shared_access(),
|
None => st.downgrade_known_shared_access(),
|
||||||
};
|
};
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -173,19 +159,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// Trashing makes a node vanish from its parent listing.
|
||||||
had_node |= forget_and_notify(st, notifier, node_uid);
|
had_node |= forget_and_notify(st, notify, node_uid);
|
||||||
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
// Known node changed: drop its cached attrs/data (and
|
// Known node changed: drop its cached attrs/data (and
|
||||||
// listing if it is a directory) so the next access
|
// listing if it is a directory) so the next access
|
||||||
// re-fetches. Its content blob may now be stale too.
|
// re-fetches. Its content blob may now be stale too.
|
||||||
had_node = true;
|
had_node = true;
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
@@ -209,8 +193,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
if !foreign_delete {
|
if !foreign_delete {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
forget_and_notify(st, notifier, node_uid);
|
forget_and_notify(st, notify, node_uid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,13 +207,11 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
for &ino in &dirs {
|
for &ino in &dirs {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,18 +285,16 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
core.for_each_mount(|st, notifier| {
|
fn drop_listing(st: &mut State, notify: &mut NotifyBatch, parent_uid: &NodeUid) {
|
||||||
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
return;
|
||||||
return;
|
|
||||||
};
|
|
||||||
st.invalidate_listing(parent);
|
|
||||||
if let Some(notifier) = notifier {
|
|
||||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
st.invalidate_listing(parent);
|
||||||
|
notify.inval_inode(parent);
|
||||||
|
}
|
||||||
|
core.for_each_mount(|st, notify| {
|
||||||
for parent_uid in &dirty.foreign {
|
for parent_uid in &dirty.foreign {
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
for (parent_uid, children) in &dirty.ours {
|
for (parent_uid, children) in &dirty.ours {
|
||||||
if dirty.foreign.contains(parent_uid) {
|
if dirty.foreign.contains(parent_uid) {
|
||||||
@@ -328,7 +308,7 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -442,6 +422,170 @@ pub(super) async fn run_event_sync(
|
|||||||
cursor = Some(applied);
|
cursor = Some(applied);
|
||||||
}
|
}
|
||||||
flush_dirty_parents(&core, &dirty);
|
flush_dirty_parents(&core, &dirty);
|
||||||
|
// Another device changed the tree: whatever a front-end lists may have
|
||||||
|
// moved, including what is shared and what is in the trash.
|
||||||
|
core.events
|
||||||
|
.publish(&[Topic::Files, Topic::Trash, Topic::Shares, Topic::Conflicts]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state key holding the photos volume's event cursor. Separate from the
|
||||||
|
/// Drive cursor because the two volumes have their own event streams, and one
|
||||||
|
/// cursor cannot address both.
|
||||||
|
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
|
||||||
|
|
||||||
|
/// Poll the photos volume's event stream, so a photo deleted or added on another
|
||||||
|
/// device shows up in the gallery within one poll instead of on the timeline's
|
||||||
|
/// freshness window.
|
||||||
|
///
|
||||||
|
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
|
||||||
|
/// part of the FUSE mount, so there is no inode space to converge and nothing to
|
||||||
|
/// invalidate in the kernel. A trash or a delete removes the rows behind the
|
||||||
|
/// gallery; anything else only drops the timeline's freshness stamp, and the
|
||||||
|
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
|
||||||
|
/// file.
|
||||||
|
pub(super) async fn run_photos_event_sync(core: Core) {
|
||||||
|
let photos = core.photos();
|
||||||
|
// The photos volume only exists once the user has used Photos at all, and
|
||||||
|
// the daemon may well start before the network does.
|
||||||
|
let scope = loop {
|
||||||
|
match photos.get_photos_root().await {
|
||||||
|
Ok(Some(root)) => break root.tree_event_scope_id(),
|
||||||
|
Ok(None) => {
|
||||||
|
debug!("no photos volume; photo event sync not started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the photos root failed; retrying");
|
||||||
|
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cursor: Option<DriveEventId> = core
|
||||||
|
.db
|
||||||
|
.state_str(PHOTOS_EVENT_CURSOR)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(DriveEventId::from);
|
||||||
|
info!(?cursor, "photo event sync started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "photo event poll failed; retrying after interval");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(head) = events.last().map(|event| event.id().clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
apply_photo_events(&core, &events);
|
||||||
|
core.events.publish(&[Topic::Photos]);
|
||||||
|
// One cursor write per batch, after the batch is applied: a crash in
|
||||||
|
// between replays events whose effects are idempotent.
|
||||||
|
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||||
|
warn!(%error, "persisting the photo event cursor failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cursor = Some(head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one batch of photos-volume events asks the gallery's tables to do.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub(super) struct PhotoEventPlan {
|
||||||
|
/// Photos that left the volume, by delete or by trash.
|
||||||
|
pub(super) gone: Vec<String>,
|
||||||
|
/// Photos whose node changed, so their stored metadata is no longer trusted.
|
||||||
|
pub(super) touched: Vec<String>,
|
||||||
|
/// Whether the whole timeline has to be read again.
|
||||||
|
pub(super) full: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhotoEventPlan {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.gone.is_empty() && self.touched.is_empty() && !self.full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one batch of photos-volume events. Pure, so the classification is
|
||||||
|
/// testable without a volume behind it.
|
||||||
|
///
|
||||||
|
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
||||||
|
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
||||||
|
/// every favorite the user toggles here would mark its own photo stale and buy
|
||||||
|
/// a resolve of a node we just wrote.
|
||||||
|
fn classify_photo_events(
|
||||||
|
events: &[DriveEvent],
|
||||||
|
mut is_echo: impl FnMut(&NodeUid) -> bool,
|
||||||
|
) -> PhotoEventPlan {
|
||||||
|
let mut plan = PhotoEventPlan::default();
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => plan.gone.push(node_uid.to_string()),
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
node_uid,
|
||||||
|
is_trashed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *is_trashed {
|
||||||
|
plan.gone.push(node_uid.to_string());
|
||||||
|
} else if !is_echo(node_uid) {
|
||||||
|
// An untrash, a rename, a favorite toggled on the phone: the
|
||||||
|
// event says which node changed but never what about it, so
|
||||||
|
// the uid is marked stale and the next refresh reads it.
|
||||||
|
plan.touched.push(node_uid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The daemon cannot know what it missed here, so everything it
|
||||||
|
// thinks it knows about every photo is dropped.
|
||||||
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
|
plan.full = true;
|
||||||
|
}
|
||||||
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one batch of photos-volume events to the gallery's tables.
|
||||||
|
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
|
||||||
|
let plan = classify_photo_events(events, |uid| core.take_self_change(uid));
|
||||||
|
let PhotoEventPlan {
|
||||||
|
gone,
|
||||||
|
touched,
|
||||||
|
full,
|
||||||
|
} = &plan;
|
||||||
|
if !gone.is_empty() {
|
||||||
|
match core.db.photos_delete(gone) {
|
||||||
|
Ok(removed) if removed > 0 => {
|
||||||
|
debug!(removed, "photos removed by a remote event");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A stale mark is what makes the next refresh cheap: it reads these photos'
|
||||||
|
// nodes and carries every other photo's metadata as it stands.
|
||||||
|
if !touched.is_empty() {
|
||||||
|
match core.db.photos_mark_unresolved(touched) {
|
||||||
|
Ok(marked) if marked > 0 => debug!(marked, "photos changed by a remote event"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "marking remotely changed photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *full {
|
||||||
|
match core.db.photos_mark_all_unresolved() {
|
||||||
|
Ok(marked) => debug!(marked, "the photo event stream lost continuity"),
|
||||||
|
Err(error) => warn!(%error, "marking the whole timeline unresolved failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plan.is_empty() {
|
||||||
|
core.invalidate_photos();
|
||||||
|
core.invalidate_albums();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +602,7 @@ pub(super) fn run_local_index(
|
|||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
mountpoint: PathBuf,
|
mountpoint: PathBuf,
|
||||||
|
shutdown: Arc<Shutdown>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let age = db.local_indexed_at().ok().flatten();
|
let age = db.local_indexed_at().ok().flatten();
|
||||||
@@ -466,7 +611,11 @@ pub(super) fn run_local_index(
|
|||||||
if stale {
|
if stale {
|
||||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
std::thread::sleep(LOCAL_INDEX_CHECK);
|
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||||
|
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
||||||
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +651,12 @@ fn scan_local_once(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
let mut excludes =
|
||||||
|
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||||
|
// Resolved per scan rather than once at startup: mounts come and go, and a
|
||||||
|
// mount that appeared since the daemon started is exactly the one most
|
||||||
|
// likely to be a half-alive network filesystem (see `nested_mount_points`).
|
||||||
|
excludes.extend(localindex::nested_mount_points(&home));
|
||||||
indexing.store(true, Ordering::Relaxed);
|
indexing.store(true, Ordering::Relaxed);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
|
||||||
@@ -556,6 +710,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,4 +958,96 @@ mod tests {
|
|||||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
assert!(db.get_event_cursor().unwrap().is_none());
|
assert!(db.get_event_cursor().unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A photo trashed or deleted elsewhere leaves the gallery; one that only
|
||||||
|
/// changed is marked stale, so the next refresh reads that node and no other.
|
||||||
|
#[test]
|
||||||
|
fn a_photo_event_names_what_it_changed() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("deleted"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
node_uid: uid("trashed"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: true,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e3"),
|
||||||
|
node_uid: uid("favorited"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
PhotoEventPlan {
|
||||||
|
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||||
|
touched: vec![uid("favorited").to_string()],
|
||||||
|
full: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed replays this daemon's own writes. Applying one would cost a
|
||||||
|
/// resolve of a node whose changed field the daemon just wrote itself.
|
||||||
|
#[test]
|
||||||
|
fn our_own_photo_write_is_not_a_remote_change() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("ours"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
}],
|
||||||
|
|claimed| *claimed == uid("ours"),
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gap in the stream means the daemon cannot know what it missed, so it
|
||||||
|
/// keeps nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_lost_photo_event_stream_asks_for_everything() {
|
||||||
|
for event in [
|
||||||
|
DriveEvent::ContinuityLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::ScopeAccessLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let plan = classify_photo_events(std::slice::from_ref(&event), |_| false);
|
||||||
|
assert!(plan.full, "{event:?}");
|
||||||
|
assert!(plan.gone.is_empty());
|
||||||
|
assert!(plan.touched.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookkeeping events change nothing about the gallery, and must not buy a
|
||||||
|
/// refresh of it.
|
||||||
|
#[test]
|
||||||
|
fn photo_bookkeeping_events_ask_for_nothing() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::CursorAdvanced {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::SharedWithMeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! Listing and resolving `(sync-conflict …)` copies for the user.
|
||||||
|
//!
|
||||||
|
//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags
|
||||||
|
//! the rest once in the activity feed. That leaves divergent copies sitting in
|
||||||
|
//! folders with nothing but a name to find them by. This module lists them with
|
||||||
|
//! the file they are a copy of, and resolves one the way the user chooses:
|
||||||
|
//!
|
||||||
|
//! - **Keep the original**: trash the copy.
|
||||||
|
//! - **Keep the copy**: trash the original, then give the copy its name.
|
||||||
|
//! - **Keep both**: rename the copy to a name of the user's choosing.
|
||||||
|
//!
|
||||||
|
//! Every resolution goes through the Proton trash, never a delete, so a wrong
|
||||||
|
//! choice is undone by restoring from Trash. Resolution refuses to touch a file
|
||||||
|
//! that is open, being written, or still owed an upload, for the same reason the
|
||||||
|
//! sweep does: the node is about to change under the decision.
|
||||||
|
//!
|
||||||
|
//! Copies under My Files are named by their mountpoint-relative path. Copies
|
||||||
|
//! inside a synced folder are named by their absolute local path, so a resolve
|
||||||
|
//! request routes to the on-demand mount that serves them. A copy inside a
|
||||||
|
//! mirror folder is listed too, but it is a local file first: the sync engine
|
||||||
|
//! owns it, so it is resolved by editing the folder, not through here.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||||
|
use sweep::conflict_base_name;
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Where the user finds a conflict copy: its mountpoint-relative path under
|
||||||
|
/// My Files, or its absolute local path inside a synced folder. `None` for a
|
||||||
|
/// node no location of this machine shows, such as one in another
|
||||||
|
/// computer's device tree.
|
||||||
|
///
|
||||||
|
/// The sweep only flags what this can name, and the listing uses it too. A
|
||||||
|
/// flagged copy the listing cannot see reads as resolved in the activity
|
||||||
|
/// feed while it still sits in the folder (`docs/BUGS.md` B99).
|
||||||
|
pub(crate) fn conflict_path(&self, roots: &SearchRoots, uid: &NodeUid) -> Option<String> {
|
||||||
|
let uid = uid.to_string();
|
||||||
|
if let Ok(Some(path)) = self
|
||||||
|
.db
|
||||||
|
.path_relative_to(&self.primary_root_uid.to_string(), &uid)
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
let drive_path = self.db.node_path(&uid).ok().flatten()?;
|
||||||
|
roots.resolve(&drive_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every conflict copy this machine shows, with the file it is a copy of.
|
||||||
|
pub(crate) fn list_conflicts(&self) -> CoreResult<Vec<ConflictInfo>> {
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.load_all()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
|
||||||
|
let nodes: Vec<Node> = stored
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| stored.node)
|
||||||
|
.filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid))
|
||||||
|
.collect();
|
||||||
|
let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new();
|
||||||
|
for node in &nodes {
|
||||||
|
by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node);
|
||||||
|
}
|
||||||
|
let roots = self.search_roots();
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for node in &nodes {
|
||||||
|
let Some(base) = conflict_base_name(&node.name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(path) = self.conflict_path(&roots, &node.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let original = by_parent_name
|
||||||
|
.get(&(node.parent_uid.as_ref(), base.as_str()))
|
||||||
|
.copied();
|
||||||
|
items.push(ConflictInfo {
|
||||||
|
original_path: sibling_path(&path, &base),
|
||||||
|
original_exists: original.is_some(),
|
||||||
|
size: node_size(node),
|
||||||
|
modified: node.modification_time,
|
||||||
|
original_size: original.map(node_size),
|
||||||
|
original_modified: original.map(|o| o.modification_time),
|
||||||
|
identical: original.is_some_and(|o| {
|
||||||
|
node_size(o) == node_size(node)
|
||||||
|
&& node_content_sha1(node)
|
||||||
|
.is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha))
|
||||||
|
}),
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.sort_by(|a, b| a.path.cmp(&b.path));
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line
|
||||||
|
/// for the user saying what happened.
|
||||||
|
pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult<String> {
|
||||||
|
let copy_name = copy
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?;
|
||||||
|
let base = conflict_base_name(copy_name)
|
||||||
|
.ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?;
|
||||||
|
let original = copy.with_file_name(&base);
|
||||||
|
self.require_settled(copy)?;
|
||||||
|
let message = match keep {
|
||||||
|
ConflictKeep::Original => {
|
||||||
|
self.delete(copy)?;
|
||||||
|
self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true);
|
||||||
|
format!("kept {base}; the copy is in Trash")
|
||||||
|
}
|
||||||
|
ConflictKeep::Copy => {
|
||||||
|
self.require_settled(&original)?;
|
||||||
|
self.delete(&original)?;
|
||||||
|
// The original is already in Trash. A failed rename leaves the
|
||||||
|
// copy under its conflict name, which loses nothing — say so
|
||||||
|
// rather than leave the user guessing where their file went.
|
||||||
|
self.rename(copy, &base).map_err(|e| {
|
||||||
|
e.context(&format!(
|
||||||
|
"{base} was moved to Trash, but the copy kept its conflict name"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Rename,
|
||||||
|
&base,
|
||||||
|
format!("kept the conflict copy {copy_name}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
format!("kept the copy as {base}; the previous version is in Trash")
|
||||||
|
}
|
||||||
|
ConflictKeep::Both { name } => {
|
||||||
|
let name = name.trim();
|
||||||
|
if name == base || name == copy_name {
|
||||||
|
return Err(CoreError::invalid("choose a name that no other file has"));
|
||||||
|
}
|
||||||
|
self.rename(copy, name)?;
|
||||||
|
self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true);
|
||||||
|
format!("kept both; the copy is now {name}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse to resolve around a file that is open, being written, or still
|
||||||
|
/// owed an upload: the decision would be about content that is changing.
|
||||||
|
fn require_settled(&self, rel: &Path) -> CoreResult<()> {
|
||||||
|
let (_, uid) = self.resolve(rel)?;
|
||||||
|
let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true);
|
||||||
|
if queued || self.is_busy(&uid) {
|
||||||
|
return Err(CoreError::conflict(format!(
|
||||||
|
"{} is still being written or uploaded; try again once it has synced",
|
||||||
|
rel.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.conflict_notified.lock().remove(&uid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path of `name` next to the file at `path`.
|
||||||
|
fn sibling_path(path: &str, name: &str) -> String {
|
||||||
|
match path.rsplit_once('/') {
|
||||||
|
Some((dir, _)) => format!("{dir}/{name}"),
|
||||||
|
None => name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_original_sits_next_to_its_copy() {
|
||||||
|
assert_eq!(
|
||||||
|
sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"),
|
||||||
|
"a/b/f.txt"
|
||||||
|
);
|
||||||
|
assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,7 @@ impl Core {
|
|||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(locations)
|
Ok(locations)
|
||||||
@@ -226,6 +227,39 @@ impl Core {
|
|||||||
Ok(format!("adopted device {name}"))
|
Ok(format!("adopted device {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The device a restore reads from: this machine's own for `None`, else
|
||||||
|
/// the registered device with that uid.
|
||||||
|
///
|
||||||
|
/// Another device is looked up without touching the cached device row —
|
||||||
|
/// restoring its folders here must not make this machine back up as it.
|
||||||
|
/// Its folders are then attached with that device's share id, so they sync
|
||||||
|
/// against the other computer's tree.
|
||||||
|
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
|
||||||
|
let Some(uid) = device else {
|
||||||
|
return self.ensure_device();
|
||||||
|
};
|
||||||
|
let remote = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_devices())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
let d = remote
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.uid.to_string() == uid)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
|
||||||
|
Ok(StoredDevice {
|
||||||
|
uid: d.uid.to_string(),
|
||||||
|
share_id: d.share_id.to_string(),
|
||||||
|
root_uid: d.root_folder_uid.to_string(),
|
||||||
|
name: d
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.ok()
|
||||||
|
.unwrap_or("(unnamed device)")
|
||||||
|
.to_string(),
|
||||||
|
created: d.creation_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
@@ -448,7 +482,8 @@ impl Core {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
let live = progress.get(&f.id).cloned();
|
let live = progress.get(&f.id).cloned();
|
||||||
sync_folder_info(f, live)
|
let paused = self.sync_folder_paused(f.id);
|
||||||
|
sync_folder_info(f, live, paused)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -536,7 +571,14 @@ 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(mount) = self.mounts.lock().remove(&id)
|
// Taken in its own statement so the registry lock is released before the
|
||||||
|
// teardown: an edition-2024 `if let` chain holds the guard for the whole
|
||||||
|
// body, and `teardown` unmounts and joins the session thread — which can
|
||||||
|
// be inside a multi-second transfer. The shutdown path takes the same
|
||||||
|
// lock, so holding it here risks systemd's stop timeout landing mid-
|
||||||
|
// unmount and stranding the endpoint.
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
@@ -554,6 +596,7 @@ 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);
|
drop(_guard);
|
||||||
|
self.clear_sync_folder_paused(id);
|
||||||
// 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
|
if delete_remote
|
||||||
@@ -584,19 +627,8 @@ impl Core {
|
|||||||
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;
|
fork.primary = false;
|
||||||
let share_access = self.state.lock().share_access.clone();
|
let share_access = self.state().share_access.clone();
|
||||||
fork.state = Arc::new(Mutex::new(State {
|
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: std::collections::HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
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`.
|
||||||
@@ -629,6 +661,13 @@ impl Core {
|
|||||||
.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}")))?;
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
|
// it up front rather than queue it indefinitely.
|
||||||
|
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this folder is paused; resume it before changing its mode",
|
||||||
|
));
|
||||||
|
}
|
||||||
if current_mode == target_mode {
|
if current_mode == target_mode {
|
||||||
let mode_str = target_mode.as_str();
|
let mode_str = target_mode.as_str();
|
||||||
let lock = self.sync_lock(id);
|
let lock = self.sync_lock(id);
|
||||||
@@ -866,8 +905,11 @@ impl Core {
|
|||||||
Ok(format!("{} is now on-demand", local.display()))
|
Ok(format!("{} is now on-demand", local.display()))
|
||||||
}
|
}
|
||||||
ApplicableMountMode::Mirror => {
|
ApplicableMountMode::Mirror => {
|
||||||
// ondemand→mirror: tear down the secondary mount first.
|
// ondemand→mirror: tear down the secondary mount first. Taken
|
||||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
// before the teardown so the registry lock is not held across
|
||||||
|
// the unmount and thread join (see `remove_sync_folder`).
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! What the daemon is doing right now, for the case where it has stopped doing
|
||||||
|
//! anything at all.
|
||||||
|
//!
|
||||||
|
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
|
||||||
|
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
|
||||||
|
//! manual `systemctl --user restart`. There was no panic, so nothing in the
|
||||||
|
//! journal said what it had been waiting on — and nothing in the daemon could be
|
||||||
|
//! asked, either. This module is the answer to "what was it stuck on?".
|
||||||
|
//!
|
||||||
|
//! Everything here is built to work *while* the daemon is wedged, which rules
|
||||||
|
//! out the obvious implementations: no lock that a stuck job might hold, no
|
||||||
|
//! database query on the calling thread, no waiting on the worker pool. A
|
||||||
|
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
|
||||||
|
//! exactly the moment it matters.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// When this process started. Read at the first touch, which
|
||||||
|
/// [`super::mount::mount`] makes happen during startup so the uptime is the
|
||||||
|
/// daemon's, not the first diagnostics request's.
|
||||||
|
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
/// Control requests currently being served, keyed by a monotonic ticket.
|
||||||
|
///
|
||||||
|
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// entries, and the only operations are push, remove-by-ticket and read-all.
|
||||||
|
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
|
||||||
|
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Start the daemon's uptime clock. Called once, from the mount path.
|
||||||
|
pub(crate) fn start_clock() {
|
||||||
|
LazyLock::force(&STARTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers one in-flight control request for as long as it is held.
|
||||||
|
pub(crate) struct InflightGuard(u64);
|
||||||
|
|
||||||
|
impl InflightGuard {
|
||||||
|
pub(crate) fn new(kind: String) -> Self {
|
||||||
|
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
|
||||||
|
INFLIGHT.lock().push((ticket, kind, Instant::now()));
|
||||||
|
Self(ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InflightGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name of a serialised [`pdfs_core::control::Request`], without
|
||||||
|
/// parsing it into one.
|
||||||
|
///
|
||||||
|
/// serde's external tagging makes this a property of the text: a unit variant is
|
||||||
|
/// the bare string `"Status"`, anything else is an object whose single key is
|
||||||
|
/// the variant name. Reading the name off the line rather than matching on 80
|
||||||
|
/// variants keeps this cheap, and keeps it correct when a variant is added.
|
||||||
|
pub(crate) fn request_kind(line: &str) -> String {
|
||||||
|
let line = line.trim();
|
||||||
|
let name = if let Some(rest) = line.strip_prefix('{') {
|
||||||
|
rest.trim_start()
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
} else {
|
||||||
|
line.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
};
|
||||||
|
let name = name.unwrap_or("unknown");
|
||||||
|
// A bare `Refresh` says nothing about which view wedged, and the three
|
||||||
|
// scopes cost wildly different amounts of work: a directory refresh is two
|
||||||
|
// cheap calls, a photos refresh can pull a whole library. The scope is one
|
||||||
|
// more peek into the same line.
|
||||||
|
if name == "Refresh"
|
||||||
|
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
|
||||||
|
{
|
||||||
|
return format!("Refresh({scope})");
|
||||||
|
}
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name serde wrote for the value of `key`, whether that value is a
|
||||||
|
/// bare string (a unit variant) or an object whose single key is the name.
|
||||||
|
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
|
||||||
|
let rest = line.split_once(key)?.1.trim_start();
|
||||||
|
let rest = rest.strip_prefix(':')?.trim_start();
|
||||||
|
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
|
||||||
|
rest.strip_prefix('"')?
|
||||||
|
.split('"')
|
||||||
|
.next()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resident set size in bytes, or `0` when `/proc` did not answer.
|
||||||
|
///
|
||||||
|
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
|
||||||
|
/// need the page size, and this needs no dependency at all.
|
||||||
|
pub(crate) fn rss_bytes() -> u64 {
|
||||||
|
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
parse_vm_rss(&status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
|
||||||
|
fn parse_vm_rss(status: &str) -> u64 {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.and_then(|kb| kb.parse::<u64>().ok())
|
||||||
|
.map_or(0, |kb| kb.saturating_mul(1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Assemble a [`Diagnostics`] report.
|
||||||
|
///
|
||||||
|
/// The pending-op count is the one field that needs the database, so it is
|
||||||
|
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
|
||||||
|
/// the database must still be able to report everything else about itself.
|
||||||
|
pub(crate) fn diagnostics(&self) -> Diagnostics {
|
||||||
|
let pool = self.workers.snapshot();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut inflight: Vec<InflightRequest> = INFLIGHT
|
||||||
|
.try_lock()
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(_, kind, started)| InflightRequest {
|
||||||
|
kind: kind.clone(),
|
||||||
|
age_secs: now.duration_since(*started).as_secs(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
|
||||||
|
|
||||||
|
Diagnostics {
|
||||||
|
uptime_secs: STARTED.elapsed().as_secs(),
|
||||||
|
rss_bytes: rss_bytes(),
|
||||||
|
workers: pool
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.map(|worker| WorkerState {
|
||||||
|
name: worker.name,
|
||||||
|
busy: worker.busy,
|
||||||
|
label: worker.label,
|
||||||
|
age_secs: worker.age_ms / 1000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
meta_queued: pool.meta_queued,
|
||||||
|
transfer_queued: pool.transfer_queued,
|
||||||
|
meta_completed: pool.meta_completed,
|
||||||
|
transfer_completed: pool.transfer_completed,
|
||||||
|
inflight,
|
||||||
|
control_handlers: super::control::active_handlers(),
|
||||||
|
control_handler_limit: super::control::handler_limit(),
|
||||||
|
pending_ops: self.pending_ops_quickly(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queued mutations, or `0` if the database did not answer within
|
||||||
|
/// [`PENDING_COUNT_DEADLINE`].
|
||||||
|
fn pending_ops_quickly(&self) -> u64 {
|
||||||
|
let db = self.db.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
if std::thread::Builder::new()
|
||||||
|
.name("pdfs-diagnostics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let counts = db.pending_op_counts().unwrap_or_default();
|
||||||
|
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
|
||||||
|
let _ = tx.send(total);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the report waits for the database before reporting without it.
|
||||||
|
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The request name is read off the wire format, so it must match both
|
||||||
|
/// shapes serde produces.
|
||||||
|
#[test]
|
||||||
|
fn the_request_kind_comes_off_both_wire_shapes() {
|
||||||
|
assert_eq!(request_kind("\"Status\""), "Status");
|
||||||
|
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
|
||||||
|
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
|
||||||
|
assert_eq!(request_kind("garbage"), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stuck `Refresh` is only actionable if the report says which view it
|
||||||
|
/// was refreshing, so the scope rides along with the name.
|
||||||
|
#[test]
|
||||||
|
fn a_refresh_reports_its_scope() {
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
|
||||||
|
"Refresh(Photos)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
|
||||||
|
"Refresh(Dir)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
|
||||||
|
"Refresh(Trash)"
|
||||||
|
);
|
||||||
|
// A shape this does not recognise still names the request.
|
||||||
|
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard registers for its lifetime and nothing longer: a handler that
|
||||||
|
/// returns must not leave a permanent "in flight" entry behind.
|
||||||
|
#[test]
|
||||||
|
fn a_guard_registers_only_while_it_lives() {
|
||||||
|
let before = INFLIGHT.lock().len();
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard::new("OpenFile".into());
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before + 1);
|
||||||
|
}
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever else it reports, the resident size has to be a real reading.
|
||||||
|
#[test]
|
||||||
|
fn rss_is_readable() {
|
||||||
|
assert!(rss_bytes() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmRSS` is in kB, and a status without one must read as unknown rather
|
||||||
|
/// than as a daemon using no memory.
|
||||||
|
#[test]
|
||||||
|
fn vm_rss_is_kilobytes_and_absence_is_zero() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
|
||||||
|
2048 * 1024
|
||||||
|
);
|
||||||
|
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
//! Push notifications for front-ends: the daemon's side of
|
||||||
|
//! [`Request::Subscribe`](pdfs_core::control::Request::Subscribe).
|
||||||
|
//!
|
||||||
|
//! Front-ends used to poll. The app asked for the status every two seconds, the
|
||||||
|
//! tray every three, and pages re-read themselves on a timer, whether or not
|
||||||
|
//! anything had changed. Now each subscription owns a [`Mailbox`] in the
|
||||||
|
//! [`EventHub`]. Code that changes something publishes a [`Topic`], and
|
||||||
|
//! [`run_event_sampler`] watches the few things that change without passing
|
||||||
|
//! through one place (the status, transfers, sync progress) and publishes them
|
||||||
|
//! when they differ.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex, MutexGuard};
|
||||||
|
use pdfs_core::control::{Event, JobItem, Response as CtlResponse, Topic, TransferItem};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// Most subscriptions served at once. Each holds a control handler for as long
|
||||||
|
/// as it lasts; the app and the tray need one apiece, and the cap keeps a
|
||||||
|
/// misbehaving client from spending the handlers that requests need.
|
||||||
|
const MAX_SUBSCRIBERS: usize = 8;
|
||||||
|
|
||||||
|
/// How long a subscription waits after a change before it sends, so a burst
|
||||||
|
/// (a folder of files trashed at once) goes out as one event.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
/// How often [`run_event_sampler`] looks at transfers. Status and sync
|
||||||
|
/// progress are looked at every other time.
|
||||||
|
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// Every open subscription's mailbox. Shared by every clone of [`Core`],
|
||||||
|
/// on-demand forks included, so a change in any mount reaches every client.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct EventHub {
|
||||||
|
mailboxes: Mutex<Vec<Arc<Mailbox>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one subscription has yet to send.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Mailbox {
|
||||||
|
inbox: Mutex<Inbox>,
|
||||||
|
ready: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inbox {
|
||||||
|
topics: BTreeSet<Topic>,
|
||||||
|
/// The newest [`Event::Status`] line. A newer one replaces it unsent: a
|
||||||
|
/// client wants the current status, not every status in between.
|
||||||
|
status: Option<Arc<str>>,
|
||||||
|
/// The newest [`Event::Transfers`] line, replaced the same way.
|
||||||
|
transfers: Option<Arc<str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Inbox {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.topics.is_empty() && self.status.is_none() && self.transfers.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `event` as one line of the wire format, newline included.
|
||||||
|
pub(crate) fn event_line(event: &Event) -> Option<Arc<str>> {
|
||||||
|
match serde_json::to_string(event) {
|
||||||
|
Ok(mut line) => {
|
||||||
|
line.push('\n');
|
||||||
|
Some(line.into())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "events: serialize event failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHub {
|
||||||
|
/// Tell every subscriber that `topics` changed.
|
||||||
|
pub(crate) fn publish(&self, topics: &[Topic]) {
|
||||||
|
self.deliver(|inbox| inbox.topics.extend(topics.iter().copied()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh status.
|
||||||
|
fn publish_status(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.status = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand every subscriber a fresh transfers snapshot.
|
||||||
|
fn publish_transfers(&self, line: Arc<str>) {
|
||||||
|
self.deliver(|inbox| inbox.transfers = Some(line.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deliver(&self, put: impl Fn(&mut Inbox)) {
|
||||||
|
for mailbox in self.mailboxes.lock().iter() {
|
||||||
|
put(&mut mailbox.inbox.lock());
|
||||||
|
mailbox.ready.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a mailbox, or `None` at [`MAX_SUBSCRIBERS`].
|
||||||
|
pub(crate) fn subscribe(&self) -> Option<Arc<Mailbox>> {
|
||||||
|
let mut mailboxes = self.mailboxes.lock();
|
||||||
|
if mailboxes.len() >= MAX_SUBSCRIBERS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mailbox = Arc::new(Mailbox::default());
|
||||||
|
mailboxes.push(mailbox.clone());
|
||||||
|
Some(mailbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unsubscribe(&self, mailbox: &Arc<Mailbox>) {
|
||||||
|
self.mailboxes
|
||||||
|
.lock()
|
||||||
|
.retain(|other| !Arc::ptr_eq(other, mailbox));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_subscribers(&self) -> bool {
|
||||||
|
!self.mailboxes.lock().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mailbox {
|
||||||
|
/// Wait up to `timeout` for something to send, then take it as wire lines:
|
||||||
|
/// the status first, then transfers, then the changed topics. Empty when
|
||||||
|
/// nothing arrived in time.
|
||||||
|
pub(crate) fn take(&self, timeout: Duration) -> Vec<Arc<str>> {
|
||||||
|
let mut inbox = self.inbox.lock();
|
||||||
|
if inbox.is_empty() {
|
||||||
|
self.ready.wait_for(&mut inbox, timeout);
|
||||||
|
if inbox.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
// Let the rest of a burst arrive before sending the first of it.
|
||||||
|
MutexGuard::unlocked(&mut inbox, || std::thread::sleep(COALESCE));
|
||||||
|
}
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
lines.extend(inbox.status.take());
|
||||||
|
lines.extend(inbox.transfers.take());
|
||||||
|
if !inbox.topics.is_empty() {
|
||||||
|
let topics = std::mem::take(&mut inbox.topics).into_iter().collect();
|
||||||
|
lines.extend(event_line(&Event::Changed { topics }));
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [`Event::Transfers`] line for a snapshot.
|
||||||
|
pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Option<Arc<str>> {
|
||||||
|
event_line(&Event::Transfers { items, jobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch what changes without a single place to publish it from, and publish it
|
||||||
|
/// when it differs from the last look: the status (queue counts, cache use,
|
||||||
|
/// online, pause), the transfers snapshot, and the synced folders with their
|
||||||
|
/// live progress. Looks only while someone is subscribed.
|
||||||
|
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
|
||||||
|
let mut transfers: Option<Arc<str>> = None;
|
||||||
|
let mut status: Option<String> = None;
|
||||||
|
let mut queue: Option<String> = None;
|
||||||
|
let mut locations: Option<String> = None;
|
||||||
|
let mut tick = 0u32;
|
||||||
|
while core.shutdown.sleep(SAMPLE_INTERVAL) {
|
||||||
|
if !core.events.has_subscribers() {
|
||||||
|
// A new subscription starts from a snapshot of its own, so there is
|
||||||
|
// nothing to compare against until then.
|
||||||
|
transfers = None;
|
||||||
|
status = None;
|
||||||
|
queue = None;
|
||||||
|
locations = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
|
||||||
|
if line.is_some() && line != transfers {
|
||||||
|
transfers = line.clone();
|
||||||
|
if let Some(line) = line {
|
||||||
|
core.events.publish_transfers(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick = tick.wrapping_add(1);
|
||||||
|
if !tick.is_multiple_of(2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let now = super::control::status_response(&core, &username, &mountpoint);
|
||||||
|
let (key, queue_key) = status_keys(&now);
|
||||||
|
if status.as_ref().is_some_and(|last| *last != key)
|
||||||
|
&& let Some(line) = event_line(&Event::Status(Box::new(now)))
|
||||||
|
{
|
||||||
|
core.events.publish_status(line);
|
||||||
|
}
|
||||||
|
status = Some(key);
|
||||||
|
if queue.as_ref().is_some_and(|last| *last != queue_key) {
|
||||||
|
core.events.publish(&[Topic::Queue]);
|
||||||
|
}
|
||||||
|
queue = Some(queue_key);
|
||||||
|
|
||||||
|
if let Ok(now) = core.list_locations() {
|
||||||
|
let key = serde_json::to_string(&now).unwrap_or_default();
|
||||||
|
if locations.as_ref().is_some_and(|last| *last != key) {
|
||||||
|
core.events.publish(&[Topic::Locations]);
|
||||||
|
}
|
||||||
|
locations = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What makes two statuses different enough to send, and what makes their
|
||||||
|
/// queues different. The age of the oldest staged write is left out of both:
|
||||||
|
/// it grows every second while anything is staged, which is not news.
|
||||||
|
fn status_keys(status: &CtlResponse) -> (String, String) {
|
||||||
|
let CtlResponse::Status {
|
||||||
|
parked_uploads,
|
||||||
|
failing_ops,
|
||||||
|
failing_error,
|
||||||
|
staged_bytes,
|
||||||
|
paused,
|
||||||
|
paused_until,
|
||||||
|
username,
|
||||||
|
mountpoint,
|
||||||
|
pinned,
|
||||||
|
used,
|
||||||
|
budget,
|
||||||
|
online,
|
||||||
|
pending_uploads,
|
||||||
|
pending_changes,
|
||||||
|
..
|
||||||
|
} = status
|
||||||
|
else {
|
||||||
|
return (String::new(), String::new());
|
||||||
|
};
|
||||||
|
let queue = format!(
|
||||||
|
"{pending_uploads}/{pending_changes}/{parked_uploads}/{failing_ops}/{failing_error:?}"
|
||||||
|
);
|
||||||
|
let key = format!(
|
||||||
|
"{queue}/{staged_bytes}/{paused}/{paused_until:?}/{username}/{mountpoint}/{pinned}/\
|
||||||
|
{used}/{budget}/{online}"
|
||||||
|
);
|
||||||
|
(key, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn changed(line: &str) -> Vec<Topic> {
|
||||||
|
match serde_json::from_str::<Event>(line.trim()) {
|
||||||
|
Ok(Event::Changed { topics }) => topics,
|
||||||
|
other => panic!("not a change event: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_burst_of_changes_arrives_as_one_event() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish(&[Topic::Trash, Topic::Files]);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
let lines = mailbox.take(Duration::from_secs(1));
|
||||||
|
assert_eq!(lines.len(), 1);
|
||||||
|
assert_eq!(changed(&lines[0]), vec![Topic::Files, Topic::Trash]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_newer_status_replaces_one_not_yet_sent() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.publish_status("first\n".into());
|
||||||
|
hub.publish_status("second\n".into());
|
||||||
|
assert_eq!(
|
||||||
|
mailbox.take(Duration::from_secs(1)),
|
||||||
|
vec![Arc::<str>::from("second\n")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_closed_subscription_gets_nothing_more() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let mailbox = hub.subscribe().expect("room for a subscriber");
|
||||||
|
hub.unsubscribe(&mailbox);
|
||||||
|
hub.publish(&[Topic::Files]);
|
||||||
|
assert!(mailbox.take(Duration::from_millis(10)).is_empty());
|
||||||
|
assert!(!hub.has_subscribers());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscriptions_are_capped() {
|
||||||
|
let hub = EventHub::default();
|
||||||
|
let open: Vec<_> = (0..MAX_SUBSCRIBERS)
|
||||||
|
.map(|_| hub.subscribe().expect("room for a subscriber"))
|
||||||
|
.collect();
|
||||||
|
assert!(hub.subscribe().is_none());
|
||||||
|
hub.unsubscribe(&open[0]);
|
||||||
|
assert!(hub.subscribe().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_age_of_a_staged_write_is_not_a_change() {
|
||||||
|
let status = |staged_oldest_secs| CtlResponse::Status {
|
||||||
|
parked_uploads: 0,
|
||||||
|
failing_ops: 0,
|
||||||
|
failing_error: None,
|
||||||
|
staged_bytes: 10,
|
||||||
|
staged_oldest_secs,
|
||||||
|
paused: false,
|
||||||
|
paused_until: None,
|
||||||
|
username: "u".into(),
|
||||||
|
mountpoint: "/m".into(),
|
||||||
|
pinned: 0,
|
||||||
|
used: 0,
|
||||||
|
budget: 0,
|
||||||
|
pins: Vec::new(),
|
||||||
|
online: true,
|
||||||
|
pending_uploads: 1,
|
||||||
|
pending_changes: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(status_keys(&status(5)), status_keys(&status(6)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
pub sweep_mode: SweepMode,
|
pub sweep_mode: SweepMode,
|
||||||
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
|
pub upload_limit: u64,
|
||||||
|
pub download_limit: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -255,6 +258,14 @@ pub(super) fn spawn_session(
|
|||||||
root: Node,
|
root: Node,
|
||||||
) -> std::io::Result<BackgroundSession> {
|
) -> std::io::Result<BackgroundSession> {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
|
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||||
|
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||||
|
// `release` currently relies on being serialised against the `open`+`read`
|
||||||
|
// an application issues right after `close(2)` — see the comment on
|
||||||
|
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||||
|
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||||
|
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||||
|
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||||
config.mount_options = vec![
|
config.mount_options = vec![
|
||||||
MountOption::FSName("protondrive".to_string()),
|
MountOption::FSName("protondrive".to_string()),
|
||||||
MountOption::Subtype("protondrive".to_string()),
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
@@ -332,7 +343,12 @@ pub fn mount(
|
|||||||
let MountOptions {
|
let MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
upload_limit,
|
||||||
|
download_limit,
|
||||||
} = options;
|
} = options;
|
||||||
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
|
// so the age it reports is the daemon's own.
|
||||||
|
super::diagnostics::start_clock();
|
||||||
let (root, online) = fetch_or_recall_root(&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)
|
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||||
@@ -346,36 +362,37 @@ pub fn mount(
|
|||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
|
|
||||||
|
let paused_until = pause::load_paused_until(&db);
|
||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
|
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||||
primary_root_uid: root.uid.clone(),
|
primary_root_uid: root.uid.clone(),
|
||||||
primary: true,
|
primary: true,
|
||||||
state: Arc::new(Mutex::new(State {
|
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: db.clone(),
|
|
||||||
})),
|
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||||
|
block_flight: Arc::new(BlockFlight::default()),
|
||||||
|
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
|
prefetch: Arc::new(Prefetch::default()),
|
||||||
|
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||||
db,
|
db,
|
||||||
shared_publication: Arc::new(Mutex::new(())),
|
shared_publication: Arc::new(Mutex::new(())),
|
||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
|
timeline_progress: Arc::new(Mutex::new(None)),
|
||||||
|
similar_hashing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_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()),
|
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||||
@@ -384,11 +401,22 @@ pub fn mount(
|
|||||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
self_changes: 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())),
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
|
super::photos::THUMB_GEN_CONCURRENCY,
|
||||||
|
)),
|
||||||
|
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||||
|
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||||
|
quota: Arc::new(Mutex::new(None)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
size_waiters: Arc::new(Default::default()),
|
||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
|
events: Arc::new(Default::default()),
|
||||||
indexing: Arc::new(AtomicBool::new(false)),
|
indexing: Arc::new(AtomicBool::new(false)),
|
||||||
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
sync_progress: Arc::new(Mutex::new(HashMap::new())),
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -399,6 +427,7 @@ pub fn mount(
|
|||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
// every mount's inode space through this registry, not through `core.state`.
|
// every mount's inode space through this registry, not through `core.state`.
|
||||||
core.register_state(mountpoint);
|
core.register_state(mountpoint);
|
||||||
|
core.transfers.set_limits(upload_limit, download_limit);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -407,16 +436,36 @@ pub fn mount(
|
|||||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||||
// see an already-queued write to the same node.
|
// see an already-queued write to the same node.
|
||||||
core.recover_fsynced_writes();
|
core.recover_fsynced_writes();
|
||||||
{
|
// And finally the staged blobs neither of those accounts for: bytes a
|
||||||
|
// failed release left behind with nothing pointing at them.
|
||||||
|
core.reconcile_staging();
|
||||||
|
// Several drain workers over one queue. One thread meant a single large
|
||||||
|
// upload held every queued rename, trash and small write behind it for as
|
||||||
|
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||||
|
// workers off each other's nodes, which is the only ordering the queue
|
||||||
|
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||||
|
//
|
||||||
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
|
// joined at the end of this function. Leaving them running turned an
|
||||||
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
|
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
||||||
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-drain".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_pending_drain())?;
|
.name(format!("pdfs-drain-{worker}"))
|
||||||
|
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
sync::spawn(core.clone(), sync_rx);
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
// A timed pause persisted by an earlier run still has to end on time.
|
||||||
|
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||||
|
core.arm_pause_timer(paused_until);
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -428,18 +477,22 @@ pub fn mount(
|
|||||||
} else {
|
} else {
|
||||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-conflict-sweep".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
.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 {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-online-probe".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_online_probe())?;
|
.name("pdfs-online-probe".into())
|
||||||
|
.spawn(move || core.run_online_probe())?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
@@ -449,9 +502,12 @@ pub fn mount(
|
|||||||
let indexing = core.indexing.clone();
|
let indexing = core.indexing.clone();
|
||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
std::thread::Builder::new()
|
let shutdown = core.shutdown.clone();
|
||||||
.name("pdfs-localindex".into())
|
workers.push(
|
||||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
std::thread::Builder::new()
|
||||||
|
.name("pdfs-localindex".into())
|
||||||
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -488,25 +544,67 @@ pub fn mount(
|
|||||||
let control_core = core.clone();
|
let control_core = core.clone();
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
if let Err(error) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-control".into())
|
.name("pdfs-control".into())
|
||||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||||
{
|
{
|
||||||
let secondaries = core.mounts.lock().close_and_drain();
|
Ok(handle) => workers.push(handle),
|
||||||
for (id, mount) in secondaries {
|
Err(error) => {
|
||||||
if let Err(unmount_error) = mount.teardown() {
|
// A failure here abandons the mount, so the background threads
|
||||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
// started above have to be told to stop with it.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
|
for (id, mount) in secondaries {
|
||||||
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Publishes what changes without one place to publish it from (status,
|
||||||
|
// transfers, sync progress) to the front-ends subscribed on that socket.
|
||||||
|
{
|
||||||
|
let sampler_core = core.clone();
|
||||||
|
let username = username.clone();
|
||||||
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-events".into())
|
||||||
|
.spawn(move || events::run_event_sampler(sampler_core, username, mountpoint))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: front-ends still get every published change, only
|
||||||
|
// not the sampled ones.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the event sampler"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
|
// socket.
|
||||||
|
{
|
||||||
|
let supervisor_core = core.clone();
|
||||||
|
let socket = control_socket.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-supervisor".into())
|
||||||
|
.spawn(move || supervisor::run(supervisor_core, socket))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: the mount works without a supervisor, it just goes back
|
||||||
|
// to being undiagnosable if it wedges.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The mount is live and the control socket answers: this is the moment the
|
||||||
|
// unit is actually usable, which is what `Type=notify` reports.
|
||||||
|
systemd::ready();
|
||||||
|
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
// fetch to observe that closure, and then drain a stable registry.
|
// fetch to observe that closure, and then drain a stable registry.
|
||||||
@@ -524,6 +622,9 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||||
|
// The photos volume has its own event stream; without it the gallery only
|
||||||
|
// learns about a phone-side deletion when the timeline goes stale.
|
||||||
|
rt.spawn(run_photos_event_sync(core.clone()));
|
||||||
if online {
|
if online {
|
||||||
let repair_core = core.clone();
|
let repair_core = core.clone();
|
||||||
let root_uid = core.primary_root_uid.clone();
|
let root_uid = core.primary_root_uid.clone();
|
||||||
@@ -613,10 +714,57 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
// Last, because the workers may legitimately be finishing an upload, and
|
||||||
|
// nothing they can still do is harmful once the mounts are down.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long teardown waits for the FUSE worker pool before proceeding without
|
||||||
|
/// it. Long enough for an in-flight block to finish, short enough that
|
||||||
|
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
||||||
|
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||||
|
///
|
||||||
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
|
/// did not, and started a second full set on top of the first (bugs.md B44).
|
||||||
|
///
|
||||||
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
|
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||||
|
/// one, from us, after the flag is already set. A join that takes a while is
|
||||||
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
|
/// than abandoning the user's bytes mid-flight.
|
||||||
|
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
|
// Say so before the joins: a teardown that takes a while must not be read as
|
||||||
|
// a missed watchdog ping and turned into a kill.
|
||||||
|
systemd::stopping();
|
||||||
|
core.shutdown.stop();
|
||||||
|
core.wake_drain();
|
||||||
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
|
// Connect while the socket still exists — that is the whole point of the
|
||||||
|
// poke — then unlink it so nothing else can arrive behind us. The listener
|
||||||
|
// re-checks the flag after every accept, so even a connection that races the
|
||||||
|
// unlink cannot put it back to sleep.
|
||||||
|
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
for worker in workers {
|
||||||
|
if worker.join().is_err() {
|
||||||
|
warn!("a background worker panicked before shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||||
|
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||||
|
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||||
|
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||||
|
// network cannot hold the stop the user asked for.
|
||||||
|
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
||||||
|
debug!("background workers stopped");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! Pausing sync: the user's "stop uploading for a while".
|
||||||
|
//!
|
||||||
|
//! A pause holds back the two things that push local state to the remote — the
|
||||||
|
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||||
|
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||||
|
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||||
|
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||||
|
//!
|
||||||
|
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||||
|
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||||
|
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||||
|
//! deadline that passed while the daemon was down is already over on mount.
|
||||||
|
//!
|
||||||
|
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||||
|
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||||
|
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||||
|
//! go through the shared queue, which only the global pause holds back.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `state` key holding the resume time, in Unix seconds.
|
||||||
|
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||||
|
|
||||||
|
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||||
|
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||||
|
|
||||||
|
fn folder_paused_key(id: i64) -> String {
|
||||||
|
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||||
|
/// counts as running, like the global pause.
|
||||||
|
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||||
|
match db.state_i64(&folder_paused_key(id)) {
|
||||||
|
Ok(value) => value.is_some_and(|v| v != 0),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist synced folder `id`'s own pause, or clear it.
|
||||||
|
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||||
|
match paused {
|
||||||
|
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||||
|
false => db.clear_state(&folder_paused_key(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pause with no end time: it lasts until the user resumes.
|
||||||
|
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||||
|
|
||||||
|
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||||
|
/// broken state row must not keep sync paused forever).
|
||||||
|
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||||
|
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||||
|
Ok(Some(until)) => until,
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||||
|
/// seconds). `0` is "not paused".
|
||||||
|
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||||
|
until != 0 && now < until
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// True while the user has sync paused.
|
||||||
|
pub(crate) fn sync_paused(&self) -> bool {
|
||||||
|
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pause's end, for status reporting: `None` when not paused,
|
||||||
|
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||||
|
/// Unix second `t`.
|
||||||
|
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||||
|
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||||
|
match paused_at(until, now_secs()) {
|
||||||
|
false => None,
|
||||||
|
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||||
|
true => Some(Some(until)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||||
|
/// it when `paused` is false.
|
||||||
|
pub(crate) fn set_sync_paused(
|
||||||
|
&self,
|
||||||
|
paused: bool,
|
||||||
|
until: Option<i64>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let value = match (paused, until) {
|
||||||
|
(false, _) => 0,
|
||||||
|
(true, None) => PAUSED_INDEFINITELY,
|
||||||
|
(true, Some(until)) if until <= now_secs() => {
|
||||||
|
return Err(CoreError::invalid("the pause would already be over"));
|
||||||
|
}
|
||||||
|
(true, Some(until)) => until,
|
||||||
|
};
|
||||||
|
let persisted = match value {
|
||||||
|
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||||
|
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||||
|
};
|
||||||
|
if let Err(error) = persisted {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the sync pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||||
|
match value {
|
||||||
|
0 => {
|
||||||
|
info!("sync resumed");
|
||||||
|
self.resume_sync_work();
|
||||||
|
}
|
||||||
|
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||||
|
until => {
|
||||||
|
info!(until, "sync paused");
|
||||||
|
self.arm_pause_timer(until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the user has this one synced folder paused. A row that cannot
|
||||||
|
/// be read counts as running, like the global pause.
|
||||||
|
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||||
|
load_folder_paused(&self.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||||
|
/// straight away to catch up on what the watcher saw meanwhile.
|
||||||
|
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||||
|
let folder = self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if paused && folder.mode != "mirror" {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||||
|
through the shared queue, so pause all sync instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the folder pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync paused");
|
||||||
|
} else {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||||
|
self.sync_now(Some(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||||
|
/// not start out paused.
|
||||||
|
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||||
|
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake everything a pause held back: the drain worker, and a full
|
||||||
|
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||||
|
fn resume_sync_work(&self) {
|
||||||
|
self.wake_drain();
|
||||||
|
self.sync_now(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||||
|
/// the stored deadline, which the timer checks before acting, so a stale
|
||||||
|
/// timer does nothing.
|
||||||
|
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||||
|
let core = self.clone();
|
||||||
|
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-pause".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if !core.shutdown.sleep(wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if core
|
||||||
|
.sync_paused_until
|
||||||
|
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||||
|
warn!(%error, "clearing an expired sync pause failed");
|
||||||
|
}
|
||||||
|
info!("sync pause ended");
|
||||||
|
core.resume_sync_work();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
// The drain and the reconcile still read the clock, so the pause
|
||||||
|
// still ends — just at the next idle poll instead of on time.
|
||||||
|
warn!(%error, "starting the sync pause timer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pause_is_in_force_until_its_deadline() {
|
||||||
|
assert!(!paused_at(0, 100));
|
||||||
|
assert!(paused_at(200, 100));
|
||||||
|
assert!(!paused_at(200, 200));
|
||||||
|
assert!(!paused_at(200, 300));
|
||||||
|
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
store_folder_paused(&db, 3, true).unwrap();
|
||||||
|
assert!(load_folder_paused(&db, 3));
|
||||||
|
assert!(!load_folder_paused(&db, 4));
|
||||||
|
assert!(!load_folder_paused(&db, 33));
|
||||||
|
store_folder_paused(&db, 3, false).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,15 +248,19 @@ impl Core {
|
|||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore (features.md 5.2) ----------------------------------------
|
||||||
|
|
||||||
/// The folders under this machine's device that could be synced here, each
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// carrying a proposed local path.
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
|
/// `None` is this machine's own.
|
||||||
///
|
///
|
||||||
/// The proposal comes from the profile when one exists and its path is
|
/// The proposal comes from the profile when one exists and its path is
|
||||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||||
/// does not exist, and silently creating it would be worse.
|
/// does not exist, and silently creating it would be worse.
|
||||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
pub(crate) fn list_restorable_folders(
|
||||||
let device = self.ensure_device()?;
|
&self,
|
||||||
|
device: Option<&str>,
|
||||||
|
) -> CoreResult<Vec<RestorableFolder>> {
|
||||||
|
let device = self.restore_source(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))
|
||||||
})?;
|
})?;
|
||||||
@@ -334,11 +338,12 @@ impl Core {
|
|||||||
pub(crate) fn restore_sync_folders(
|
pub(crate) fn restore_sync_folders(
|
||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<String> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
let device = self.ensure_device()?;
|
let device = self.restore_source(device)?;
|
||||||
let existing = self
|
let existing = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -499,6 +504,19 @@ mod tests {
|
|||||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restoring another computer's folders reads that device, but must not
|
||||||
|
/// rewrite the cached device row: this machine would then back up as the
|
||||||
|
/// other computer.
|
||||||
|
#[test]
|
||||||
|
fn restoring_from_another_device_keeps_this_machines_identity() {
|
||||||
|
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
|
||||||
|
assert!(source.contains("ensure_device()"), "None is this machine");
|
||||||
|
assert!(
|
||||||
|
!source.contains("device_set"),
|
||||||
|
"the cached row is untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||||
let start = source.find(signature).expect("function signature exists");
|
let start = source.find(signature).expect("function signature exists");
|
||||||
let body = &source[start..];
|
let body = &source[start..];
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! The user's view of the pending-op queue: what has not reached the remote
|
||||||
|
//! yet, why, and a way to stop waiting out a backoff.
|
||||||
|
//!
|
||||||
|
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||||
|
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||||
|
//! user's write (see `docs/RECOVERY.md`).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::PendingOpInfo;
|
||||||
|
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Every queued op, oldest first, with a path a person can read.
|
||||||
|
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
Ok(ops
|
||||||
|
.into_iter()
|
||||||
|
.map(|op| {
|
||||||
|
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||||
|
let path = self
|
||||||
|
.db
|
||||||
|
.node_path(&op.uid)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.or_else(|| op.name.clone())
|
||||||
|
.unwrap_or_else(|| op.uid.clone());
|
||||||
|
PendingOpInfo {
|
||||||
|
id: op.id,
|
||||||
|
kind: op.kind,
|
||||||
|
path,
|
||||||
|
attempts: op.attempts,
|
||||||
|
last_error: op.last_error,
|
||||||
|
queued_at: op.created_at / 1000,
|
||||||
|
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||||
|
parked,
|
||||||
|
failing: op.attempts >= FAILING_ATTEMPTS,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||||
|
/// Returns how many ops were moved up.
|
||||||
|
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||||
|
let now = now_millis();
|
||||||
|
let moved = match id {
|
||||||
|
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||||
|
None => self.db.retry_failed_ops_now(now),
|
||||||
|
}
|
||||||
|
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||||
|
if moved > 0 {
|
||||||
|
self.wake_drain();
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Repairing photos whose capture time is when they were *imported* rather than
|
||||||
|
//! when they were taken.
|
||||||
|
//!
|
||||||
|
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
|
||||||
|
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
|
||||||
|
//! and the SDK then seals "now" into every revision. A decade of photos lands on
|
||||||
|
//! one day.
|
||||||
|
//!
|
||||||
|
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||||
|
//! rewrite: download the photo, upload it again with the capture time its file
|
||||||
|
//! name implies, put the new copy back into the albums and favorites the old
|
||||||
|
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||||
|
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||||
|
//!
|
||||||
|
//! A photo is a candidate only when its name carries a date that disagrees with
|
||||||
|
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
|
||||||
|
//! a photo whose name says nothing about its date is left alone, because there
|
||||||
|
//! is nothing better to set it to.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{RedateSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::parse_uid as parse_node_uid;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// How far a stored capture time may sit from the one the file name implies
|
||||||
|
/// before the photo counts as mis-dated. A day, because a name usually carries
|
||||||
|
/// only the date and the stored time carries a clock — and because a timezone
|
||||||
|
/// can legitimately move a photo by hours.
|
||||||
|
const DRIFT: i64 = 86_400;
|
||||||
|
|
||||||
|
/// How many examples a report carries.
|
||||||
|
const SAMPLES: usize = 10;
|
||||||
|
|
||||||
|
/// The one re-date this daemon may be running, and how the last one ended.
|
||||||
|
/// Process-global for the same reasons as the Takeout import's state: one run
|
||||||
|
/// per daemon however many `Core` clones exist, and a front-end asking after it
|
||||||
|
/// finished must still find the report.
|
||||||
|
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RedateState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<RedateSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a re-date is running, and the last one's report.
|
||||||
|
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||||
|
let state = REDATE.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running re-date to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_redate() -> bool {
|
||||||
|
match &REDATE.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to repair.
|
||||||
|
struct Candidate {
|
||||||
|
uid: NodeUid,
|
||||||
|
name: String,
|
||||||
|
/// The capture time it carries now.
|
||||||
|
stored: i64,
|
||||||
|
/// The capture time its name implies.
|
||||||
|
wanted: i64,
|
||||||
|
favorite: bool,
|
||||||
|
/// Album node uids the original is filed under.
|
||||||
|
albums: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Re-date mis-dated photos. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary. A photo is only ever
|
||||||
|
/// trashed after its replacement has uploaded, so a cancel — or a crash —
|
||||||
|
/// can leave a duplicate, never a hole.
|
||||||
|
pub(crate) fn redate_photos(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<RedateSummary> {
|
||||||
|
let job = self.transfers.begin_job("Fixing photo dates");
|
||||||
|
job.detail("looking for mis-dated photos");
|
||||||
|
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.photos_all()
|
||||||
|
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
|
||||||
|
let mut summary = RedateSummary {
|
||||||
|
examined: stored.len(),
|
||||||
|
dry_run,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for photo in stored {
|
||||||
|
if let Some((from, to)) = range
|
||||||
|
&& !(from..to).contains(&photo.capture_time)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(wanted) = capture_time_from_name(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if (wanted - photo.capture_time).abs() <= DRIFT {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(uid) = parse_node_uid(&photo.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
candidates.push(Candidate {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
stored: photo.capture_time,
|
||||||
|
wanted,
|
||||||
|
favorite: photo.favorite,
|
||||||
|
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
candidates.truncate(limit);
|
||||||
|
}
|
||||||
|
summary.candidates = candidates.len();
|
||||||
|
// Sampled after the limit, so the examples are of photos the run would
|
||||||
|
// actually touch.
|
||||||
|
summary.samples = candidates
|
||||||
|
.iter()
|
||||||
|
.take(SAMPLES)
|
||||||
|
.map(|c| (c.name.clone(), c.stored, c.wanted))
|
||||||
|
.collect();
|
||||||
|
job.set_total(candidates.len() as u64);
|
||||||
|
info!(
|
||||||
|
examined = summary.examined,
|
||||||
|
candidates = summary.candidates,
|
||||||
|
dry_run,
|
||||||
|
"photo re-date scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
if dry_run || candidates.is_empty() {
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
job.detail(candidate.name.clone());
|
||||||
|
if let Err(e) = self.redate_one(&candidate, &mut summary) {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-date photo");
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite one photo at the capture time its name implies.
|
||||||
|
///
|
||||||
|
/// Order is load-bearing: upload first, file the new copy, and only then
|
||||||
|
/// trash the original. Every earlier step failing leaves the account exactly
|
||||||
|
/// as it was.
|
||||||
|
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let path = self.open_photo(&candidate.uid)?;
|
||||||
|
let size = std::fs::metadata(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||||
|
.len();
|
||||||
|
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
let guard =
|
||||||
|
self.transfers
|
||||||
|
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
|
||||||
|
let file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
|
||||||
|
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: Some(candidate.wanted),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&candidate.name,
|
||||||
|
media_type,
|
||||||
|
reader,
|
||||||
|
size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
drop(guard);
|
||||||
|
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||||
|
|
||||||
|
// Favorite and albums before the trash: a failure here leaves both
|
||||||
|
// copies on the account, which is recoverable by hand. Losing the
|
||||||
|
// original first would not be.
|
||||||
|
if candidate.favorite {
|
||||||
|
let update = PhotoTagsUpdate {
|
||||||
|
node_uid: new_uid.clone(),
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
};
|
||||||
|
match self.rt.block_on(photos.update_photos(&[update])) {
|
||||||
|
// As in `set_photo_favorite`: claim our own echo, so the tag we
|
||||||
|
// just set does not come back as a remote change.
|
||||||
|
Ok(_) => self.note_self_change(&new_uid),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for album in &candidate.albums {
|
||||||
|
let Some(album_uid) = parse_node_uid(album) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
|
||||||
|
{
|
||||||
|
Ok(_) => summary.album_links += 1,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||||
|
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||||
|
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.redated += 1;
|
||||||
|
summary.bytes += size;
|
||||||
|
info!(
|
||||||
|
name = candidate.name,
|
||||||
|
from = candidate.stored,
|
||||||
|
to = candidate.wanted,
|
||||||
|
"photo re-dated"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a re-date on its own thread. Errors when one is already running.
|
||||||
|
pub(crate) fn spawn_redate(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"a photo re-date is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.redate_photos(dry_run, limit, range, cancel);
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
match &outcome {
|
||||||
|
Ok(summary) => info!(
|
||||||
|
candidates = summary.candidates,
|
||||||
|
redated = summary.redated,
|
||||||
|
failed = summary.failed,
|
||||||
|
"photo re-date finished"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(error = %e, "photo re-date failed"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_uid_without_both_halves_is_not_a_node() {
|
||||||
|
assert!(parse_node_uid("vol~link").is_some());
|
||||||
|
assert!(parse_node_uid("vol~").is_none());
|
||||||
|
assert!(parse_node_uid("~link").is_none());
|
||||||
|
assert!(parse_node_uid("nothing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_disagreement_larger_than_a_day_counts() {
|
||||||
|
// The name says 2023-02-19; a stored time inside that day is fine.
|
||||||
|
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
|
||||||
|
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
|
||||||
|
// An import three years later is not.
|
||||||
|
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ use pdfs_core::control::{
|
|||||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::QUOTA_TTL;
|
||||||
|
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{MemberRole, NodeKind};
|
use proton_drive_rs::{MemberRole, NodeKind};
|
||||||
|
|
||||||
@@ -44,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
.map(role_to_str)
|
.map(role_to_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let invitation = n.membership.as_ref();
|
||||||
|
let shared_by = invitation
|
||||||
|
.and_then(|m| m.inviter_email.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||||
|
// Only a signature that is present and wrong is a warning. An invitation
|
||||||
|
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||||
|
// is not evidence of forgery, and flagging every such share would teach the
|
||||||
|
// user to ignore the flag.
|
||||||
|
let shared_by_unverified = invitation
|
||||||
|
.and_then(|m| m.inviter_verification)
|
||||||
|
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -54,6 +70,11 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
role,
|
role,
|
||||||
|
shared_by,
|
||||||
|
shared_at,
|
||||||
|
shared_by_unverified,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +390,66 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop sharing the node at `rel`: remove its public link, then every
|
||||||
|
/// external invitation, Proton invitation and member. The SDK has no call
|
||||||
|
/// that deletes a share outright, so this composes the per-entry ones.
|
||||||
|
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each step runs even when an earlier one failed, so a single stuck
|
||||||
|
/// member does not leave the public link up. The link goes first: it is the
|
||||||
|
/// widest exposure. Only a failure to list what is there fails the whole
|
||||||
|
/// request; a failed removal is counted in the outcome.
|
||||||
|
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
let link = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.get_public_link(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
|
||||||
|
if let Some(link) = link {
|
||||||
|
let result = self.rt.block_on(self.client.remove_public_link(&link));
|
||||||
|
outcome.record("the public link", result);
|
||||||
|
}
|
||||||
|
let external = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_external_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
|
||||||
|
for ext in external {
|
||||||
|
let result = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.delete_external_invitation(&ext));
|
||||||
|
outcome.record(&ext.invitee_email, result);
|
||||||
|
}
|
||||||
|
let invitations = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||||
|
for inv in invitations {
|
||||||
|
let result = self.rt.block_on(self.client.delete_invitation(&inv));
|
||||||
|
outcome.record(&inv.invitee_email, result);
|
||||||
|
}
|
||||||
|
let members = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_members(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list members"))?;
|
||||||
|
for member in members {
|
||||||
|
let result = self.rt.block_on(self.client.remove_member(&member));
|
||||||
|
outcome.record(&member.email, result);
|
||||||
|
}
|
||||||
|
if !outcome.failed.is_empty() {
|
||||||
|
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared with me ---------------------------------------------------
|
// ---- shared with me ---------------------------------------------------
|
||||||
|
|
||||||
/// List nodes shared with me that I have accepted.
|
/// List nodes shared with me that I have accepted.
|
||||||
@@ -533,9 +614,33 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.quota())
|
.block_on(self.client.quota())
|
||||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||||
|
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||||
Ok((q.max_space, q.used_space))
|
Ok((q.max_space, q.used_space))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||||
|
/// refetched after that, and `None` when there is no usable answer at all —
|
||||||
|
/// offline with nothing cached.
|
||||||
|
///
|
||||||
|
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||||
|
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||||
|
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||||
|
/// the last known figures keep being served rather than degrading to zero.
|
||||||
|
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||||
|
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||||
|
&& at.elapsed() < QUOTA_TTL
|
||||||
|
{
|
||||||
|
return Some((max_space, used_space));
|
||||||
|
}
|
||||||
|
match self.account_quota() {
|
||||||
|
Ok(q) => Some(q),
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||||
|
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared by me -----------------------------------------------------
|
// ---- shared by me -----------------------------------------------------
|
||||||
|
|
||||||
/// List the nodes I have shared with others, each with a summary of its share
|
/// List the nodes I have shared with others, each with a summary of its share
|
||||||
@@ -582,6 +687,7 @@ impl Core {
|
|||||||
items.push(SharedItem {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
modified: n.modification_time,
|
||||||
name: n.name,
|
name: n.name,
|
||||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||||
member_count: members,
|
member_count: members,
|
||||||
@@ -613,6 +719,47 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`Core::stop_sharing`] managed to remove.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct StopSharingOutcome {
|
||||||
|
/// Entries removed (the link, invitations and members alike).
|
||||||
|
pub(crate) removed: usize,
|
||||||
|
/// What could not be removed, by email (or "the public link"), with why.
|
||||||
|
pub(crate) failed: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StopSharingOutcome {
|
||||||
|
fn record<E: std::fmt::Display>(&mut self, what: &str, result: Result<(), E>) {
|
||||||
|
match result {
|
||||||
|
Ok(()) => self.removed += 1,
|
||||||
|
Err(e) => self.failed.push((what.to_string(), e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply for the control socket: a sentence for a full success, an
|
||||||
|
/// error naming what is left otherwise.
|
||||||
|
pub(crate) fn into_result(self) -> Result<String, String> {
|
||||||
|
if self.failed.is_empty() {
|
||||||
|
return Ok(match self.removed {
|
||||||
|
0 => "it was not shared".to_string(),
|
||||||
|
1 => "stopped sharing: removed 1 entry".to_string(),
|
||||||
|
n => format!("stopped sharing: removed {n} entries"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let left = self
|
||||||
|
.failed
|
||||||
|
.iter()
|
||||||
|
.map(|(what, why)| format!("{what} ({why})"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
Err(format!(
|
||||||
|
"removed {} of {}; still shared: {left}",
|
||||||
|
self.removed,
|
||||||
|
self.removed + self.failed.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
/// 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 —
|
/// that mount has never interned the node, or if the chain breaks partway —
|
||||||
/// a broken chain is a missing path, never a partial one.
|
/// a broken chain is a missing path, never a partial one.
|
||||||
@@ -627,3 +774,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
|||||||
parts.reverse();
|
parts.reverse();
|
||||||
Some(parts.join("/"))
|
Some(parts.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_a_share_that_is_gone_says_so() {
|
||||||
|
let outcome = StopSharingOutcome::default();
|
||||||
|
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_removal_names_what_is_still_shared() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record("bob@example.com", Err("forbidden"));
|
||||||
|
outcome.record::<String>("carol@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap_err(),
|
||||||
|
"removed 2 of 3; still shared: bob@example.com (forbidden)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_removal_counts_the_entries() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record::<String>("bob@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap(),
|
||||||
|
"stopped sharing: removed 2 entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! One shutdown signal shared by every long-lived worker in the daemon.
|
||||||
|
//!
|
||||||
|
//! The mount used to end by unmounting and returning, leaving the drain
|
||||||
|
//! workers, the sync engine, the conflict sweep, the online probe and the local
|
||||||
|
//! indexer running on `Core` clones. In a process that exits immediately that is
|
||||||
|
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||||
|
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||||
|
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||||
|
//! longer exists (bugs.md B44).
|
||||||
|
//!
|
||||||
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
|
||||||
|
//! called, so teardown does not have to wait out a five-minute sweep interval to
|
||||||
|
//! join a thread.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex};
|
||||||
|
|
||||||
|
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
|
||||||
|
/// told to stop belongs to a mount that is going away.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Shutdown {
|
||||||
|
stopping: Mutex<bool>,
|
||||||
|
changed: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shutdown {
|
||||||
|
/// Whether teardown has begun. Loops check this where they would otherwise
|
||||||
|
/// start another unit of work.
|
||||||
|
pub(crate) fn is_stopping(&self) -> bool {
|
||||||
|
*self.stopping.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
|
||||||
|
/// Idempotent, because more than one path can reach teardown (a signal, the
|
||||||
|
/// kernel mount ending, a failed startup).
|
||||||
|
pub(crate) fn stop(&self) {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
*stopping = true;
|
||||||
|
self.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait up to `duration`, or until teardown starts.
|
||||||
|
///
|
||||||
|
/// Returns `false` if the daemon is stopping — including when it was already
|
||||||
|
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
|
||||||
|
/// correct loop condition and no separate check is needed at the top.
|
||||||
|
pub(crate) fn sleep(&self, duration: Duration) -> bool {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
if !*stopping {
|
||||||
|
self.changed.wait_for(&mut stopping, duration);
|
||||||
|
}
|
||||||
|
!*stopping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_returns_early_once_stopped() {
|
||||||
|
let shutdown = Arc::new(Shutdown::default());
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
|
||||||
|
let waker = shutdown.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
waker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
// An hour's worth of interval, cut short by the stop.
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"stop must interrupt the wait, not wait it out"
|
||||||
|
);
|
||||||
|
assert!(shutdown.is_stopping());
|
||||||
|
// Already stopping: the next wait does not sleep at all.
|
||||||
|
let started = Instant::now();
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_runs_to_the_timeout_while_running() {
|
||||||
|
let shutdown = Shutdown::default();
|
||||||
|
assert!(shutdown.sleep(Duration::from_millis(10)));
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,11 @@ pub(crate) struct PendingRevision {
|
|||||||
pub(crate) meta: StagedWrite,
|
pub(crate) meta: StagedWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One directory's entries as `readdir` reports them: `(ino, is_dir, name)` per
|
||||||
|
/// entry, in reply order. The kernel's file-type enum is mapped in at reply
|
||||||
|
/// time so this module stays free of `fuser`.
|
||||||
|
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
||||||
|
|
||||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
/// `Filesystem` trait through `&self`.
|
/// `Filesystem` trait through `&self`.
|
||||||
pub(crate) struct State {
|
pub(crate) struct State {
|
||||||
@@ -158,6 +163,16 @@ pub(crate) struct State {
|
|||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
pub(crate) handles: HashMap<u64, u64>,
|
pub(crate) handles: HashMap<u64, u64>,
|
||||||
pub(crate) next_fh: u64,
|
pub(crate) next_fh: u64,
|
||||||
|
/// One frozen listing per open directory handle, taken at `opendir`.
|
||||||
|
///
|
||||||
|
/// `readdir` resumes from a byte offset into the listing it is walking, so
|
||||||
|
/// that listing has to stay the same across the calls of one `getdents`
|
||||||
|
/// sequence. Rebuilding it live meant a create or a trash landing between
|
||||||
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
|
/// module stays free of `fuser` (bugs.md B43).
|
||||||
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
/// kernel only after releasing the State lock.
|
/// kernel only after releasing the State lock.
|
||||||
@@ -166,13 +181,164 @@ pub(crate) struct State {
|
|||||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||||
/// this map instead of issuing one SQLite query per interned inode.
|
/// this map instead of issuing one SQLite query per interned inode.
|
||||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||||
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
/// Unified SQLite metadata cache. Read from here (`has_children`) but never
|
||||||
/// it inside the `State` lock so the DB stays the authoritative copy across
|
/// written: mutations go to [`State::outbox`] and are applied after the lock
|
||||||
/// restarts (see plan.md P1).
|
/// is released. See [`StateGuard`].
|
||||||
pub(crate) db: Arc<Db>,
|
pub(crate) db: Arc<Db>,
|
||||||
|
/// Write-throughs this mutation owes SQLite, applied by [`StateGuard`] once
|
||||||
|
/// the inode lock is released.
|
||||||
|
///
|
||||||
|
/// The DB stays the authoritative copy across restarts, so every map
|
||||||
|
/// mutation still writes through — it just no longer does so with the
|
||||||
|
/// whole-mount lock held. A single listing's write-through is one
|
||||||
|
/// transaction over up to a few thousand rows, and every other FUSE callback
|
||||||
|
/// used to wait behind it for no reason: the maps were already updated
|
||||||
|
/// before the first row was written.
|
||||||
|
outbox: Vec<DbWrite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A write-through queued by a `State` mutation, applied after the lock drops.
|
||||||
|
///
|
||||||
|
/// Deliberately a small closed set rather than a boxed closure: these run
|
||||||
|
/// outside the lock, and what they can touch should be readable here.
|
||||||
|
pub(crate) enum DbWrite {
|
||||||
|
/// One node or a whole listing. Both go through `upsert_nodes`, which is a
|
||||||
|
/// single transaction either way, so there is nothing to gain by splitting
|
||||||
|
/// them — and a `Node` is large enough that a variant holding one inline
|
||||||
|
/// would set the size of every other.
|
||||||
|
Upsert(Vec<Node>),
|
||||||
|
Delete(NodeUid),
|
||||||
|
SetListed(NodeUid, bool),
|
||||||
|
SetShareAccess(NodeUid, Access),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbWrite {
|
||||||
|
fn apply(self, db: &Db) {
|
||||||
|
let outcome = match &self {
|
||||||
|
Self::Upsert(nodes) => db.upsert_nodes(nodes),
|
||||||
|
Self::Delete(uid) => db.delete_node(uid),
|
||||||
|
Self::SetListed(uid, listed) => db.set_listed(uid, *listed),
|
||||||
|
Self::SetShareAccess(uid, access) => db.set_share_access(uid, *access),
|
||||||
|
};
|
||||||
|
if let Err(error) = outcome {
|
||||||
|
let what = match &self {
|
||||||
|
Self::Upsert(_) => "upsert_nodes",
|
||||||
|
Self::Delete(_) => "delete_node",
|
||||||
|
Self::SetListed(..) => "set_listed",
|
||||||
|
Self::SetShareAccess(..) => "set_share_access",
|
||||||
|
};
|
||||||
|
warn!(%error, write = what, "db write-through failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inode lock, held for the duration of a mutation, plus the write-throughs
|
||||||
|
/// that mutation owed the database.
|
||||||
|
///
|
||||||
|
/// Dropping it releases the lock *first* and only then writes, which is the
|
||||||
|
/// whole point: interning a five-thousand-child listing commits one transaction
|
||||||
|
/// — several fsyncs' worth — and nothing about that needs the maps frozen. The
|
||||||
|
/// write still happens synchronously on this thread before the caller proceeds,
|
||||||
|
/// so a read that follows a mutation still sees it.
|
||||||
|
pub(crate) struct StateGuard<'a> {
|
||||||
|
guard: Option<parking_lot::MutexGuard<'a, State>>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
||||||
|
/// every live mount rather than this `Core`'s own.
|
||||||
|
pub(crate) fn lock_state<'a>(
|
||||||
|
state: &'a parking_lot::Mutex<State>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
) -> StateGuard<'a> {
|
||||||
|
StateGuard::new(state.lock(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StateGuard<'a> {
|
||||||
|
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||||
|
Self {
|
||||||
|
guard: Some(guard),
|
||||||
|
db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for StateGuard<'_> {
|
||||||
|
type Target = State;
|
||||||
|
|
||||||
|
fn deref(&self) -> &State {
|
||||||
|
self.guard.as_ref().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for StateGuard<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut State {
|
||||||
|
self.guard.as_mut().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StateGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(mut guard) = self.guard.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let writes = std::mem::take(&mut guard.outbox);
|
||||||
|
// The lock goes before the writes do. That ordering is the feature.
|
||||||
|
drop(guard);
|
||||||
|
for write in writes {
|
||||||
|
write.apply(self.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
|
/// A fresh, empty inode space over `db`.
|
||||||
|
///
|
||||||
|
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
||||||
|
/// to its root and starts allocating above it, while a bare state under test
|
||||||
|
/// has no root and starts at 1.
|
||||||
|
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||||
|
///
|
||||||
|
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||||
|
/// first and then applies them, which is the whole point. A test driving a
|
||||||
|
/// bare `State` has no guard, so it calls this where the daemon would have
|
||||||
|
/// dropped one.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flush_outbox(&mut self) {
|
||||||
|
let db = self.db.clone();
|
||||||
|
for write in std::mem::take(&mut self.outbox) {
|
||||||
|
write.apply(&db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database, with everything this state owes it already written.
|
||||||
|
///
|
||||||
|
/// What a test means by `st.db` is almost always "the database as of now",
|
||||||
|
/// which in the daemon is the state after the guard dropped. Handing back an
|
||||||
|
/// owned `Arc` rather than a borrow keeps it usable inside an `assert_eq!`
|
||||||
|
/// that also touches `st`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flushed_db(&mut self) -> Arc<Db> {
|
||||||
|
self.flush_outbox();
|
||||||
|
self.db.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
by_uid: HashMap::new(),
|
||||||
|
children: HashMap::new(),
|
||||||
|
next_ino,
|
||||||
|
active_writes: HashMap::new(),
|
||||||
|
handles: HashMap::new(),
|
||||||
|
next_fh: 1,
|
||||||
|
dir_snapshots: HashMap::new(),
|
||||||
|
access_changes: HashSet::new(),
|
||||||
|
share_access,
|
||||||
|
db,
|
||||||
|
outbox: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
/// Whether `uid` names an owned node currently reachable from this mount's
|
||||||
/// root. Resident entries can outlive their dentries for open-handle and
|
/// root. Resident entries can outlive their dentries for open-handle and
|
||||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
||||||
@@ -278,11 +444,9 @@ impl State {
|
|||||||
|| stored.unwrap_or(Access::Viewer),
|
|| stored.unwrap_or(Access::Viewer),
|
||||||
|membership| access_for(membership.role_exact(), parent_access, true),
|
|membership| access_for(membership.role_exact(), parent_access, true),
|
||||||
);
|
);
|
||||||
if let Err(error) = self.db.set_share_access(&node.uid, access) {
|
self.outbox
|
||||||
warn!(uid = %node.uid, ?access, %error, "db set_share_access failed");
|
.push(DbWrite::SetShareAccess(node.uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(node.uid.clone(), access);
|
||||||
self.share_access.insert(node.uid.clone(), access);
|
|
||||||
}
|
|
||||||
return access;
|
return access;
|
||||||
}
|
}
|
||||||
parent_access
|
parent_access
|
||||||
@@ -313,11 +477,9 @@ impl State {
|
|||||||
None => (None, Access::Unknown),
|
None => (None, Access::Unknown),
|
||||||
};
|
};
|
||||||
let access = access_for(role, parent_access, true);
|
let access = access_for(role, parent_access, true);
|
||||||
if let Err(error) = self.db.set_share_access(uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set observed share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid.clone(), access);
|
||||||
self.share_access.insert(uid.clone(), access);
|
|
||||||
}
|
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -602,18 +764,14 @@ impl State {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for (uid, access) in discovered {
|
for (uid, access) in discovered {
|
||||||
if let Err(error) = self.db.set_share_access(&uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set hydrated share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid, access);
|
||||||
self.share_access.insert(uid, access);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
|
||||||
}
|
|
||||||
self.intern_mem(parent, node)
|
self.intern_mem(parent, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,9 +843,7 @@ impl State {
|
|||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// listing, rather than one autocommit — and one fsync — per child.
|
||||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
warn!(error = %e, "db upsert_nodes failed");
|
|
||||||
}
|
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| self.intern_mem(parent, node))
|
.map(|node| self.intern_mem(parent, node))
|
||||||
@@ -769,9 +925,7 @@ impl State {
|
|||||||
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||||
/// the kernel.
|
/// the kernel.
|
||||||
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
self.outbox.push(DbWrite::Delete(uid.clone()));
|
||||||
warn!(%uid, error = %e, "db delete_node failed");
|
|
||||||
}
|
|
||||||
self.forget_mem(uid)
|
self.forget_mem(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,9 +988,7 @@ impl State {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
|
||||||
}
|
|
||||||
self.recompute_descendant_access(ino);
|
self.recompute_descendant_access(ino);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,9 +1003,7 @@ impl State {
|
|||||||
self.children.remove(&ino);
|
self.children.remove(&ino);
|
||||||
if let Some(e) = self.entries.get(&ino) {
|
if let Some(e) = self.entries.get(&ino) {
|
||||||
let uid = e.uid.clone();
|
let uid = e.uid.clone();
|
||||||
if let Err(err) = self.db.set_listed(&uid, false) {
|
self.outbox.push(DbWrite::SetListed(uid, false));
|
||||||
warn!(%uid, error = %err, "db set_listed(false) failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,14 +1070,15 @@ impl State {
|
|||||||
/// file is as long as the caller was told it is. Without it a restart serves
|
/// file is as long as the caller was told it is. Without it a restart serves
|
||||||
/// the stale size and the file reads as truncated — or empty — while its
|
/// the stale size and the file reads as truncated — or empty — while its
|
||||||
/// bytes sit safely in staging (offline.md Phase 3).
|
/// bytes sit safely in staging (offline.md Phase 3).
|
||||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||||
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
|
/// the daemon acknowledged a write and then serves the old size for it, so
|
||||||
|
/// the caller has to be able to turn it into a failed `release`.
|
||||||
|
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) -> Option<Node> {
|
||||||
self.set_size(ino, size);
|
self.set_size(ino, size);
|
||||||
self.touch_mtime(ino, mtime);
|
self.touch_mtime(ino, mtime);
|
||||||
if let Some(e) = self.entries.get(&ino)
|
self.entries.get(&ino).map(|e| e.node.clone())
|
||||||
&& let Err(err) = self.db.upsert_node(&e.node)
|
|
||||||
{
|
|
||||||
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1120,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1130,9 @@ mod tests {
|
|||||||
share_id: ShareId::from("share"),
|
share_id: ShareId::from("share"),
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,18 +1164,7 @@ mod tests {
|
|||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||||
let share_access = db.all_share_access().unwrap();
|
let share_access = db.all_share_access().unwrap();
|
||||||
let st = State {
|
let st = State::new(Arc::new(db), share_access, 1);
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 1,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: Arc::new(db),
|
|
||||||
};
|
|
||||||
(st, dir)
|
(st, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,8 +1177,8 @@ mod tests {
|
|||||||
let f = st.intern(src, node("f", "src", "f.txt", false));
|
let f = st.intern(src, node("f", "src", "f.txt", false));
|
||||||
st.children.insert(src, vec![f]);
|
st.children.insert(src, vec![f]);
|
||||||
st.children.insert(dst, vec![]);
|
st.children.insert(dst, vec![]);
|
||||||
st.db.set_listed(&uid("src"), true).unwrap();
|
st.flushed_db().set_listed(&uid("src"), true).unwrap();
|
||||||
st.db.set_listed(&uid("dst"), true).unwrap();
|
st.flushed_db().set_listed(&uid("dst"), true).unwrap();
|
||||||
(st, dir, src, dst)
|
(st, dir, src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,11 +1192,17 @@ mod tests {
|
|||||||
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("dst"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"destination re-enumerates: it gained a child whose row was just deleted"
|
"destination re-enumerates: it gained a child whose row was just deleted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"source re-enumerates: its listing predates the move, and a stale \
|
"source re-enumerates: its listing predates the move, and a stale \
|
||||||
`listed` flag would hide every later remote change under it"
|
`listed` flag would hide every later remote change under it"
|
||||||
);
|
);
|
||||||
@@ -1074,12 +1225,41 @@ mod tests {
|
|||||||
let (mut st, _dir, src, _dst) = two_folders();
|
let (mut st, _dir, src, _dst) = two_folders();
|
||||||
let f = st.by_uid[&uid("f")];
|
let f = st.by_uid[&uid("f")];
|
||||||
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
||||||
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
assert!(
|
||||||
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert!(!st.children.contains_key(&src));
|
assert!(!st.children.contains_key(&src));
|
||||||
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
||||||
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DB1: a mutation's write-through must not happen with the inode lock
|
||||||
|
/// held. Interning a listing commits a transaction over every child, and
|
||||||
|
/// every other FUSE callback used to wait behind that for no reason — the
|
||||||
|
/// maps it needs were updated before the first row was written.
|
||||||
|
#[test]
|
||||||
|
fn a_mutation_writes_through_only_after_the_lock_is_released() {
|
||||||
|
let (st, _dir) = state();
|
||||||
|
let db = st.db.clone();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
let state = parking_lot::Mutex::new(st);
|
||||||
|
{
|
||||||
|
let mut guard = lock_state(&state, &db);
|
||||||
|
guard.intern(0, node("root", "none", "My Files", true));
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_none(),
|
||||||
|
"the row must not be written while the lock is held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_some(),
|
||||||
|
"…and must be written by the time the guard is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The failure mode the fix exists for, stated directly: forgetting the node
|
/// The failure mode the fix exists for, stated directly: forgetting the node
|
||||||
/// prunes it from the resident listing but leaves the DB claiming the source
|
/// prunes it from the resident listing but leaves the DB claiming the source
|
||||||
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
||||||
@@ -1087,7 +1267,7 @@ mod tests {
|
|||||||
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
||||||
let (mut st, _dir, _src, _dst) = two_folders();
|
let (mut st, _dir, _src, _dst) = two_folders();
|
||||||
st.forget(&uid("f"));
|
st.forget(&uid("f"));
|
||||||
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
let listed = st.flushed_db().children_if_listed(&uid("src")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
listed.is_some(),
|
listed.is_some(),
|
||||||
"forget does not clear the flag — which is exactly why relocate must"
|
"forget does not clear the flag — which is exactly why relocate must"
|
||||||
@@ -1136,7 +1316,7 @@ mod tests {
|
|||||||
assert!(!st.has_children(folder), "cleared memory children");
|
assert!(!st.has_children(folder), "cleared memory children");
|
||||||
|
|
||||||
// DB children
|
// DB children
|
||||||
st.db
|
st.flushed_db()
|
||||||
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1162,7 +1342,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Editor)
|
Some(Access::Editor)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1174,8 +1354,8 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
||||||
virtual_root.uid = virtual_uid.clone();
|
virtual_root.uid = virtual_uid.clone();
|
||||||
st.db.upsert_node(&virtual_root).unwrap();
|
st.flushed_db().upsert_node(&virtual_root).unwrap();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
||||||
@@ -1183,15 +1363,17 @@ mod tests {
|
|||||||
let mut shared = node("shared", "placeholder", "Retained", true);
|
let mut shared = node("shared", "placeholder", "Retained", true);
|
||||||
shared.parent_uid = Some(virtual_uid.clone());
|
shared.parent_uid = Some(virtual_uid.clone());
|
||||||
shared.membership = Some(membership(6));
|
shared.membership = Some(membership(6));
|
||||||
st.db.upsert_node(&shared).unwrap();
|
st.flushed_db().upsert_node(&shared).unwrap();
|
||||||
st.db.set_share_access(&shared.uid, Access::Editor).unwrap();
|
st.flushed_db()
|
||||||
|
.set_share_access(&shared.uid, Access::Editor)
|
||||||
|
.unwrap();
|
||||||
let shared_ino =
|
let shared_ino =
|
||||||
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
||||||
let child = st.intern(
|
let child = st.intern(
|
||||||
shared_ino,
|
shared_ino,
|
||||||
node("shared-child", "shared", "pending.txt", false),
|
node("shared-child", "shared", "pending.txt", false),
|
||||||
);
|
);
|
||||||
st.db
|
st.flushed_db()
|
||||||
.enqueue_op(&pdfs_core::db::PendingOp {
|
.enqueue_op(&pdfs_core::db::PendingOp {
|
||||||
id: 0,
|
id: 0,
|
||||||
kind: pdfs_core::db::OP_RENAME.to_string(),
|
kind: pdfs_core::db::OP_RENAME.to_string(),
|
||||||
@@ -1209,20 +1391,20 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let snapshot = st.db.visible_children(&virtual_uid).unwrap();
|
let snapshot = st.flushed_db().visible_children(&virtual_uid).unwrap();
|
||||||
assert_eq!(snapshot.len(), 1);
|
assert_eq!(snapshot.len(), 1);
|
||||||
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&shared.uid).unwrap(),
|
st.flushed_db().share_access(&shared.uid).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(st.db.pending_ops().unwrap().len(), 1);
|
assert_eq!(st.flushed_db().pending_ops().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1233,12 +1415,12 @@ mod tests {
|
|||||||
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
||||||
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
||||||
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("offline-share"), Access::Viewer)
|
.set_share_access(&uid("offline-share"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
|
|
||||||
// Reconstruct in deliberately non-topological order, matching the
|
// Reconstruct in deliberately non-topological order, matching the
|
||||||
// database hydration path that first allocates every inode and only
|
// database hydration path that first allocates every inode and only
|
||||||
@@ -1296,7 +1478,7 @@ mod tests {
|
|||||||
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
||||||
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
let inos: Vec<u64> = nodes
|
let inos: Vec<u64> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1394,10 +1576,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("device-root"), Access::Viewer)
|
.set_share_access(&uid("device-root"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let nodes = [
|
let nodes = [
|
||||||
node("root", "none", "My Files", true),
|
node("root", "none", "My Files", true),
|
||||||
node("documents", "device-root", "Documents", true),
|
node("documents", "device-root", "Documents", true),
|
||||||
@@ -1431,7 +1613,7 @@ mod tests {
|
|||||||
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1441,8 +1623,10 @@ mod tests {
|
|||||||
for persisted in [Access::Viewer, Access::Editor] {
|
for persisted in [Access::Viewer, Access::Editor] {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db.set_share_access(&uid("shared"), persisted).unwrap();
|
st.flushed_db()
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
.set_share_access(&uid("shared"), persisted)
|
||||||
|
.unwrap();
|
||||||
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let shared = st.intern(
|
let shared = st.intern(
|
||||||
owned_root,
|
owned_root,
|
||||||
node("shared", "root", "Shared without membership", true),
|
node("shared", "root", "Shared without membership", true),
|
||||||
@@ -1454,7 +1638,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(st.entries[&shared].access, persisted);
|
assert_eq!(st.entries[&shared].access, persisted);
|
||||||
assert_eq!(st.entries[&child].access, persisted);
|
assert_eq!(st.entries[&child].access, persisted);
|
||||||
assert_eq!(st.db.share_access(&uid("shared")).unwrap(), Some(persisted));
|
assert_eq!(
|
||||||
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
|
Some(persisted)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,7 +1649,7 @@ mod tests {
|
|||||||
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Editor)
|
.set_share_access(&uid("shared"), Access::Editor)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
||||||
@@ -1474,7 +1661,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1491,7 +1678,7 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
||||||
virtual_node.uid = virtual_uid.clone();
|
virtual_node.uid = virtual_uid.clone();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
||||||
@@ -1558,7 +1745,7 @@ mod tests {
|
|||||||
let child = st.intern(shared, node("child", "shared", "folder", true));
|
let child = st.intern(shared, node("child", "shared", "folder", true));
|
||||||
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Viewer)
|
.set_share_access(&uid("shared"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
||||||
@@ -1572,7 +1759,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
||||||
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.effective_node_access(&uid("leaf")).unwrap(),
|
st.flushed_db().effective_node_access(&uid("leaf")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1628,7 +1815,7 @@ mod tests {
|
|||||||
let shared = st.intern(owned_root, shared_node);
|
let shared = st.intern(owned_root, shared_node);
|
||||||
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
||||||
|
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1652,7 +1839,7 @@ mod tests {
|
|||||||
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
||||||
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
||||||
}
|
}
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
|
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! The thread that watches the daemon for the thing the daemon cannot report
|
||||||
|
//! about itself: that it has stopped making progress.
|
||||||
|
//!
|
||||||
|
//! Three jobs, all cheap, all on one timer:
|
||||||
|
//!
|
||||||
|
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
|
||||||
|
//! queue that is not draining, a control request that never returned — each
|
||||||
|
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
|
||||||
|
//! all; this is what turns the next one into a one-line diagnosis.
|
||||||
|
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
|
||||||
|
//! control socket. Pinging unconditionally from a thread that does nothing
|
||||||
|
//! else would keep the unit "healthy" through exactly the hang it is meant to
|
||||||
|
//! catch: the probe is what makes the ping mean "a client can still talk to
|
||||||
|
//! this daemon".
|
||||||
|
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
|
||||||
|
//! until it was over.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pdfs_core::control::Request as CtlRequest;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use super::systemd;
|
||||||
|
|
||||||
|
/// How often the supervisor looks. Fast enough that a stall is named while the
|
||||||
|
/// user is still watching it, slow enough to cost nothing.
|
||||||
|
const TICK: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// How long one job may hold a worker, or one control request may run, before it
|
||||||
|
/// is called out. Generous: a large upload chunk or a cold folder listing on a
|
||||||
|
/// slow link is minutes of legitimate work, and a false WARN every few minutes
|
||||||
|
/// would train everyone to ignore the real one.
|
||||||
|
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// How often resident size goes into the log.
|
||||||
|
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// How long the liveness probe waits for the daemon to answer its own control
|
||||||
|
/// socket. Well under the watchdog period, so a probe that times out still
|
||||||
|
/// leaves time for the manager to hear nothing and act.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
|
||||||
|
pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
||||||
|
let watchdog = systemd::watchdog_period();
|
||||||
|
if let Some(period) = watchdog {
|
||||||
|
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_rss = Instant::now();
|
||||||
|
let mut last_ping = Instant::now();
|
||||||
|
// Per-lane completion counts from the previous tick, to tell a queue that is
|
||||||
|
// being worked through from one that is not moving at all.
|
||||||
|
let mut last_completed = (0_u64, 0_u64);
|
||||||
|
|
||||||
|
while core.shutdown.sleep(TICK) {
|
||||||
|
let report = core.diagnostics();
|
||||||
|
let stalled = report_stalls(&report, last_completed);
|
||||||
|
last_completed = (report.meta_completed, report.transfer_completed);
|
||||||
|
|
||||||
|
if last_rss.elapsed() >= RSS_EVERY {
|
||||||
|
info!(
|
||||||
|
rss_bytes = report.rss_bytes,
|
||||||
|
pending_ops = report.pending_ops,
|
||||||
|
meta_queued = report.meta_queued,
|
||||||
|
transfer_queued = report.transfer_queued,
|
||||||
|
stalled,
|
||||||
|
"daemon resource sample"
|
||||||
|
);
|
||||||
|
last_rss = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stalled lane is deliberately *not* a reason to withhold the ping:
|
||||||
|
// the mount may still be answering everything else, and a restart would
|
||||||
|
// throw away in-flight uploads. The probe decides that; a stall only
|
||||||
|
// gets said out loud.
|
||||||
|
if let Some(period) = watchdog
|
||||||
|
&& last_ping.elapsed() >= period
|
||||||
|
{
|
||||||
|
if probe(&control_socket) {
|
||||||
|
systemd::watchdog_ping();
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"daemon did not answer its own control socket; withholding the watchdog ping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last_ping = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
|
||||||
|
/// anything was reported, so the caller can weigh it.
|
||||||
|
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
|
||||||
|
let threshold = STALL_AFTER.as_secs();
|
||||||
|
let mut stalled = false;
|
||||||
|
|
||||||
|
for worker in &report.workers {
|
||||||
|
if worker.busy && worker.age_secs >= threshold {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
worker = %worker.name,
|
||||||
|
job = %worker.label,
|
||||||
|
age_secs = worker.age_secs,
|
||||||
|
"a fuse worker has held the same job for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(oldest) = report.inflight.first()
|
||||||
|
&& oldest.age_secs >= threshold
|
||||||
|
{
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
request = %oldest.kind,
|
||||||
|
age_secs = oldest.age_secs,
|
||||||
|
in_flight = report.inflight.len(),
|
||||||
|
"a control request has been running for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A backed-up lane is normal; a backed-up lane that finished nothing since
|
||||||
|
// the last tick is not.
|
||||||
|
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.meta_queued,
|
||||||
|
"the metadata lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.transfer_queued,
|
||||||
|
"the transfer lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stalled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round trip over the control socket: connect, ask for the cheapest
|
||||||
|
/// request there is, read the answer.
|
||||||
|
///
|
||||||
|
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
|
||||||
|
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
|
||||||
|
/// depends on — without touching the network or the database.
|
||||||
|
fn probe(control_socket: &Path) -> bool {
|
||||||
|
let Ok(stream) = UnixStream::connect(control_socket) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut stream = stream;
|
||||||
|
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut line = String::new();
|
||||||
|
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||||
|
WorkerState {
|
||||||
|
name: "pdfs-fuse-3".into(),
|
||||||
|
busy: true,
|
||||||
|
label: "read".into(),
|
||||||
|
age_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker doing normal work must not be reported: a WARN every tick is a
|
||||||
|
/// WARN nobody reads.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_work_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(5)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker on the same job past the threshold is the shape of the hang.
|
||||||
|
#[test]
|
||||||
|
fn a_long_held_job_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// So is a control request that never returned.
|
||||||
|
#[test]
|
||||||
|
fn a_long_running_request_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
inflight: vec![InflightRequest {
|
||||||
|
kind: "OpenFile".into(),
|
||||||
|
age_secs: STALL_AFTER.as_secs() + 1,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queue that is draining is busy, not stalled — that is what comparing
|
||||||
|
/// the completion counts is for.
|
||||||
|
#[test]
|
||||||
|
fn a_draining_queue_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
transfer_queued: 12,
|
||||||
|
transfer_completed: 40,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 30)));
|
||||||
|
assert!(report_stalls(&report, (0, 40)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,10 +67,16 @@ impl Core {
|
|||||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
// Both waits are interruptible, so teardown does not have to sit out a
|
||||||
|
// five-minute interval to join this thread (bugs.md B44).
|
||||||
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
self.sweep_conflicts_once();
|
self.sweep_conflicts_once();
|
||||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +109,7 @@ impl Core {
|
|||||||
by_parent_name.insert((parent, node.name.as_str()), node);
|
by_parent_name.insert((parent, node.name.as_str()), node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let roots = self.search_roots();
|
||||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
for node in &nodes {
|
for node in &nodes {
|
||||||
if node.trashed || node.is_folder() {
|
if node.trashed || node.is_folder() {
|
||||||
@@ -126,7 +133,16 @@ impl Core {
|
|||||||
self.remove_conflict_copy(node, &base);
|
self.remove_conflict_copy(node, &base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SweepAction::Flag(detail) => self.flag_conflict(&node.uid, &node.name, detail),
|
// Only flag what the conflict listing can show. Anything else
|
||||||
|
// would sit in the feed as "resolved" with no way to act on
|
||||||
|
// it (`docs/BUGS.md` B99).
|
||||||
|
SweepAction::Flag(detail) => {
|
||||||
|
if self.conflict_path(&roots, &node.uid).is_some() {
|
||||||
|
self.flag_conflict(&node.uid, &node.name, detail);
|
||||||
|
} else {
|
||||||
|
debug!(uid = %node.uid, name = %node.name, "conflict sweep: copy is outside every location; not flagged");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,9 +154,9 @@ impl Core {
|
|||||||
/// enumerated every node up front and has been doing network I/O since. Before
|
/// 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
|
/// 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
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||||
/// unopened. Without that, a user edit landing inside the window is destroyed
|
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||||
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
/// or its staged blob: a write that arrives after that check is left for the
|
||||||
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
/// drain to resolve against the now-trashed node.
|
||||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
@@ -166,10 +182,13 @@ impl Core {
|
|||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
st.forget_or_unlink(uid);
|
st.forget_or_unlink(uid);
|
||||||
});
|
});
|
||||||
if let Err(error) = self.discard_queued_ops(uid) {
|
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||||
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
// proved the queue empty before the trash above, but that proof is stale
|
||||||
return;
|
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||||
}
|
// that window stages bytes, and discarding here would unlink the staged
|
||||||
|
// blob holding the only copy of them. Leaving the op queued is the
|
||||||
|
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||||
|
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
if let Err(e) = self.db.delete_node(uid) {
|
||||||
@@ -194,8 +213,8 @@ impl Core {
|
|||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
|
|
||||||
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
// A queued op means bytes are still owed an upload; trashing the node
|
||||||
// would throw those away along with the staged blob holding them.
|
// under them turns an ordinary upload into a conflict fork.
|
||||||
match self.db.has_any_op(&uid.to_string()) {
|
match self.db.has_any_op(&uid.to_string()) {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
@@ -252,7 +271,7 @@ impl Core {
|
|||||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
/// 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
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
let mut busy = false;
|
let mut busy = false;
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
@@ -264,12 +283,21 @@ impl Core {
|
|||||||
busy
|
busy
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
/// Surface a conflict copy that needs the user's attention, once. The
|
||||||
/// daemon run so the activity feed is not spammed each pass.
|
/// in-memory set spares the database each pass; the activity log itself is
|
||||||
|
/// what keeps a restart from logging the same copy again (`docs/BUGS.md`
|
||||||
|
/// B99). A copy's name carries its own timestamp, so the name identifies it.
|
||||||
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
fn flag_conflict(&self, uid: &NodeUid, name: &str, detail: String) {
|
||||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
match self.db.activity_has(ActivityKind::Conflict, name) {
|
||||||
|
Ok(false) => {}
|
||||||
|
Ok(true) => return,
|
||||||
|
// Cannot tell: a repeat entry is harmless, a missing one hides a
|
||||||
|
// conflict.
|
||||||
|
Err(e) => warn!(%uid, name, error = ?e, "conflict sweep: activity check failed"),
|
||||||
|
}
|
||||||
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
debug!(%uid, name, detail, "conflict sweep: flagged divergent conflict copy");
|
||||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||||
}
|
}
|
||||||
@@ -359,6 +387,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ use engine::{classify, settle_with};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
use model::{LocalItem, LocalSig, Outcome, PassAbort, Pending, RemoteItem};
|
||||||
pub(crate) use planner::base_name;
|
pub(crate) use planner::base_name;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use planner::conflict_path;
|
use planner::conflict_path;
|
||||||
@@ -82,6 +82,12 @@ impl Core {
|
|||||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||||
/// pass that may run for minutes.
|
/// pass that may run for minutes.
|
||||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||||
|
// A paused folder skips the settle too: a queued switch to on-demand
|
||||||
|
// evicts the local tree, which is only safe after a pass uploaded it.
|
||||||
|
if self.sync_folder_paused(folder.id) {
|
||||||
|
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||||
// so the settle below is not skipped with it.
|
// so the settle below is not skipped with it.
|
||||||
@@ -525,6 +531,9 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trashed.push(rel.clone());
|
trashed.push(rel.clone());
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -687,6 +696,7 @@ impl Core {
|
|||||||
rel_path: rel.clone(),
|
rel_path: rel.clone(),
|
||||||
remote_uid: Some(remote.uid.to_string()),
|
remote_uid: Some(remote.uid.to_string()),
|
||||||
local_mtime: local.mtime,
|
local_mtime: local.mtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: local.size,
|
local_size: local.size,
|
||||||
remote_rev: Some(remote.mtime.to_string()),
|
remote_rev: Some(remote.mtime.to_string()),
|
||||||
remote_hash: Some(remote.size.to_string()),
|
remote_hash: Some(remote.size.to_string()),
|
||||||
@@ -717,6 +727,9 @@ impl Core {
|
|||||||
uid: remote.uid.clone(),
|
uid: remote.uid.clone(),
|
||||||
mtime: remote.mtime,
|
mtime: remote.mtime,
|
||||||
size: remote.size,
|
size: remote.size,
|
||||||
|
// What the walk saw here, so the apply step can tell a
|
||||||
|
// stale plan from a live one before it overwrites.
|
||||||
|
local: l.map(LocalSig::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
FilePlan::Conflict => {
|
FilePlan::Conflict => {
|
||||||
@@ -739,7 +752,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
FilePlan::DeleteLocal => {
|
FilePlan::DeleteLocal => {
|
||||||
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_file(local_root.join(rel_to_path(rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local file failed");
|
warn!(rel, error = %e, "sync: remove local file failed");
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -771,6 +786,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -797,7 +815,9 @@ impl Core {
|
|||||||
// Deferred folder deletions, deepest first.
|
// Deferred folder deletions, deepest first.
|
||||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||||
for rel in delete_local_dirs {
|
for rel in delete_local_dirs {
|
||||||
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local folder failed");
|
warn!(rel, error = %e, "sync: remove local folder failed");
|
||||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
@@ -824,6 +844,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(&rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -849,68 +872,9 @@ impl Core {
|
|||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
||||||
for entry in entries {
|
self.progress_scanned(folder_id, base_name(rel))
|
||||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
})
|
||||||
let path = entry.path();
|
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
||||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"local path {} escaped sync root {}: {e}",
|
|
||||||
path.display(),
|
|
||||||
root.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let rel = stripped.to_str().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
// Ignore our own in-flight download temp files.
|
|
||||||
if rel.contains(".pdfs-tmp-") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Ignored paths are dropped here as well as from the classification
|
|
||||||
// union — not for correctness (the union filter is what makes this
|
|
||||||
// safe) but so an ignored `node_modules/` is never descended into or
|
|
||||||
// counted against scan progress.
|
|
||||||
if rules.is_ignored(rel, meta.is_dir()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if meta.is_dir() {
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: true,
|
|
||||||
mtime: 0,
|
|
||||||
size: 0,
|
|
||||||
open_for_write: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
self.walk_local(folder_id, root, &path, rules, writing, out)?;
|
|
||||||
} else if meta.is_file() {
|
|
||||||
// Check whether any process holds this file open for writing.
|
|
||||||
// The canonical path is used because /proc/*/fd links resolve
|
|
||||||
// to canonical targets.
|
|
||||||
let ofw = std::fs::canonicalize(&path)
|
|
||||||
.map(|canon| writing.contains(&canon))
|
|
||||||
.unwrap_or(false);
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: false,
|
|
||||||
mtime: system_mtime(&meta),
|
|
||||||
size: meta.len() as i64,
|
|
||||||
open_for_write: ofw,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
||||||
@@ -1069,6 +1033,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
@@ -1175,6 +1140,9 @@ impl Core {
|
|||||||
.create_folder(parent, base_name(rel), Some(now_secs()))
|
.create_folder(parent, base_name(rel), Some(now_secs()))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
||||||
|
// The parent has gained a child that no cached listing of it
|
||||||
|
// knows about (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
self.baseline_dir(folder_id, rel, &uid)?;
|
self.baseline_dir(folder_id, rel, &uid)?;
|
||||||
Ok(Applied::Dir(rel.clone(), uid))
|
Ok(Applied::Dir(rel.clone(), uid))
|
||||||
}
|
}
|
||||||
@@ -1192,8 +1160,9 @@ impl Core {
|
|||||||
uid,
|
uid,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
|
local,
|
||||||
} => {
|
} => {
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, *local)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Downloaded)
|
Ok(Applied::Downloaded)
|
||||||
}
|
}
|
||||||
@@ -1211,7 +1180,11 @@ impl Core {
|
|||||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||||
info!(rel, "sync: kept local changes as a conflict copy");
|
info!(rel, "sync: kept local changes as a conflict copy");
|
||||||
}
|
}
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
// The local copy has just been moved aside, so the state this
|
||||||
|
// download expects to find at the path is "nothing" — anything
|
||||||
|
// that appears while it runs is a *new* racing write, and gets
|
||||||
|
// its own conflict copy rather than being overwritten (B39).
|
||||||
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, None)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Conflict)
|
Ok(Applied::Conflict)
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1223,12 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
// Taken from the descriptor being streamed, so it names the content that
|
||||||
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), name.to_string())
|
||||||
|
.await;
|
||||||
// Count the bytes as they are read, so this upload shows up in
|
// Count the bytes as they are read, so this upload shows up in
|
||||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||||
// draft is sealed, so the transfer registers without one.
|
// draft is sealed, so the transfer registers without one.
|
||||||
@@ -1270,13 +1249,19 @@ impl Core {
|
|||||||
crate::media_type_for(name),
|
crate::media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len() as i64,
|
meta.len() as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
Some(mtime),
|
Some(mtime),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload {rel}: {e}"))?;
|
.map_err(|e| format!("upload {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, &uid).await
|
// The parent has gained a child that no cached listing of it knows
|
||||||
|
// about. Without this, a folder later switched to on-demand serves a
|
||||||
|
// snapshot that predates this upload (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a changed local file as a new revision of an existing remote node.
|
/// Upload a changed local file as a new revision of an existing remote node.
|
||||||
@@ -1291,6 +1276,10 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
|
let thumbnails = self
|
||||||
|
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
|
||||||
|
.await;
|
||||||
let reader = OwnedCountingReader::new(
|
let reader = OwnedCountingReader::new(
|
||||||
file,
|
file,
|
||||||
self.transfers.begin(
|
self.transfers.begin(
|
||||||
@@ -1301,14 +1290,23 @@ impl Core {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
self.client
|
self.client
|
||||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
.upload_new_revision_from(uid, reader, meta.len() as i64, thumbnails, Some(mtime))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a remote file to its local path (atomically via a temp file),
|
/// Download a remote file to its local path (atomically via a temp file),
|
||||||
/// stamp the local mtime to match the remote, then record baseline.
|
/// stamp the local mtime to match the remote, then record baseline.
|
||||||
|
///
|
||||||
|
/// `expected_local` is the identity planning saw at the destination. The
|
||||||
|
/// rename below is the destructive step, and the download in between can run
|
||||||
|
/// for minutes, so the destination is re-examined right before it: a local
|
||||||
|
/// file that no longer matches the plan is set aside as a conflict copy
|
||||||
|
/// instead of being replaced (bugs.md B39).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1317,6 +1315,7 @@ impl Core {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
expected_local: Option<LocalSig>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = local_root.join(rel_to_path(rel));
|
let path = local_root.join(rel_to_path(rel));
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -1351,6 +1350,13 @@ impl Core {
|
|||||||
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
||||||
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
|
// The last moment at which the destination can still be saved. Anything
|
||||||
|
// other than the state planning classified means a writer got there
|
||||||
|
// first, and its bytes are the ones nothing else has a copy of.
|
||||||
|
if let Err(e) = keep_racing_local_edit(&path, expected_local) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(format!("set aside racing local edit for {rel}: {e}"));
|
||||||
|
}
|
||||||
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
||||||
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
||||||
let f = std::fs::File::options()
|
let f = std::fs::File::options()
|
||||||
@@ -1366,23 +1372,36 @@ impl Core {
|
|||||||
.and_then(|dir| dir.sync_all())
|
.and_then(|dir| dir.sync_all())
|
||||||
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
// Here the file on disk *is* the transferred content: it was published
|
||||||
|
// by a rename and stamped just above, so its stat is the identity.
|
||||||
|
self.record_file_baseline(folder_id, rel, local_sig(&path), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh a file's baseline from ground truth: the local stat and the
|
/// Refresh a file's baseline from ground truth: the local identity these
|
||||||
/// remote node's reported signature. Called after every upload/download so
|
/// bytes came from and the remote node's reported signature. Called after
|
||||||
/// the very next reconcile sees the path as unchanged.
|
/// every upload/download so the very next reconcile sees the path as
|
||||||
|
/// unchanged.
|
||||||
|
///
|
||||||
|
/// `local` is the identity the *transferred content* corresponds to, which
|
||||||
|
/// is not always the file's current stat. An upload streams the live file,
|
||||||
|
/// so it passes the identity it read the first byte at: if a writer changed
|
||||||
|
/// the file mid-stream, the remote now holds a torn revision, and recording
|
||||||
|
/// the pre-upload identity is what makes the next pass see the path as
|
||||||
|
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||||
|
/// post-upload stat instead declared the torn revision synchronized and left
|
||||||
|
/// it as the file's content on Drive (bugs.md B40).
|
||||||
async fn record_file_baseline(
|
async fn record_file_baseline(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
local_path: &Path,
|
local: Option<LocalSig>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (lmtime, lsize) = match std::fs::metadata(local_path) {
|
let Some(local) = local else {
|
||||||
Ok(m) => (system_mtime(&m), m.len() as i64),
|
return Err(format!("stat {rel}: no local identity to baseline"));
|
||||||
Err(e) => return Err(format!("stat {rel}: {e}")),
|
|
||||||
};
|
};
|
||||||
|
let (lmtime, lsize) = (local.mtime, local.size);
|
||||||
// Re-fetch the node so the stored remote signature is exactly what a walk
|
// Re-fetch the node so the stored remote signature is exactly what a walk
|
||||||
// will report next time.
|
// will report next time.
|
||||||
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||||
@@ -1402,6 +1421,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: lmtime,
|
local_mtime: lmtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: lsize,
|
local_size: lsize,
|
||||||
remote_rev: Some(rmtime.to_string()),
|
remote_rev: Some(rmtime.to_string()),
|
||||||
remote_hash: Some(rsize.to_string()),
|
remote_hash: Some(rsize.to_string()),
|
||||||
@@ -1411,6 +1431,177 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Say so when a file changed under an upload that was streaming it.
|
||||||
|
///
|
||||||
|
/// Purely diagnostic — the correction is already in place, because the baseline
|
||||||
|
/// records the identity the upload *read* rather than the one on disk now, so
|
||||||
|
/// the next pass classifies the path as locally changed and replaces the torn
|
||||||
|
/// revision. Without the log there is nothing in the journal to explain the
|
||||||
|
/// extra revision (bugs.md B40).
|
||||||
|
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||||
|
let now = local_sig(path);
|
||||||
|
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||||
|
warn!(
|
||||||
|
rel,
|
||||||
|
?streamed,
|
||||||
|
?now,
|
||||||
|
"sync: file changed while uploading; the remote revision may be torn \
|
||||||
|
and will be replaced on the next pass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity a reconcile pass uses to recognise a local file it has already
|
||||||
|
/// looked at: `None` when nothing is at the path.
|
||||||
|
fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||||
|
std::fs::metadata(path).ok().map(|m| LocalSig {
|
||||||
|
mtime: system_mtime(&m),
|
||||||
|
mtime_ns: system_mtime_ns(&m),
|
||||||
|
size: m.len() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guard the destination of a download: if what is at `path` is no longer what
|
||||||
|
/// planning classified, move it aside as a conflict copy before the caller
|
||||||
|
/// renames over it.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides what to do from a walk that finished before the
|
||||||
|
/// transfer started, and a download of a large file can run for minutes. Without
|
||||||
|
/// this, a local edit completed inside that window was replaced by the remote
|
||||||
|
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||||
|
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||||
|
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||||
|
/// conflict already gets.
|
||||||
|
///
|
||||||
|
/// A path that is empty when it was expected to be is not a conflict: the
|
||||||
|
/// download is about to recreate it, and there is nothing to preserve.
|
||||||
|
fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::Result<()> {
|
||||||
|
let actual = local_sig(path);
|
||||||
|
let unchanged = match (actual, expected) {
|
||||||
|
(Some(a), Some(e)) => a.same_content(&e),
|
||||||
|
// Nothing there: the download is about to create it, and there is
|
||||||
|
// nothing to preserve either way.
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(_), None) => false,
|
||||||
|
};
|
||||||
|
if unchanged {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
?expected,
|
||||||
|
?actual,
|
||||||
|
"sync: local file changed while downloading; keeping it as a conflict copy"
|
||||||
|
);
|
||||||
|
preserve_conflict_copy(path, now_secs()).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a local removal as the sync engine has to read it: the *state* it was
|
||||||
|
/// asking for is "this path is gone", so a path that was already gone is a
|
||||||
|
/// success, not a failure.
|
||||||
|
///
|
||||||
|
/// It matters because the baseline is only dropped on success. Treating
|
||||||
|
/// `NotFound` as an error left the baseline in place for a file that no longer
|
||||||
|
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||||
|
/// every pass, forever. Every other error is still an error, and keeping the
|
||||||
|
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||||
|
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||||
|
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||||
|
match result {
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||||
|
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
||||||
|
///
|
||||||
|
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
||||||
|
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
||||||
|
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
||||||
|
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
||||||
|
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
||||||
|
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
||||||
|
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
||||||
|
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
||||||
|
/// pass retries once the transient condition clears.
|
||||||
|
///
|
||||||
|
/// Split out from the method so exactly that can be tested against a real tree
|
||||||
|
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||||
|
fn walk_local_tree(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
writing: &HashSet<PathBuf>,
|
||||||
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
scanned: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let meta = std::fs::symlink_metadata(&path)
|
||||||
|
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||||
|
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"local path {} escaped sync root {}: {e}",
|
||||||
|
path.display(),
|
||||||
|
root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let rel = stripped.to_str().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Ignore our own in-flight download temp files.
|
||||||
|
if rel.contains(".pdfs-tmp-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ignored paths are dropped here as well as from the classification
|
||||||
|
// union — not for correctness (the union filter is what makes this
|
||||||
|
// safe) but so an ignored `node_modules/` is never descended into or
|
||||||
|
// counted against scan progress.
|
||||||
|
if rules.is_ignored(rel, meta.is_dir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: true,
|
||||||
|
mtime: 0,
|
||||||
|
mtime_ns: None,
|
||||||
|
size: 0,
|
||||||
|
open_for_write: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
||||||
|
} else if meta.is_file() {
|
||||||
|
// Check whether any process holds this file open for writing.
|
||||||
|
// The canonical path is used because /proc/*/fd links resolve
|
||||||
|
// to canonical targets.
|
||||||
|
let ofw = std::fs::canonicalize(&path)
|
||||||
|
.map(|canon| writing.contains(&canon))
|
||||||
|
.unwrap_or(false);
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: false,
|
||||||
|
mtime: system_mtime(&meta),
|
||||||
|
mtime_ns: system_mtime_ns(&meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
open_for_write: ofw,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||||
/// same timestamp. The original is removed only after the new copy is durable.
|
/// same timestamp. The original is removed only after the new copy is durable.
|
||||||
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
||||||
@@ -1444,6 +1635,26 @@ fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
|||||||
unreachable!("u32 conflict suffix space exhausted")
|
unreachable!("u32 conflict suffix space exhausted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The remote uid of `rel`'s parent folder: from the pass's folder-uid map
|
||||||
|
/// first, and from the baseline second for a parent the map never covered.
|
||||||
|
///
|
||||||
|
/// Used to name the folder whose cached listing a remote mutation has just
|
||||||
|
/// invalidated (B86). `None` is not an error — it only means this pass cannot
|
||||||
|
/// say which folder to invalidate, and the caller skips it.
|
||||||
|
fn parent_uid_of(
|
||||||
|
rel: &str,
|
||||||
|
remote_dirs: &HashMap<String, NodeUid>,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
) -> Option<NodeUid> {
|
||||||
|
let parent = parent_rel(rel);
|
||||||
|
remote_dirs.get(parent).cloned().or_else(|| {
|
||||||
|
baseline
|
||||||
|
.get(parent)
|
||||||
|
.and_then(|e| e.remote_uid.as_deref())
|
||||||
|
.and_then(parse_uid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||||
match path.parent() {
|
match path.parent() {
|
||||||
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
||||||
@@ -1476,10 +1687,77 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
|||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same modification time in nanoseconds, when the filesystem reports one.
|
||||||
|
///
|
||||||
|
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||||
|
/// sync from no edit at all, and if the size did not change either, that file
|
||||||
|
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||||
|
/// cannot answer, which falls the comparison back to seconds rather than
|
||||||
|
/// asserting an mtime of zero.
|
||||||
|
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||||
|
meta.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|d| i64::try_from(d.as_nanos()).ok())
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A removal of something already gone has produced the state the sync
|
||||||
|
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||||
|
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||||
|
#[test]
|
||||||
|
fn an_already_absent_path_counts_as_removed() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||||
|
let missing = root.join("never-existed");
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&missing)).is_ok());
|
||||||
|
assert!(removed_locally(std::fs::remove_dir_all(&missing)).is_ok());
|
||||||
|
|
||||||
|
// A directory in the way of `remove_file` is a real failure, and must
|
||||||
|
// stay one: the file survives, and its baseline has to survive with it.
|
||||||
|
let occupied = root.join("a-directory");
|
||||||
|
std::fs::create_dir_all(&occupied).unwrap();
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&occupied)).is_err());
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The download guard: a destination that still matches the plan is
|
||||||
|
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||||
|
#[test]
|
||||||
|
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let path = root.join("report.txt");
|
||||||
|
|
||||||
|
// Nothing there, nothing expected: the download simply creates it.
|
||||||
|
assert!(keep_racing_local_edit(&path, None).is_ok());
|
||||||
|
assert!(!path.exists());
|
||||||
|
|
||||||
|
// Unchanged since planning: no copy, the caller overwrites it.
|
||||||
|
std::fs::write(&path, b"planned").unwrap();
|
||||||
|
let planned = local_sig(&path).unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"planned");
|
||||||
|
|
||||||
|
// Changed since planning: the racing bytes are the only copy of
|
||||||
|
// themselves, so they must survive the overwrite as a conflict copy.
|
||||||
|
std::fs::write(&path, b"a much longer local edit").unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
let kept: Vec<_> = std::fs::read_dir(&root)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| std::fs::read(e.path()).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
kept.iter()
|
||||||
|
.any(|bytes| bytes == b"a much longer local edit"),
|
||||||
|
"the racing edit must still exist somewhere: {kept:?}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
||||||
/// immediately. Returns the sender's join handle so the test can keep it
|
/// immediately. Returns the sender's join handle so the test can keep it
|
||||||
/// alive (dropping the sender early would look like a disconnect).
|
/// alive (dropping the sender early would look like a disconnect).
|
||||||
@@ -1565,6 +1843,7 @@ mod tests {
|
|||||||
|
|
||||||
fn local_item(is_dir: bool) -> LocalItem {
|
fn local_item(is_dir: bool) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir,
|
is_dir,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
size: 1,
|
size: 1,
|
||||||
@@ -1722,6 +2001,44 @@ mod tests {
|
|||||||
assert!(guard_local_wipe(&base, &fresh).is_err());
|
assert!(guard_local_wipe(&base, &fresh).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B54's remaining case, end to end: a folder whose local root is still
|
||||||
|
/// *readable* but is no longer the tree it was — an empty directory left
|
||||||
|
/// where a mount used to be, or a different device mounted over it.
|
||||||
|
///
|
||||||
|
/// Nothing fails here. The scan succeeds and honestly reports an empty
|
||||||
|
/// directory, so the only thing standing between the user and a trashed
|
||||||
|
/// remote folder is the guard, and it has to fire for a one-file baseline
|
||||||
|
/// exactly as it does for a large one.
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_mountpoint_trips_the_guard_at_every_baseline_size() {
|
||||||
|
let root = sync_test_dir("wipe-replaced-mount");
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||||
|
|
||||||
|
for size in 1..=3 {
|
||||||
|
let baseline: HashMap<String, ()> =
|
||||||
|
(0..size).map(|i| (format!("file{i}.txt"), ())).collect();
|
||||||
|
assert!(
|
||||||
|
guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_err(),
|
||||||
|
"a {size}-path baseline against an empty root is not a deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the guard steps aside the moment the real tree is back, so a
|
||||||
|
// remounted folder resumes rather than staying wedged.
|
||||||
|
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||||
/// an empty local directory and a full remote. That must reconcile as
|
/// an empty local directory and a full remote. That must reconcile as
|
||||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||||
@@ -1764,6 +2081,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some(mtime.to_string()),
|
remote_rev: Some(mtime.to_string()),
|
||||||
remote_hash: Some(size.to_string()),
|
remote_hash: Some(size.to_string()),
|
||||||
},
|
},
|
||||||
@@ -1801,6 +2119,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
},
|
},
|
||||||
@@ -1815,6 +2134,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 10,
|
local_mtime: 10,
|
||||||
local_size: 20,
|
local_size: 20,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some("1700".into()),
|
remote_rev: Some("1700".into()),
|
||||||
remote_hash: Some("4096".into()),
|
remote_hash: Some("4096".into()),
|
||||||
};
|
};
|
||||||
@@ -1828,6 +2148,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
};
|
};
|
||||||
@@ -1863,6 +2184,7 @@ mod tests {
|
|||||||
uid: uid(),
|
uid: uid(),
|
||||||
mtime: 0,
|
mtime: 0,
|
||||||
size: 10,
|
size: 10,
|
||||||
|
local: None,
|
||||||
};
|
};
|
||||||
assert_eq!(op.kind(), ActivityKind::Download);
|
assert_eq!(op.kind(), ActivityKind::Download);
|
||||||
}
|
}
|
||||||
@@ -1918,6 +2240,204 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B49's remaining cases. Preservation is the step that makes a download
|
||||||
|
/// safe to publish, so every way it can fail has to leave the local file
|
||||||
|
/// where it was — the download is aborted, not the file replaced.
|
||||||
|
#[test]
|
||||||
|
fn conflict_preservation_failures_all_keep_the_local_file() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
// An unwritable directory: the conflict copy cannot be created at all.
|
||||||
|
let dir = sync_test_dir("conflict-eacces");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"the only copy").unwrap();
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||||
|
let denied = preserve_conflict_copy(&source, 1700);
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
assert!(denied.is_err(), "a read-only directory must fail the copy");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&source).unwrap(),
|
||||||
|
b"the only copy",
|
||||||
|
"the local file survives byte-identical"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A source that vanished between planning and preservation. The pass
|
||||||
|
// must report it rather than publish the download over the gap.
|
||||||
|
let dir = sync_test_dir("conflict-enoent");
|
||||||
|
assert!(preserve_conflict_copy(&dir.join("gone.txt"), 1700).is_err());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A write error partway through the copy — induced by making the
|
||||||
|
// destination name a directory, so `create_new` fails as something
|
||||||
|
// other than `AlreadyExists`.
|
||||||
|
let dir = sync_test_dir("conflict-eisdir");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"still the only copy").unwrap();
|
||||||
|
std::fs::create_dir(conflict_path(&source, 1700)).unwrap();
|
||||||
|
// Suffix 0 is taken by a directory; `create_new` on it reports
|
||||||
|
// `AlreadyExists`, so preservation moves on to suffix 1 and succeeds.
|
||||||
|
// What must not happen is the source being removed without a copy.
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
assert!(preserved.is_file());
|
||||||
|
assert_eq!(std::fs::read(&preserved).unwrap(), b"still the only copy");
|
||||||
|
assert!(!source.exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conflict copy keeps the bytes *and* the metadata that identifies them,
|
||||||
|
/// so the next pass uploads the user's version rather than a file that looks
|
||||||
|
/// freshly written.
|
||||||
|
#[test]
|
||||||
|
fn a_conflict_copy_is_byte_and_mtime_identical() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let dir = sync_test_dir("conflict-identity");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"local edit that must survive").unwrap();
|
||||||
|
let before = std::fs::metadata(&source).unwrap();
|
||||||
|
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
let after = std::fs::metadata(&preserved).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&preserved).unwrap(),
|
||||||
|
b"local edit that must survive"
|
||||||
|
);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
assert_eq!(after.modified().unwrap(), before.modified().unwrap());
|
||||||
|
assert_eq!(after.permissions().mode(), before.permissions().mode());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- B55: an incomplete local scan is never a set of deletions ---------
|
||||||
|
|
||||||
|
fn no_progress() -> impl FnMut(&str) {
|
||||||
|
|_: &str| {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the pass cannot read must fail the pass. Omitting it would
|
||||||
|
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
||||||
|
/// having deleted that subtree and propagates to Drive (B55).
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
let locked = root.join("locked");
|
||||||
|
std::fs::create_dir(&locked).unwrap();
|
||||||
|
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("an unreadable directory must abort the pass");
|
||||||
|
assert!(
|
||||||
|
error.contains("locked"),
|
||||||
|
"the error should name the subtree it could not read: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||||
|
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_root_fails_the_scan() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-root-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root that is gone entirely — an unmounted device folder, a removed
|
||||||
|
/// directory — is the sharpest version of the same case: every baseline path
|
||||||
|
/// is "missing", and the scan must refuse rather than report an empty tree.
|
||||||
|
#[test]
|
||||||
|
fn an_absent_root_fails_the_scan() {
|
||||||
|
let root = sync_test_dir("scan-absent");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
||||||
|
/// cannot be compared against the baseline either. Refusing the pass is the
|
||||||
|
/// only safe reading: skipping it would classify the file as deleted.
|
||||||
|
#[test]
|
||||||
|
fn a_non_utf8_name_fails_the_scan() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-non-utf8");
|
||||||
|
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
||||||
|
assert!(error.contains("UTF-8"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complement: a scan that *can* read everything reports everything, so
|
||||||
|
/// the strictness above does not come at the cost of a working pass. A
|
||||||
|
/// symlink is skipped by design rather than by failure, and a dangling one
|
||||||
|
/// stats fine through `symlink_metadata`.
|
||||||
|
#[test]
|
||||||
|
fn a_readable_tree_scans_completely() {
|
||||||
|
let root = sync_test_dir("scan-complete");
|
||||||
|
std::fs::write(root.join("a.txt"), b"aa").unwrap();
|
||||||
|
std::fs::create_dir(root.join("sub")).unwrap();
|
||||||
|
std::fs::write(root.join("sub/b.txt"), b"bbb").unwrap();
|
||||||
|
std::os::unix::fs::symlink("nowhere", root.join("dangling")).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
||||||
|
seen.push(rel.to_string())
|
||||||
|
});
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
result.unwrap();
|
||||||
|
let mut keys: Vec<&str> = out.keys().map(String::as_str).collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
assert_eq!(keys, vec!["a.txt", "sub", "sub/b.txt"]);
|
||||||
|
assert_eq!(out["a.txt"].size, 2);
|
||||||
|
assert_eq!(out["sub/b.txt"].size, 3);
|
||||||
|
assert!(out["sub"].is_dir);
|
||||||
|
assert_eq!(seen.len(), 3, "every reported entry ticks scan progress");
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_test_dir(label: &str) -> PathBuf {
|
fn sync_test_dir(label: &str) -> PathBuf {
|
||||||
let nonce = SystemTime::now()
|
let nonce = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
|
|||||||
ReconcileAll,
|
ReconcileAll,
|
||||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
|
/// The daemon is tearing down: finish the current pass and end the engine
|
||||||
|
/// thread, so the mount can join it (bugs.md B44).
|
||||||
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
||||||
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
||||||
/// on `rx`; senders live in [`Core::sync_tx`].
|
/// on `rx`; senders live in [`Core::sync_tx`].
|
||||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
|
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
|
||||||
if let Err(e) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-sync".into())
|
.name("pdfs-sync".into())
|
||||||
.spawn(move || engine_loop(core, rx))
|
.spawn(move || engine_loop(core, rx))
|
||||||
{
|
{
|
||||||
warn!(error = %e, "failed to start sync engine");
|
Ok(handle) => Some(handle),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to start sync engine");
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +81,11 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
// Periodic remote poll.
|
// Periodic remote poll.
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pdfs-sync-poll".into())
|
.name("pdfs-sync-poll".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -94,8 +101,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if do_rewatch {
|
if do_rewatch {
|
||||||
rewatch(&core, &mut watcher, &watched);
|
rewatch(&core, &mut watcher, &watched);
|
||||||
@@ -104,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(&core);
|
reconcile_all(&core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -116,9 +130,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -130,12 +144,19 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if all {
|
if all {
|
||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -158,7 +179,7 @@ pub(super) fn settle_with(
|
|||||||
do_rewatch: &mut bool,
|
do_rewatch: &mut bool,
|
||||||
quiet: Duration,
|
quiet: Duration,
|
||||||
cap: Duration,
|
cap: Duration,
|
||||||
) {
|
) -> bool {
|
||||||
let deadline = Instant::now() + cap;
|
let deadline = Instant::now() + cap;
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -170,32 +191,59 @@ pub(super) fn settle_with(
|
|||||||
match rx.recv_timeout(wait) {
|
match rx.recv_timeout(wait) {
|
||||||
// Another event: the burst is still going, so the quiet window
|
// Another event: the burst is still going, so the quiet window
|
||||||
// starts again from here.
|
// starts again from here.
|
||||||
Ok(m) => classify(m, ids, all, do_rewatch),
|
// A stop lands mid-burst: give up the rest of the burst rather
|
||||||
|
// than settling for it, and let the caller end the loop.
|
||||||
|
Ok(m) => {
|
||||||
|
if !classify(m, ids, all, do_rewatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Quiet for a full window: whatever was being written has stopped.
|
// Quiet for a full window: whatever was being written has stopped.
|
||||||
Err(RecvTimeoutError::Timeout) => break,
|
Err(RecvTimeoutError::Timeout) => break,
|
||||||
// Every sender is gone; the caller's loop is about to end anyway.
|
// Every sender is gone; the caller's loop is about to end anyway.
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
Err(RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`settle_with`] at the real timings.
|
/// [`settle_with`] at the real timings.
|
||||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
fn settle(
|
||||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
rx: &Receiver<SyncMsg>,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
do_rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
|
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
|
/// Fold one message into the pending work of the current burst. Returns `false`
|
||||||
|
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
|
||||||
|
pub(super) fn classify(
|
||||||
|
msg: SyncMsg,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
match msg {
|
match msg {
|
||||||
SyncMsg::Reconcile(id) => {
|
SyncMsg::Reconcile(id) => {
|
||||||
ids.insert(id);
|
ids.insert(id);
|
||||||
}
|
}
|
||||||
SyncMsg::ReconcileAll => *all = true,
|
SyncMsg::ReconcileAll => *all = true,
|
||||||
SyncMsg::Rewatch => *rewatch = true,
|
SyncMsg::Rewatch => *rewatch = true,
|
||||||
|
SyncMsg::Stop => return false,
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||||
|
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||||
|
/// watcher reported meanwhile.
|
||||||
fn reconcile_all(core: &Core) {
|
fn reconcile_all(core: &Core) {
|
||||||
|
if core.sync_paused() {
|
||||||
|
debug!("sync: paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match core.db.sync_folder_list() {
|
let folders = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -2,9 +2,73 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// What a reconcile pass remembers about the local side of one path: enough to
|
||||||
|
/// recognise the same content again, and no more.
|
||||||
|
///
|
||||||
|
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
|
||||||
|
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct LocalSig {
|
||||||
|
pub(super) mtime: i64,
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
|
pub(super) size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalSig {
|
||||||
|
/// Whether these two observations describe the same local content.
|
||||||
|
///
|
||||||
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
|
/// (bugs.md B25).
|
||||||
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
|
if self.size != other.size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (self.mtime_ns, other.mtime_ns) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => self.mtime == other.mtime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&LocalItem> for LocalSig {
|
||||||
|
fn from(item: &LocalItem) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: item.mtime,
|
||||||
|
mtime_ns: item.mtime_ns,
|
||||||
|
size: item.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&std::fs::Metadata> for LocalSig {
|
||||||
|
fn from(meta: &std::fs::Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: system_mtime(meta),
|
||||||
|
mtime_ns: system_mtime_ns(meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline's record of the local side, as [`LocalSig`] compares it.
|
||||||
|
impl From<&StoredSyncEntry> for LocalSig {
|
||||||
|
fn from(entry: &StoredSyncEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: entry.local_mtime,
|
||||||
|
mtime_ns: entry.local_mtime_ns,
|
||||||
|
size: entry.local_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct LocalItem {
|
pub(super) struct LocalItem {
|
||||||
pub(super) is_dir: bool,
|
pub(super) is_dir: bool,
|
||||||
pub(super) mtime: i64,
|
pub(super) mtime: i64,
|
||||||
|
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
|
||||||
|
/// reports one.
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
pub(super) size: i64,
|
pub(super) size: i64,
|
||||||
/// True when another process holds this file open for writing (detected via
|
/// True when another process holds this file open for writing (detected via
|
||||||
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
||||||
@@ -91,11 +155,18 @@ pub(super) enum Pending {
|
|||||||
UploadRevision { rel: String, uid: NodeUid },
|
UploadRevision { rel: String, uid: NodeUid },
|
||||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
||||||
/// remote's reported size, used as the transfer's expected total.
|
/// remote's reported size, used as the transfer's expected total.
|
||||||
|
///
|
||||||
|
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||||
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
|
/// against it immediately before it renames over the destination: a writer
|
||||||
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
|
/// conflict copy (bugs.md B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
local: Option<LocalSig>,
|
||||||
},
|
},
|
||||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||||
Conflict {
|
Conflict {
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
let local_changed = local.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
|
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
||||||
|
// could not see an edit made in the same second as the last sync that
|
||||||
|
// left the file the same length, so it was never uploaded.
|
||||||
|
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||||
});
|
});
|
||||||
let remote_changed = remote.is_some_and(|item| {
|
let remote_changed = remote.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||||
@@ -252,6 +255,7 @@ mod file_plan_tests {
|
|||||||
|
|
||||||
fn local(mtime: i64, size: i64) -> LocalItem {
|
fn local(mtime: i64, size: i64) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
@@ -278,12 +282,49 @@ mod file_plan_tests {
|
|||||||
rel_path: "file".into(),
|
rel_path: "file".into(),
|
||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime,
|
local_mtime,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size,
|
local_size,
|
||||||
remote_rev: Some(remote_mtime.to_string()),
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
remote_hash: Some(remote_size.to_string()),
|
remote_hash: Some(remote_size.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||||
|
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||||
|
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||||
|
#[test]
|
||||||
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.local_mtime_ns = Some(10_000_000_000);
|
||||||
|
let edited = LocalItem {
|
||||||
|
mtime_ns: Some(10_400_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::UploadRevision
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same file untouched still reads as unchanged.
|
||||||
|
let same = LocalItem {
|
||||||
|
mtime_ns: Some(10_000_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&same), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a row from before the column exists compares on seconds, exactly
|
||||||
|
// as it did when it was written — no mass re-upload on upgrade.
|
||||||
|
let old = baseline(10, 20, 30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&old)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_plan_covers_every_presence_and_change_shape() {
|
fn file_plan_covers_every_presence_and_change_shape() {
|
||||||
let same_local = local(10, 20);
|
let same_local = local(10, 20);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! The `sd_notify` half of the service contract, without linking libsystemd.
|
||||||
|
//!
|
||||||
|
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
|
||||||
|
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
|
||||||
|
//! directly keeps the daemon buildable on a machine without systemd headers —
|
||||||
|
//! everything here degrades to "no socket, do nothing" off systemd.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixDatagram;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Send one notification. `false` when there is no manager to send it to, or the
|
||||||
|
/// send failed — a caller has nothing useful to do about either.
|
||||||
|
pub(crate) fn notify(message: &str) -> bool {
|
||||||
|
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if socket_path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(socket) = UnixDatagram::unbound() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(name) = socket_path.strip_prefix('@') {
|
||||||
|
// Abstract namespace: the address is a NUL byte followed by the name,
|
||||||
|
// which `std`'s path-based API cannot express.
|
||||||
|
send_abstract(&socket, name, message)
|
||||||
|
} else {
|
||||||
|
socket.send_to(message.as_bytes(), &socket_path).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sendto` on an abstract-namespace address, which needs a hand-built
|
||||||
|
/// `sockaddr_un` because the first byte of the name is NUL.
|
||||||
|
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||||
|
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
// One byte for the leading NUL, and the path must still be addressable.
|
||||||
|
if bytes.len() + 1 > addr.sun_path.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
|
||||||
|
*slot = *byte as libc::c_char;
|
||||||
|
}
|
||||||
|
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
|
||||||
|
let sent = unsafe {
|
||||||
|
libc::sendto(
|
||||||
|
socket.as_raw_fd(),
|
||||||
|
message.as_ptr().cast(),
|
||||||
|
message.len(),
|
||||||
|
0,
|
||||||
|
std::ptr::from_ref(&addr).cast(),
|
||||||
|
len as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sent >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is up and serving. Called once the mount is live,
|
||||||
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
|
/// when the process exists.
|
||||||
|
pub(crate) fn ready() {
|
||||||
|
if notify("READY=1\n") {
|
||||||
|
debug!("notified systemd: ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is still healthy.
|
||||||
|
pub(crate) fn watchdog_ping() {
|
||||||
|
let _ = notify("WATCHDOG=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
|
/// for a hang by the watchdog it is racing.
|
||||||
|
pub(crate) fn stopping() {
|
||||||
|
let _ = notify("STOPPING=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
|
||||||
|
/// the watchdog is off or belongs to another process.
|
||||||
|
///
|
||||||
|
/// Reports *half* the configured interval, the interval systemd documents for
|
||||||
|
/// pinging: a ping period equal to the deadline would restart the daemon on the
|
||||||
|
/// first late scheduling.
|
||||||
|
pub(crate) fn watchdog_period() -> Option<Duration> {
|
||||||
|
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
|
||||||
|
// in that case a ping from anyone else is not the one it is waiting for.
|
||||||
|
if let Ok(pid) = std::env::var("WATCHDOG_PID")
|
||||||
|
&& pid.parse::<u32>().ok() != Some(std::process::id())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
|
||||||
|
(usec > 0).then(|| Duration::from_micros(usec / 2))
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Importing a Google Photos Takeout export into Proton Photos.
|
||||||
|
//!
|
||||||
|
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
|
||||||
|
//! talks to Proton. One import runs on its own thread and moves through four
|
||||||
|
//! phases, reporting into the transfer registry as a job so the GUI's transfer
|
||||||
|
//! list shows it without a bespoke widget:
|
||||||
|
//!
|
||||||
|
//! 1. **Scan** — read the archives' central directories and sidecars.
|
||||||
|
//! 2. **Deduplicate against the account** — ask the photos volume which of the
|
||||||
|
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
|
||||||
|
//! those files, then confirm with the content digest. A library that is
|
||||||
|
//! already half-synced by Proton Photos on a phone therefore costs one cheap
|
||||||
|
//! request per 150 names and reads no media at all for the misses.
|
||||||
|
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
|
||||||
|
//! no extraction to disk, so the import needs no scratch space.
|
||||||
|
//! 4. **Albums** — create the albums the export names that the account lacks,
|
||||||
|
//! then add every photo of each album, the ones that were already on the
|
||||||
|
//! account included.
|
||||||
|
//!
|
||||||
|
//! Re-running an interrupted import is the resume story: phase 2 finds
|
||||||
|
//! everything the previous run uploaded and skips it. Nothing is persisted
|
||||||
|
//! between runs.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
|
///
|
||||||
|
/// Process-global rather than a [`Core`] field because it must be *one* import
|
||||||
|
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
|
||||||
|
/// hold one), and because a front-end asking for the result after the import
|
||||||
|
/// finished must still find it.
|
||||||
|
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ImportState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<ImportSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an import is running, and the last one's report.
|
||||||
|
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
|
||||||
|
let state = IMPORT.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running import to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_import() -> bool {
|
||||||
|
match &IMPORT.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to import, after the copies Google keeps per album are folded into
|
||||||
|
/// a single upload that joins several albums.
|
||||||
|
struct Planned {
|
||||||
|
photo: TakeoutPhoto,
|
||||||
|
/// Every album this photo belongs to, by export title.
|
||||||
|
albums: Vec<String>,
|
||||||
|
/// Filled in phase 2/3: where the photo ended up on the account.
|
||||||
|
uid: Option<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the scan into one entry per distinct photo.
|
||||||
|
///
|
||||||
|
/// Google stores a full copy of a photo in every album folder it appears in, so
|
||||||
|
/// the same file is in the export two or three times over. They are folded on
|
||||||
|
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
|
||||||
|
/// and the album memberships of the copies are unioned onto the survivor. Two
|
||||||
|
/// genuinely different photos sharing both a name and an exact size would be
|
||||||
|
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
|
||||||
|
/// filed under an extra album rather than lost data.
|
||||||
|
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
|
||||||
|
let mut order: Vec<(String, u64)> = Vec::new();
|
||||||
|
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
|
||||||
|
|
||||||
|
for photo in &scan.photos {
|
||||||
|
let key = (photo.name.clone(), photo.size);
|
||||||
|
let entry = by_key.entry(key.clone()).or_insert_with(|| {
|
||||||
|
order.push(key.clone());
|
||||||
|
Planned {
|
||||||
|
photo: photo.clone(),
|
||||||
|
albums: Vec::new(),
|
||||||
|
uid: None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Bucket::Album(title) = &photo.bucket
|
||||||
|
&& !entry.albums.iter().any(|existing| existing == title)
|
||||||
|
{
|
||||||
|
entry.albums.push(title.clone());
|
||||||
|
}
|
||||||
|
// A copy that carries a capture time wins over one that does not: the
|
||||||
|
// album copy and the year-folder copy do not always both have a sidecar.
|
||||||
|
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
|
||||||
|
entry.photo.capture_time = photo.capture_time;
|
||||||
|
}
|
||||||
|
entry.photo.favorite |= photo.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| by_key.remove(&key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one archive and stream `entry` through `sink`.
|
||||||
|
fn read_entry<R>(
|
||||||
|
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||||
|
entry: &str,
|
||||||
|
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
|
||||||
|
) -> CoreResult<R> {
|
||||||
|
let mut file = archive
|
||||||
|
.by_name(entry)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
|
||||||
|
Ok(sink(&mut file))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
|
/// the wire is allowed to finish, so a cancelled import never leaves a
|
||||||
|
/// half-written revision behind.
|
||||||
|
pub(crate) fn import_takeout(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<ImportSummary> {
|
||||||
|
let job = self.transfers.begin_job("Importing Google Photos");
|
||||||
|
job.detail("reading archives");
|
||||||
|
|
||||||
|
let scan = scan_archives(&archives)?;
|
||||||
|
let mut planned = plan(&scan);
|
||||||
|
let mut summary = ImportSummary {
|
||||||
|
found: planned.len(),
|
||||||
|
skipped_trashed: scan.skipped_trashed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
job.set_total(planned.len() as u64);
|
||||||
|
info!(
|
||||||
|
archives = archives.len(),
|
||||||
|
photos = summary.found,
|
||||||
|
albums = scan.albums.len(),
|
||||||
|
trashed = summary.skipped_trashed,
|
||||||
|
"takeout scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
let photos = self.photos();
|
||||||
|
if self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.get_photos_root())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: which of these are already on the account.
|
||||||
|
job.detail("checking for photos you already have");
|
||||||
|
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: upload what is left, one archive at a time so each zip's
|
||||||
|
// central directory is parsed once.
|
||||||
|
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
|
||||||
|
|
||||||
|
// Phase 4: albums. Attempted even after a cancel — the photos that did
|
||||||
|
// upload should still land in their albums.
|
||||||
|
if !scan.albums.is_empty() {
|
||||||
|
job.detail("sorting photos into albums");
|
||||||
|
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
|
||||||
|
warn!(error = %e, "takeout album linking failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in `uid` for every planned photo the account already holds.
|
||||||
|
///
|
||||||
|
/// Two passes on purpose: the name-only query is one request per 150 photos
|
||||||
|
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
|
||||||
|
/// handful of names that actually collide.
|
||||||
|
fn mark_existing(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
|
||||||
|
let collisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.name_collisions(&names))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
// Hash the colliding files, grouped by archive so each zip opens once.
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, planned) in planned.iter().enumerate() {
|
||||||
|
if collisions.get(index).copied().unwrap_or(false) {
|
||||||
|
by_archive
|
||||||
|
.entry(planned.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept as a pair list, not a map: the duplicate lookup answers in input
|
||||||
|
// order, so the order these were built in is what maps answers back to
|
||||||
|
// planned photos.
|
||||||
|
let mut digests: Vec<(usize, String)> = Vec::new();
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
for index in indices {
|
||||||
|
let entry = planned[index].photo.entry.clone();
|
||||||
|
match read_entry(&mut archive, &entry, sha1_of) {
|
||||||
|
Ok(Ok(digest)) => digests.push((index, digest)),
|
||||||
|
Ok(Err(e)) | Err(e) => {
|
||||||
|
warn!(entry, error = %e, "cannot hash takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digests.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<(String, String)> = digests
|
||||||
|
.iter()
|
||||||
|
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
|
||||||
|
.collect();
|
||||||
|
let matches = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.find_duplicates_many(&items))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
for (slot, (index, _)) in digests.iter().enumerate() {
|
||||||
|
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
|
||||||
|
planned[*index].uid = Some(uid.clone());
|
||||||
|
summary.duplicates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload every planned photo that is not already on the account.
|
||||||
|
fn upload_planned(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
job: &crate::transfers::JobGuard,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let mut favorites: Vec<NodeUid> = Vec::new();
|
||||||
|
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, item) in planned.iter().enumerate() {
|
||||||
|
if item.uid.is_none() {
|
||||||
|
by_archive
|
||||||
|
.entry(item.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
|
||||||
|
for index in indices {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let photo = planned[index].photo.clone();
|
||||||
|
job.detail(photo.name.clone());
|
||||||
|
|
||||||
|
let guard = self.transfers.begin(
|
||||||
|
photo.name.clone(),
|
||||||
|
"",
|
||||||
|
TransferDirection::Upload,
|
||||||
|
photo.size,
|
||||||
|
);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||||
|
let counting = CountingReader::new(reader, &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
drop(guard);
|
||||||
|
job.step();
|
||||||
|
|
||||||
|
match uploaded {
|
||||||
|
Ok(Ok(uid)) => {
|
||||||
|
summary.uploaded += 1;
|
||||||
|
summary.bytes += photo.size;
|
||||||
|
if photo.favorite {
|
||||||
|
favorites.push(uid.clone());
|
||||||
|
}
|
||||||
|
planned[index].uid = Some(uid);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "takeout photo upload failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "cannot read takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
|
// volume's dedicated favorite endpoint.
|
||||||
|
if !favorites.is_empty() {
|
||||||
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
|
.into_iter()
|
||||||
|
.map(|node_uid| PhotoTagsUpdate {
|
||||||
|
node_uid,
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the albums the export names that the account lacks, then add each
|
||||||
|
/// album's photos to it.
|
||||||
|
fn link_albums(
|
||||||
|
&self,
|
||||||
|
planned: &[Planned],
|
||||||
|
albums: &[String],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
|
||||||
|
// Existing albums by decrypted name, so a second import of the same
|
||||||
|
// export adds to the album it made the first time instead of a twin.
|
||||||
|
let mut existing: HashMap<String, NodeUid> = HashMap::new();
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_album_node_uids())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
|
||||||
|
if !uids.is_empty() {
|
||||||
|
let albums_nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
|
||||||
|
for node in albums_nodes {
|
||||||
|
existing.entry(node.name.clone()).or_insert(node.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for title in albums {
|
||||||
|
let members: Vec<NodeUid> = planned
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.albums.iter().any(|album| album == title))
|
||||||
|
.filter_map(|item| item.uid.clone())
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_uid = match existing.get(title) {
|
||||||
|
Some(uid) => uid.clone(),
|
||||||
|
None => match self.rt.block_on(photos.create_album(title)) {
|
||||||
|
Ok(uid) => {
|
||||||
|
summary.albums_created += 1;
|
||||||
|
existing.insert(title.clone(), uid.clone());
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, error = %e, "cannot create album");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, &members))
|
||||||
|
{
|
||||||
|
Ok(outcomes) => {
|
||||||
|
for (uid, outcome) in outcomes {
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => summary.album_links += 1,
|
||||||
|
// A photo already in the album is the expected
|
||||||
|
// outcome of a re-run, not a failure worth counting.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, %uid, error = %e, "cannot add photo to album")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(album = title, error = %e, "album addition failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an import on a background thread, logging its outcome to the activity
|
||||||
|
/// feed. Returns immediately.
|
||||||
|
///
|
||||||
|
/// Refuses while one is already running: two imports of the same export
|
||||||
|
/// would race each other's duplicate detection and upload everything twice.
|
||||||
|
pub(crate) fn spawn_takeout_import(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"an import is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.import_takeout(archives, dry_run, cancel);
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(summary) => {
|
||||||
|
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
parts.push(format!("{} already there", summary.duplicates));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
parts.push(format!("{} albums", summary.albums_created));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
parts.push(format!("{} failed", summary.failed));
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
parts.push("cancelled".to_string());
|
||||||
|
}
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"Google Photos import",
|
||||||
|
parts.join(" · "),
|
||||||
|
summary.failed == 0 && !summary.cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "takeout import failed");
|
||||||
|
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one of the import's archives by index.
|
||||||
|
fn open_archive(
|
||||||
|
archives: &[PathBuf],
|
||||||
|
index: usize,
|
||||||
|
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
|
||||||
|
let path = archives
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
|
/// photo duplicate detection is keyed on.
|
||||||
|
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(hex_lower(&hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::takeout::TakeoutPhoto;
|
||||||
|
|
||||||
|
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
|
||||||
|
TakeoutPhoto {
|
||||||
|
archive: 0,
|
||||||
|
entry: format!("Takeout/Google Photos/x/{name}"),
|
||||||
|
name: name.to_string(),
|
||||||
|
size,
|
||||||
|
media_type: "image/jpeg".to_string(),
|
||||||
|
capture_time: None,
|
||||||
|
favorite: false,
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_copies_of_one_photo_fold_into_a_single_upload() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
|
||||||
|
photo("IMG_2.jpg", 200, Bucket::Timeline),
|
||||||
|
],
|
||||||
|
albums: vec!["Iceland".into(), "Best of".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let planned = plan(&scan);
|
||||||
|
assert_eq!(planned.len(), 2, "the three copies are one upload");
|
||||||
|
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
|
||||||
|
assert!(planned[1].albums.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_name_different_size_stays_two_photos() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(plan(&scan).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_from_any_copy_survives_the_fold() {
|
||||||
|
// Only the album copy carries a sidecar; the fold must not lose it.
|
||||||
|
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
|
||||||
|
bare.capture_time = None;
|
||||||
|
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
|
||||||
|
rich.capture_time = Some(1_560_000_000);
|
||||||
|
rich.favorite = true;
|
||||||
|
|
||||||
|
let planned = plan(&TakeoutScan {
|
||||||
|
photos: vec![bare, rich],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
|
||||||
|
assert!(planned[0].photo.favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_matches_the_known_digest_of_abc() {
|
||||||
|
let mut reader: &[u8] = b"abc";
|
||||||
|
assert_eq!(
|
||||||
|
sha1_of(&mut reader).unwrap(),
|
||||||
|
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,18 @@
|
|||||||
//! always reflects exactly what is in flight — even if the work fails partway
|
//! always reflects exactly what is in flight — even if the work fails partway
|
||||||
//! and unwinds.
|
//! and unwinds.
|
||||||
//!
|
//!
|
||||||
|
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||||
|
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||||
|
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||||
|
//!
|
||||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
|
|
||||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
|||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
|
upload_limit: RateLimit,
|
||||||
|
download_limit: RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||||
|
/// small file still goes out at full speed.
|
||||||
|
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||||
|
/// cap.
|
||||||
|
///
|
||||||
|
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||||
|
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||||
|
/// transfers split the cap between them instead of each getting all of it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RateLimit {
|
||||||
|
rate: AtomicU64,
|
||||||
|
next_free: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
fn set(&self, rate: u64) {
|
||||||
|
self.rate.store(rate, Ordering::Relaxed);
|
||||||
|
// A new cap starts from now, not from the debt the old one ran up.
|
||||||
|
*self.next_free.lock() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||||
|
/// to stay under the cap.
|
||||||
|
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||||
|
let rate = self.rate.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 || n == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let mut next_free = self.next_free.lock();
|
||||||
|
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||||
|
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||||
|
*next_free = Some(done);
|
||||||
|
done.saturating_duration_since(now + RATE_BURST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||||
|
/// the sleep is announced to the runtime there rather than silently holding a
|
||||||
|
/// worker other tasks — FUSE reads among them — may need.
|
||||||
|
fn throttle(wait: Duration) {
|
||||||
|
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||||
|
match Handle::try_current() {
|
||||||
|
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||||
|
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||||
|
}
|
||||||
|
_ => std::thread::sleep(wait),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransferRegistry {
|
impl TransferRegistry {
|
||||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||||
|
/// Applies to transfers already running from their next block.
|
||||||
|
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||||
|
self.upload_limit.set(upload);
|
||||||
|
self.download_limit.set(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
|
match direction {
|
||||||
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
TransferDirection::Download => &self.download_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||||
pub fn begin(
|
pub fn begin(
|
||||||
@@ -169,9 +240,17 @@ pub struct TransferGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransferGuard {
|
impl TransferGuard {
|
||||||
/// Record `n` more bytes moved.
|
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||||
|
/// its cap.
|
||||||
pub fn add(&self, n: u64) {
|
pub fn add(&self, n: u64) {
|
||||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
let wait = self
|
||||||
|
.reg
|
||||||
|
.limit(self.entry.direction)
|
||||||
|
.charge(n, Instant::now());
|
||||||
|
if !wait.is_zero() {
|
||||||
|
throttle(wait);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,22 +351,99 @@ impl<R: Read> Read for OwnedCountingReader<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
|
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path),
|
||||||
|
/// and can be told to stop.
|
||||||
pub struct CountingReader<'a, R> {
|
pub struct CountingReader<'a, R> {
|
||||||
inner: R,
|
inner: R,
|
||||||
guard: &'a TransferGuard,
|
guard: &'a TransferGuard,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, R: Read> CountingReader<'a, R> {
|
impl<'a, R: Read> CountingReader<'a, R> {
|
||||||
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
||||||
Self { inner, guard }
|
Self {
|
||||||
|
inner,
|
||||||
|
guard,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon the upload as soon as the SDK asks for its next bytes when
|
||||||
|
/// `cancel` is set.
|
||||||
|
///
|
||||||
|
/// This is the only cancellation point an upload has: the SDK takes a plain
|
||||||
|
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
|
||||||
|
/// hand over the next block is what stops it. Used when a newer revision of
|
||||||
|
/// the same file is queued — the bytes on the wire have already been
|
||||||
|
/// superseded, and finishing them only spends uplink on a revision the next
|
||||||
|
/// op replaces.
|
||||||
|
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
|
||||||
|
self.cancel = Some(cancel);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Read> Read for CountingReader<'_, R> {
|
impl<R: Read> Read for CountingReader<'_, R> {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if let Some(cancel) = &self.cancel
|
||||||
|
&& cancel.load(Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
|
// and the SDK would seal a truncated revision from it. Not
|
||||||
|
// `Interrupted` either: every reader loop, the SDK's included,
|
||||||
|
// retries that kind, and this flag never clears, so the drain
|
||||||
|
// thread spun forever with the transfer stuck at 0 bytes (B98).
|
||||||
|
return Err(std::io::Error::other("upload superseded by a newer write"));
|
||||||
|
}
|
||||||
let n = self.inner.read(buf)?;
|
let n = self.inner.read(buf)?;
|
||||||
self.guard.add(n as u64);
|
self.guard.add(n as u64);
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_direction_never_waits() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_direction_waits_once_past_its_burst() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
// One second of bytes is the burst: no wait yet.
|
||||||
|
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||||
|
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||||
|
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||||
|
// Lifting the cap drops the debt with it.
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_transfers_share_one_cap() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry() {
|
||||||
|
let reg = TransferRegistry::new();
|
||||||
|
let guard = reg.begin("f", "", TransferDirection::Upload, 4);
|
||||||
|
let cancel = Arc::new(AtomicBool::new(true));
|
||||||
|
let mut reader = CountingReader::new(&b"data"[..], &guard).with_cancel(cancel);
|
||||||
|
// `read_exact` retries `Interrupted` the way the SDK's block loop does,
|
||||||
|
// so this hangs rather than fails if the cancel asks for a retry.
|
||||||
|
let err = reader.read_exact(&mut [0; 4]).unwrap_err();
|
||||||
|
assert_ne!(err.kind(), std::io::ErrorKind::Interrupted);
|
||||||
|
assert_eq!(guard.entry.done.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ async fn run_uploads(
|
|||||||
format!("destination access denied: errno {}", error.code()),
|
format!("destination access denied: errno {}", error.code()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let thumbnails = core
|
||||||
|
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||||
|
.await;
|
||||||
match core
|
match core
|
||||||
.client
|
.client
|
||||||
.upload_file_from(
|
.upload_file_from(
|
||||||
@@ -77,7 +80,7 @@ async fn run_uploads(
|
|||||||
media_type_for(&t.name),
|
media_type_for(&t.name),
|
||||||
reader,
|
reader,
|
||||||
t.size as i64,
|
t.size as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
mtime,
|
mtime,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
@@ -171,7 +174,7 @@ impl Core {
|
|||||||
stats.bytes += size;
|
stats.bytes += size;
|
||||||
match self.fetch_node(&uid) {
|
match self.fetch_node(&uid) {
|
||||||
Ok(node) => {
|
Ok(node) => {
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(parent_ino, node);
|
let ino = st.intern(parent_ino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&parent_ino)
|
if let Some(kids) = st.children.get_mut(&parent_ino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
@@ -216,7 +219,7 @@ 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 existing = {
|
||||||
let st = self.state.lock();
|
let st = self.state();
|
||||||
st.children.get(&pino).and_then(|kids| {
|
st.children.get(&pino).and_then(|kids| {
|
||||||
kids.iter().find_map(|ino| {
|
kids.iter().find_map(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -243,7 +246,7 @@ impl Core {
|
|||||||
let node = self
|
let node = self
|
||||||
.fetch_node(&new_uid)
|
.fetch_node(&new_uid)
|
||||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(pino, node);
|
let ino = st.intern(pino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&pino)
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use parking_lot::{Condvar, Mutex};
|
use parking_lot::{Condvar, Mutex};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -51,8 +53,25 @@ pub(crate) const FUSE_WORKERS: usize = 11;
|
|||||||
/// exactly the blocking this split exists to prevent.
|
/// exactly the blocking this split exists to prevent.
|
||||||
const META_WORKERS: usize = 3;
|
const META_WORKERS: usize = 3;
|
||||||
|
|
||||||
|
/// Queue depth that gets a line in the log, and the step at which it repeats.
|
||||||
|
/// Every job holds a `Reply` and a few fields, so depth is cheap — but a lane
|
||||||
|
/// this far behind is worth knowing about before the user reports a slow mount.
|
||||||
|
const QUEUE_WARN_STEP: usize = 256;
|
||||||
|
|
||||||
|
/// Whether a queue reaching `depth` is one of the depths worth logging.
|
||||||
|
fn crosses_warn_step(depth: usize) -> bool {
|
||||||
|
depth >= QUEUE_WARN_STEP && depth.is_multiple_of(QUEUE_WARN_STEP)
|
||||||
|
}
|
||||||
|
|
||||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
|
/// A queued job and the name its caller gave it, so a job that never finishes
|
||||||
|
/// can be named in the diagnostics instead of showing up as "a worker is busy".
|
||||||
|
struct Queued {
|
||||||
|
label: &'static str,
|
||||||
|
job: Job,
|
||||||
|
}
|
||||||
|
|
||||||
/// Which lane a job belongs in.
|
/// Which lane a job belongs in.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum Lane {
|
pub(crate) enum Lane {
|
||||||
@@ -65,12 +84,63 @@ pub(crate) enum Lane {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Queues {
|
struct Queues {
|
||||||
meta: VecDeque<Job>,
|
meta: VecDeque<Queued>,
|
||||||
transfer: VecDeque<Job>,
|
transfer: VecDeque<Queued>,
|
||||||
/// Set when the pool is dropped, to wake and retire every worker.
|
/// Set when the pool is dropped, to wake and retire every worker.
|
||||||
closed: bool,
|
closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one worker thread is doing, published for [`Workers::snapshot`].
|
||||||
|
///
|
||||||
|
/// Every field is an atomic (or a lock the worker only ever holds for the length
|
||||||
|
/// of a pointer write) because the whole point of the snapshot is to answer
|
||||||
|
/// *while the daemon is wedged*: a diagnostic that waits on the same lock the
|
||||||
|
/// stuck job holds reports nothing at exactly the moment it is needed.
|
||||||
|
struct Slot {
|
||||||
|
name: String,
|
||||||
|
/// True between picking a job up and finishing it.
|
||||||
|
busy: AtomicBool,
|
||||||
|
/// When the current state (busy or idle) began, as milliseconds since the
|
||||||
|
/// pool started. Monotonic, so an age is always a real duration.
|
||||||
|
since_ms: AtomicU64,
|
||||||
|
/// The label of the job in hand. Behind a mutex because a `&'static str` is
|
||||||
|
/// two words and there is no atomic for that; the worker holds it only to
|
||||||
|
/// swap the pointer, and the snapshot never blocks on it (see
|
||||||
|
/// [`Workers::snapshot`]).
|
||||||
|
label: Mutex<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
fn new(name: String, now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
busy: AtomicBool::new(false),
|
||||||
|
since_ms: AtomicU64::new(now_ms),
|
||||||
|
label: Mutex::new(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One worker's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct WorkerSnapshot {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) busy: bool,
|
||||||
|
/// How long the worker has been in that state.
|
||||||
|
pub(crate) age_ms: u64,
|
||||||
|
/// The job in hand, or `""` when idle. `"?"` when the label could not be
|
||||||
|
/// read without blocking.
|
||||||
|
pub(crate) label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pool's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct PoolSnapshot {
|
||||||
|
pub(crate) workers: Vec<WorkerSnapshot>,
|
||||||
|
pub(crate) meta_queued: usize,
|
||||||
|
pub(crate) transfer_queued: usize,
|
||||||
|
pub(crate) meta_completed: u64,
|
||||||
|
pub(crate) transfer_completed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// The queues and the two wait sets over them.
|
/// The queues and the two wait sets over them.
|
||||||
///
|
///
|
||||||
/// Two condvars, not one, because the workers are not interchangeable: a
|
/// Two condvars, not one, because the workers are not interchangeable: a
|
||||||
@@ -86,6 +156,22 @@ struct Pool {
|
|||||||
meta_cv: Condvar,
|
meta_cv: Condvar,
|
||||||
/// Waited on by the general workers.
|
/// Waited on by the general workers.
|
||||||
general_cv: Condvar,
|
general_cv: Condvar,
|
||||||
|
/// Queue depths, mirrored outside the lock so a snapshot can read them
|
||||||
|
/// without waiting on whatever is holding it.
|
||||||
|
meta_queued: AtomicUsize,
|
||||||
|
transfer_queued: AtomicUsize,
|
||||||
|
/// Jobs finished per lane. A depth that stays high while this stays still is
|
||||||
|
/// a stalled lane rather than a busy one.
|
||||||
|
meta_completed: AtomicU64,
|
||||||
|
transfer_completed: AtomicU64,
|
||||||
|
slots: Vec<Slot>,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pool {
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
self.started.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded thread pool behind the network-touching FUSE handlers.
|
/// Bounded thread pool behind the network-touching FUSE handlers.
|
||||||
@@ -94,25 +180,46 @@ struct Pool {
|
|||||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||||
pub(crate) struct Workers {
|
pub(crate) struct Workers {
|
||||||
inner: Arc<Pool>,
|
inner: Arc<Pool>,
|
||||||
|
/// Kept so teardown can *join* the workers, not merely flag them. A worker
|
||||||
|
/// still inside a job when the process drops its tokio runtime panics with
|
||||||
|
/// "A Tokio 1.x context was found, but it is being shutdown" (seen
|
||||||
|
/// 2026-09-18 09:58:35), which is a teardown ordering bug rather than a real
|
||||||
|
/// failure.
|
||||||
|
handles: Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workers {
|
impl Workers {
|
||||||
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
||||||
|
// Never leave the pool without a general worker, however small `n` is.
|
||||||
|
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
||||||
|
let names: Vec<String> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
format!(
|
||||||
|
"pdfs-fuse-{}{i}",
|
||||||
|
if i < meta_workers { "meta-" } else { "" }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let inner = Arc::new(Pool {
|
let inner = Arc::new(Pool {
|
||||||
queues: Mutex::new(Queues::default()),
|
queues: Mutex::new(Queues::default()),
|
||||||
meta_cv: Condvar::new(),
|
meta_cv: Condvar::new(),
|
||||||
general_cv: Condvar::new(),
|
general_cv: Condvar::new(),
|
||||||
|
meta_queued: AtomicUsize::new(0),
|
||||||
|
transfer_queued: AtomicUsize::new(0),
|
||||||
|
meta_completed: AtomicU64::new(0),
|
||||||
|
transfer_completed: AtomicU64::new(0),
|
||||||
|
slots: names
|
||||||
|
.iter()
|
||||||
|
.map(|name| Slot::new(name.clone(), 0))
|
||||||
|
.collect(),
|
||||||
|
started: Instant::now(),
|
||||||
});
|
});
|
||||||
// Never leave the pool without a general worker, however small `n` is.
|
let mut handles = Vec::with_capacity(n);
|
||||||
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
for (i, name) in names.iter().enumerate() {
|
||||||
for i in 0..n {
|
|
||||||
let inner = inner.clone();
|
let inner = inner.clone();
|
||||||
let meta_only = i < meta_workers;
|
let meta_only = i < meta_workers;
|
||||||
std::thread::Builder::new()
|
let handle = std::thread::Builder::new()
|
||||||
.name(format!(
|
.name(name.clone())
|
||||||
"pdfs-fuse-{}{i}",
|
|
||||||
if meta_only { "meta-" } else { "" }
|
|
||||||
))
|
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let cv = if meta_only {
|
let cv = if meta_only {
|
||||||
&inner.meta_cv
|
&inner.meta_cv
|
||||||
@@ -121,17 +228,20 @@ impl Workers {
|
|||||||
};
|
};
|
||||||
loop {
|
loop {
|
||||||
let mut q = inner.queues.lock();
|
let mut q = inner.queues.lock();
|
||||||
let job = loop {
|
let picked = loop {
|
||||||
// A general worker takes transfers first: metadata
|
// A general worker takes transfers first: metadata
|
||||||
// already has threads of its own, so draining the
|
// already has threads of its own, so draining the
|
||||||
// bulk queue is the useful thing for it to do.
|
// bulk queue is the useful thing for it to do.
|
||||||
let picked = if meta_only {
|
let taken = if meta_only {
|
||||||
q.meta.pop_front()
|
q.meta.pop_front().map(|job| (Lane::Meta, job))
|
||||||
} else {
|
} else {
|
||||||
q.transfer.pop_front().or_else(|| q.meta.pop_front())
|
q.transfer
|
||||||
|
.pop_front()
|
||||||
|
.map(|job| (Lane::Transfer, job))
|
||||||
|
.or_else(|| q.meta.pop_front().map(|job| (Lane::Meta, job)))
|
||||||
};
|
};
|
||||||
if let Some(job) = picked {
|
if let Some(taken) = taken {
|
||||||
break Some(job);
|
break Some(taken);
|
||||||
}
|
}
|
||||||
if q.closed {
|
if q.closed {
|
||||||
break None;
|
break None;
|
||||||
@@ -139,7 +249,15 @@ impl Workers {
|
|||||||
cv.wait(&mut q);
|
cv.wait(&mut q);
|
||||||
};
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
let Some(job) = job else { break };
|
let Some((lane, queued)) = picked else { break };
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => inner.transfer_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
};
|
||||||
|
let slot = &inner.slots[i];
|
||||||
|
*slot.label.lock() = queued.label;
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
slot.busy.store(true, Ordering::Relaxed);
|
||||||
// A panicking handler must not cost the pool a worker
|
// A panicking handler must not cost the pool a worker
|
||||||
// for the rest of the run. The dropped `Reply` answers
|
// for the rest of the run. The dropped `Reply` answers
|
||||||
// EIO on its own, so the caller of the failed op is
|
// EIO on its own, so the caller of the failed op is
|
||||||
@@ -164,11 +282,70 @@ impl Workers {
|
|||||||
// poisoning at all. Anything new reached from a job owes
|
// poisoning at all. Anything new reached from a job owes
|
||||||
// the same check — an SDK-side `.lock().unwrap()`
|
// the same check — an SDK-side `.lock().unwrap()`
|
||||||
// silently invalidates this comment.
|
// silently invalidates this comment.
|
||||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(queued.job));
|
||||||
|
slot.busy.store(false, Ordering::Relaxed);
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
*slot.label.lock() = "";
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_completed.fetch_add(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => {
|
||||||
|
inner.transfer_completed.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
Ok(Self { inner })
|
Ok(Self {
|
||||||
|
inner,
|
||||||
|
handles: Mutex::new(handles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting work, wake every worker, and wait up to `deadline` for the
|
||||||
|
/// jobs in flight to finish.
|
||||||
|
///
|
||||||
|
/// Bounded because a join is only worth what it costs: a worker wedged on a
|
||||||
|
/// network call would otherwise hold the whole teardown, and the user asked
|
||||||
|
/// for the daemon to stop. What is stuck gets named in the log, and the
|
||||||
|
/// process continues shutting down around it.
|
||||||
|
pub(crate) fn stop_and_join(&self, deadline: std::time::Duration) {
|
||||||
|
self.close();
|
||||||
|
let handles: Vec<_> = std::mem::take(&mut *self.handles.lock());
|
||||||
|
if handles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let joiner = std::thread::Builder::new()
|
||||||
|
.name("pdfs-fuse-join".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
let _ = tx.send(());
|
||||||
|
});
|
||||||
|
if joiner.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if rx.recv_timeout(deadline).is_err() {
|
||||||
|
let busy: Vec<String> = self
|
||||||
|
.snapshot()
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|worker| worker.busy)
|
||||||
|
.map(|worker| format!("{}={}", worker.name, worker.label))
|
||||||
|
.collect();
|
||||||
|
warn!(
|
||||||
|
stuck = busy.join(","),
|
||||||
|
"fuse workers did not finish in time; shutting down around them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self) {
|
||||||
|
self.inner.queues.lock().closed = true;
|
||||||
|
self.inner.meta_cv.notify_all();
|
||||||
|
self.inner.general_cv.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue `job` in `lane`.
|
/// Queue `job` in `lane`.
|
||||||
@@ -179,7 +356,15 @@ impl Workers {
|
|||||||
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
||||||
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
||||||
/// semaphore is what bounds how many of those exist at once.
|
/// semaphore is what bounds how many of those exist at once.
|
||||||
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
|
/// The queue is *not* bounded, and the depth warning above is deliberately
|
||||||
|
/// all this does about it: the only caller able to feel backpressure here is
|
||||||
|
/// fuser's dispatch loop, and blocking that is the stall the whole module
|
||||||
|
/// exists to avoid. A depth that keeps climbing is a symptom to read in the
|
||||||
|
/// log, not something to fix by stopping the mount.
|
||||||
|
///
|
||||||
|
/// `label` names the work for the diagnostics (`pdfs diagnostics`) and for
|
||||||
|
/// the stall warnings: it is what a hung daemon reports instead of "busy".
|
||||||
|
pub(crate) fn run(&self, lane: Lane, label: &'static str, job: impl FnOnce() + Send + 'static) {
|
||||||
let mut q = self.inner.queues.lock();
|
let mut q = self.inner.queues.lock();
|
||||||
if q.closed {
|
if q.closed {
|
||||||
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
||||||
@@ -189,11 +374,31 @@ impl Workers {
|
|||||||
job();
|
job();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match lane {
|
let queued = Queued {
|
||||||
Lane::Meta => q.meta.push_back(Box::new(job)),
|
label,
|
||||||
Lane::Transfer => q.transfer.push_back(Box::new(job)),
|
job: Box::new(job),
|
||||||
}
|
};
|
||||||
|
let depth = match lane {
|
||||||
|
Lane::Meta => {
|
||||||
|
q.meta.push_back(queued);
|
||||||
|
self.inner.meta_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
Lane::Transfer => {
|
||||||
|
q.transfer.push_back(queued);
|
||||||
|
self.inner.transfer_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
|
if crosses_warn_step(depth) {
|
||||||
|
warn!(
|
||||||
|
lane = if matches!(lane, Lane::Meta) {
|
||||||
|
"meta"
|
||||||
|
} else {
|
||||||
|
"transfer"
|
||||||
|
},
|
||||||
|
depth, label, "fuse worker queue is deep"
|
||||||
|
);
|
||||||
|
}
|
||||||
match lane {
|
match lane {
|
||||||
// Either class can serve metadata, and only one of them needs to:
|
// Either class can serve metadata, and only one of them needs to:
|
||||||
// whichever wakes first takes it, and the other finds the queue
|
// whichever wakes first takes it, and the other finds the queue
|
||||||
@@ -209,13 +414,50 @@ impl Workers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What every worker is doing right now, without taking the queue lock.
|
||||||
|
///
|
||||||
|
/// A diagnostic is worth having exactly when the daemon is stuck, so this
|
||||||
|
/// reads atomics only, and `try_lock`s each label rather than waiting for
|
||||||
|
/// it: a label is held for one pointer write, so failing to get it means a
|
||||||
|
/// racing worker, not a wedged one, and `"?"` is a better answer than a
|
||||||
|
/// diagnostics request that hangs too.
|
||||||
|
pub(crate) fn snapshot(&self) -> PoolSnapshot {
|
||||||
|
let now = self.inner.now_ms();
|
||||||
|
let workers = self
|
||||||
|
.inner
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.map(|slot| {
|
||||||
|
let label = slot
|
||||||
|
.label
|
||||||
|
.try_lock()
|
||||||
|
.map_or_else(|| "?".to_string(), |label| (*label).to_string());
|
||||||
|
WorkerSnapshot {
|
||||||
|
name: slot.name.clone(),
|
||||||
|
busy: slot.busy.load(Ordering::Relaxed),
|
||||||
|
age_ms: now.saturating_sub(slot.since_ms.load(Ordering::Relaxed)),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
PoolSnapshot {
|
||||||
|
workers,
|
||||||
|
meta_queued: self.inner.meta_queued.load(Ordering::Relaxed),
|
||||||
|
transfer_queued: self.inner.transfer_queued.load(Ordering::Relaxed),
|
||||||
|
meta_completed: self.inner.meta_completed.load(Ordering::Relaxed),
|
||||||
|
transfer_completed: self.inner.transfer_completed.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Workers {
|
impl Drop for Workers {
|
||||||
|
/// A backstop for the paths that never call
|
||||||
|
/// [`stop_and_join`](Workers::stop_and_join) — tests, and a mount that fails
|
||||||
|
/// before teardown is wired up. Flag and wake only: a `Drop` that blocks on
|
||||||
|
/// a network call is worse than a worker outliving its pool by a moment.
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.inner.queues.lock().closed = true;
|
self.close();
|
||||||
self.inner.meta_cv.notify_all();
|
|
||||||
self.inner.general_cv.notify_all();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +467,64 @@ mod tests {
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The depth warning fires on the step, and only on the step: a lane that
|
||||||
|
/// oscillates around the threshold must not log on every single job.
|
||||||
|
#[test]
|
||||||
|
fn queue_depth_warns_on_each_step() {
|
||||||
|
assert!(!crosses_warn_step(1));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP - 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP + 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP * 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown must return even while a job is running, and must not leave the
|
||||||
|
/// pool accepting work.
|
||||||
|
#[test]
|
||||||
|
fn stopping_is_bounded_and_closes_the_pool() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5)).expect("it runs");
|
||||||
|
|
||||||
|
pool.stop_and_join(Duration::from_secs(5));
|
||||||
|
|
||||||
|
// A job queued after the stop runs inline rather than disappearing.
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("a closed pool serves inline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A diagnostic is only useful if it names the work. A running job must show
|
||||||
|
/// up under its label, and the queue behind it must show up as depth.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_names_the_running_job_and_the_queue_behind_it() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release_rx = Arc::new(Mutex::new(release_rx));
|
||||||
|
let (started_tx, started_rx) = mpsc::channel();
|
||||||
|
{
|
||||||
|
let release_rx = release_rx.clone();
|
||||||
|
pool.run(Lane::Transfer, "read", move || {
|
||||||
|
started_tx.send(()).unwrap();
|
||||||
|
let _ = release_rx.lock().recv();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
started_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("the job starts");
|
||||||
|
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
assert!(
|
||||||
|
snapshot.workers.iter().any(|w| w.busy && w.label == "read"),
|
||||||
|
"the running job is named"
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.transfer_completed, 0);
|
||||||
|
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
/// Audit A6. Transfers filling every general worker must not delay a
|
/// Audit A6. Transfers filling every general worker must not delay a
|
||||||
/// metadata job: that is what the reserved threads are for.
|
/// metadata job: that is what the reserved threads are for.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -240,7 +540,7 @@ mod tests {
|
|||||||
for _ in 0..general {
|
for _ in 0..general {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
let started_tx = started_tx.clone();
|
let started_tx = started_tx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
started_tx.send(()).unwrap();
|
started_tx.send(()).unwrap();
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
@@ -255,13 +555,13 @@ mod tests {
|
|||||||
// in the way that used to starve metadata.
|
// in the way that used to starve metadata.
|
||||||
for _ in 0..16 {
|
for _ in 0..16 {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let (meta_tx, meta_rx) = mpsc::channel();
|
let (meta_tx, meta_rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || meta_tx.send(()).unwrap());
|
||||||
meta_rx
|
meta_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a metadata job runs while every transfer thread is stuck");
|
.expect("a metadata job runs while every transfer thread is stuck");
|
||||||
@@ -279,7 +579,7 @@ mod tests {
|
|||||||
// must have been served by the general worker.
|
// must have been served by the general worker.
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a general worker takes metadata when no transfer is waiting");
|
.expect("a general worker takes metadata when no transfer is waiting");
|
||||||
}
|
}
|
||||||
@@ -288,9 +588,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_panicking_job_does_not_retire_its_worker() {
|
fn a_panicking_job_does_not_retire_its_worker() {
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
pool.run(Lane::Meta, || panic!("boom"));
|
pool.run(Lane::Meta, "test", || panic!("boom"));
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("the pool still serves work after a panic");
|
.expect("the pool still serves work after a panic");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pdfs-tray"
|
name = "pdfs-tray"
|
||||||
@@ -24,10 +27,17 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ksni = "0.2"
|
ksni = { version = "0.3", features = ["blocking"] }
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.4"
|
webkit6 = "0.6"
|
||||||
|
# Town and street names on the Places map, with font fallback for every script.
|
||||||
|
pangocairo = "0.22"
|
||||||
|
# Map tiles for the opt-in online map. The same build proton-sdk already pulls in.
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls", "webpki-roots"] }
|
||||||
|
# The system libintl (glibc) rather than a bundled gettext build.
|
||||||
|
gettext-rs = { version = "0.7", features = ["gettext-system"] }
|
||||||
@@ -9,6 +9,8 @@ fn main() {
|
|||||||
&format!("{}/pdfs.gresource", out_dir),
|
&format!("{}/pdfs.gresource", out_dir),
|
||||||
"--sourcedir",
|
"--sourcedir",
|
||||||
"resources/icons",
|
"resources/icons",
|
||||||
|
"--sourcedir",
|
||||||
|
"resources",
|
||||||
"resources/pdfs.gresource.xml",
|
"resources/pdfs.gresource.xml",
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
@@ -22,7 +24,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
);
|
println!("cargo:rerun-if-changed=resources/proton-theme.css");
|
||||||
|
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
47.98,8.00 48.94,9.45 48.94,11.26
|
||||||
|
47.98,8.00 46.42,6.50 44.94,4.91 43.99,4.95 43.58,4.85 43.13,4.64 42.86,4.32 42.02,4.14 41.88,3.98
|
||||||
|
34.25,31.21 34.53,31.53 34.48,31.58
|
||||||
|
35.55,32.40 35.19,32.53 35.07,32.46 34.96,32.16 34.95,31.84 35.20,31.75 34.95,31.60 34.87,31.40 34.91,31.35 35.10,31.37 35.45,31.48
|
||||||
|
35.11,33.08 35.41,33.08 35.49,33.12 35.53,33.25 35.60,33.24
|
||||||
|
35.79,32.73 35.89,32.94 35.79,33.29 35.84,33.42
|
||||||
|
34.00,35.07 33.87,35.09 33.48,35.00 33.38,35.16 33.19,35.17 32.92,35.09 32.71,35.17
|
||||||
|
77.80,35.50 77.89,35.45 78.04,35.48 78.01,35.25 78.33,34.61 78.67,34.52 78.97,34.30 78.97,34.23 78.73,34.01 78.80,33.50 78.92,33.39
|
||||||
|
77.05,35.11 77.00,34.99 76.59,34.74 76.04,34.67 75.71,34.50 75.32,34.58
|
||||||
|
91.63,27.76 91.98,27.73 92.10,27.81 92.55,27.88 92.69,27.99 92.64,28.06 92.70,28.15 93.17,28.36 93.36,28.65 93.90,28.74 93.97,28.86 94.27,28.97 94.29,29.14 94.62,29.31 94.77,29.18 95.39,29.04 95.52,29.21 95.71,29.24 95.77,29.34 96.13,29.38 96.23,29.25 96.36,29.25 96.18,29.12 96.20,28.96 96.44,29.05 96.58,28.76 96.28,28.41 96.39,28.37 96.60,28.46 96.78,28.37 97.08,28.37 97.32,28.22
|
||||||
|
128.37,38.62 128.17,38.36 128.04,38.31 127.09,38.28 126.67,37.92 126.63,37.78
|
||||||
|
-8.68,27.66 -8.82,27.66 -8.75,27.19 -8.79,27.12 -9.41,27.09 -9.82,26.85 -10.03,26.91 -10.25,26.86 -10.76,27.02 -11.39,26.88 -11.32,26.74 -11.34,26.63 -11.51,26.47 -11.72,26.10 -12.06,25.99 -12.43,24.83 -12.99,24.47 -13.31,23.98 -13.89,23.69 -14.10,23.10 -14.22,22.31 -14.63,21.86 -14.61,21.75 -14.75,21.50 -14.97,21.44 -16.04,21.50 -17.00,21.42
|
||||||
|
35.92,4.62 35.76,4.81 35.80,5.16 35.75,5.34 35.47,5.42 35.33,5.36 35.27,5.49
|
||||||
|
-124.76,48.49 -123.86,48.26 -123.39,48.25 -123.14,48.40 -123.24,48.64 -123.01,48.79 -123.31,48.99
|
||||||
|
91.63,27.76 91.64,27.92 91.27,28.08 91.02,27.97 90.33,28.12
|
||||||
|
23.61,51.52 23.61,51.61 23.71,51.64 23.98,51.59 24.36,51.87 25.79,51.92 27.14,51.75 27.30,51.60 27.69,51.57 27.70,51.48 27.86,51.59 28.18,51.61 28.60,51.54 28.73,51.43 29.10,51.63 29.35,51.38 30.16,51.48 30.33,51.33 30.54,51.27 30.63,51.36 30.53,51.60 30.76,51.90 31.08,52.08 31.76,52.10
|
||||||
|
35.84,33.42 35.60,33.24
|
||||||
|
35.84,33.42 36.03,33.59 35.94,33.67 35.99,33.75 36.09,33.83 36.37,33.84 36.28,33.93 36.58,34.22 36.50,34.43 36.33,34.50 36.43,34.61 36.38,34.66 35.98,34.63
|
||||||
|
34.68,-12.01 34.76,-12.03 34.68,-12.12 34.68,-12.01
|
||||||
|
80.21,42.19 80.26,42.24 80.16,42.63 80.20,42.73 80.54,42.87 80.38,42.97 80.39,43.04 80.62,43.13 80.75,43.10 80.79,43.16 80.67,43.31 80.70,43.43 80.65,43.56 80.36,44.10 80.36,44.55 80.38,44.66 80.48,44.71 80.26,44.81 80.00,44.80 79.87,44.88 80.06,45.01 80.85,45.13 81.69,45.35 81.79,45.23 81.94,45.16 82.27,45.22 82.52,45.13 82.62,45.29 82.61,45.42 82.33,45.52 82.32,45.59 82.97,46.97 83.03,47.19 84.02,46.97 84.67,46.97 84.79,46.83 85.23,47.04 85.48,47.06 85.66,47.25 85.53,47.92 85.75,48.39 86.55,48.53 86.72,48.70 86.73,48.94 86.81,49.05 87.05,49.11 87.32,49.09
|
||||||
|
23.48,53.94 24.19,53.95 24.32,53.89 24.77,53.97 24.87,54.15 25.05,54.13 25.46,54.29 25.51,54.16 25.75,54.16 25.75,54.26 25.55,54.33 25.72,54.56 25.72,54.72 25.86,54.92 26.18,55.00 26.29,55.14 26.60,55.13 26.78,55.27 26.46,55.34 26.59,55.67
|
||||||
|
-53.37,-33.74 -53.53,-33.66 -53.53,-33.17 -53.13,-32.74 -53.16,-32.68 -53.60,-32.40 -53.76,-32.06 -54.22,-31.86 -54.59,-31.49 -54.90,-31.39 -55.04,-31.28 -55.17,-31.28 -55.56,-30.88 -55.63,-30.86 -55.81,-31.03
|
||||||
|
116.68,49.82 116.03,48.78 115.82,48.58 115.79,48.25 115.53,48.13 115.62,47.87 115.90,47.69 116.23,47.86 116.76,47.87 117.07,47.81 117.35,47.65 117.77,47.99 118.15,48.03 118.50,47.98 118.76,47.76 119.08,47.65 119.16,47.53 119.71,47.15 119.90,46.86 119.87,46.67 119.71,46.61 119.33,46.61 118.84,46.76 118.72,46.69 118.31,46.72 118.07,46.67 117.74,46.52 117.44,46.59 117.33,46.36 116.86,46.39 116.56,46.29 116.21,45.89 116.20,45.74 115.68,45.46 114.56,45.39 114.42,45.20 114.08,44.97 113.59,44.75 112.71,44.88 112.41,45.06 111.90,45.06 111.62,44.83 111.40,44.37 111.60,44.11 111.84,43.93 111.93,43.71 111.55,43.50 111.01,43.34 110.40,42.77 109.34,42.44 108.17,42.45 106.77,42.29 105.20,41.74 104.98,41.60 104.50,41.66 104.50,41.88 103.71,41.75 103.07,42.01 102.16,42.16 101.97,42.22 101.66,42.50 101.50,42.54 99.98,42.68 99.47,42.57 97.21,42.79 96.39,42.72 96.30,42.93 95.86,43.28 95.53,43.95 95.33,44.04 95.35,44.28 95.05,44.26 94.71,44.35 94.20,44.65 93.96,44.67 93.52,44.94 92.79,45.04 91.58,45.08 91.05,45.22 90.88,45.20 90.66,45.53 90.71,45.73 91.00,46.04 90.91,46.27 91.03,46.57 90.87,46.95 90.72,47.00 90.50,47.29 90.33,47.66 90.10,47.75 90.03,47.88 89.78,47.83 89.48,48.03 89.05,48.00 88.58,48.22 88.52,48.38 87.98,48.56 88.06,48.71 87.74,48.88 87.87,49.00 87.81,49.16
|
||||||
|
13.81,48.77 13.55,48.96 13.40,48.98 12.92,49.33 12.68,49.41 12.39,49.74 12.51,49.90 12.21,50.10 12.09,50.30 12.28,50.18 12.55,50.39 12.94,50.41 13.02,50.49 13.44,50.60 13.56,50.70 14.37,50.90 14.26,51.00 14.32,51.04 14.55,50.99 14.66,50.83 14.81,50.86
|
||||||
|
27.35,57.53 26.97,57.61 26.46,57.54 25.99,57.84 25.28,58.05 25.26,58.00 25.11,58.06 24.32,57.87
|
||||||
|
27.35,57.53 27.51,57.51 27.54,57.43 27.83,57.29 27.64,56.85 27.85,56.85 28.10,56.55 28.20,56.26 28.15,56.14
|
||||||
|
21.05,56.07 21.65,56.31 22.08,56.41 22.88,56.40 23.04,56.32 23.20,56.37 24.12,56.26 24.47,56.28 24.84,56.41 25.07,56.20 25.66,56.10 26.28,55.75 26.59,55.67
|
||||||
|
20.62,69.04 20.12,69.02 20.35,68.85 20.24,68.67 19.97,68.54 20.24,68.48 19.97,68.36 18.30,68.56 18.16,68.53 18.18,68.20 17.92,67.96 17.32,68.10 16.78,67.90 16.57,67.62 16.19,67.51 16.13,67.43 16.43,67.16 16.40,67.05 15.42,66.49 15.48,66.31 15.04,66.17 14.54,66.13 14.63,65.79 14.48,65.30 13.65,64.58 14.08,64.46 14.14,64.17 13.96,64.01 13.20,64.08 12.79,64.00 12.18,63.60 12.21,63.49 12.00,63.29 12.22,63.00 12.11,62.92 12.11,62.59 12.30,62.29 12.16,61.72 12.88,61.35 12.71,61.06 12.29,61.00 12.59,60.45 12.49,60.11 12.17,59.91 11.93,59.86 11.83,59.70 11.68,59.59 11.80,59.29 11.64,58.93 11.47,58.91 11.34,59.09 11.24,59.10 11.14,59.09 11.06,58.96 10.91,58.95
|
||||||
|
20.62,69.04 21.07,69.04 21.13,69.08 21.07,69.21 21.59,69.27 22.30,68.86 22.41,68.72 23.32,68.65 23.71,68.71 23.85,68.81 24.94,68.59 25.09,68.64 25.25,68.82 25.58,68.89 25.75,68.99 25.77,69.28 26.07,69.69 26.53,69.92 27.13,69.91 27.75,70.06 27.89,70.06 28.41,69.82 29.14,69.67 29.33,69.47 28.85,69.18 28.83,69.12 28.97,69.02
|
||||||
|
104.43,10.41 104.56,10.52 104.85,10.53 105.05,10.70 105.05,10.91 105.31,10.85 105.41,10.95 105.76,10.99 105.85,10.86 106.16,10.79 106.16,11.04 105.86,11.29 105.85,11.64 106.01,11.76 106.40,11.69 106.41,11.95 106.70,11.98 106.76,12.05 106.93,12.08 107.21,12.30 107.33,12.32 107.39,12.26 107.51,12.36 107.56,12.54 107.48,13.03 107.61,13.44 107.33,14.13 107.36,14.37 107.49,14.55 107.52,14.71
|
||||||
|
-69.51,-17.50 -69.31,-17.94 -69.09,-18.05 -69.15,-18.14 -68.97,-18.97 -68.46,-19.43 -68.70,-19.72 -68.56,-19.97 -68.60,-20.04 -68.76,-20.09 -68.69,-20.31 -68.76,-20.42 -68.48,-20.63 -68.56,-20.72 -68.56,-20.90 -68.44,-20.95 -68.20,-21.30 -68.19,-21.62 -68.08,-21.98 -67.99,-22.06 -67.88,-22.49 -67.88,-22.82 -67.58,-22.89 -67.19,-22.82
|
||||||
|
-69.51,-17.51 -69.85,-17.70 -69.80,-17.99 -69.93,-18.21 -70.18,-18.33 -70.42,-18.35
|
||||||
|
6.34,49.45 6.49,49.80 6.20,49.92 6.11,50.03 6.12,50.12
|
||||||
|
2.52,51.10 2.60,50.88 2.76,50.75 2.84,50.71 3.11,50.78 3.23,50.66 3.27,50.53 3.60,50.48 3.69,50.31 3.95,50.34 4.17,50.25 4.15,49.97 4.55,49.96 4.82,50.15 4.86,50.14 4.79,49.96 4.87,49.79 5.28,49.68 5.51,49.51 5.79,49.54
|
||||||
|
6.12,50.12 5.98,50.17 5.74,49.92 5.72,49.81 5.88,49.64 5.79,49.54
|
||||||
|
46.43,41.89 45.95,42.04 45.64,42.21 45.71,42.50 45.34,42.53 45.16,42.68 44.87,42.76 44.77,42.62 44.64,42.73 44.51,42.75 43.83,42.57 43.74,42.62 43.78,42.75 43.09,42.99 42.99,43.09 42.76,43.17 42.57,43.16 42.42,43.22 41.58,43.22 40.65,43.53 40.15,43.57 39.98,43.42
|
||||||
|
20.96,40.85 20.93,40.90 20.71,40.93 20.49,41.27 20.45,41.52 20.52,41.57 20.57,41.87
|
||||||
|
20.57,41.87 20.58,42.01 20.49,42.22 20.24,42.34 20.06,42.55
|
||||||
|
41.51,41.52 41.82,41.43 41.93,41.50 42.47,41.44 42.61,41.58 42.75,41.58 43.15,41.31 43.21,41.20 43.40,41.18 43.44,41.11
|
||||||
|
101.14,21.57 101.20,21.52 101.25,21.20 101.54,21.23 101.70,21.15 101.80,21.21 101.72,21.31 101.74,21.83 101.60,21.99 101.52,22.25 101.74,22.50 101.84,22.39 102.02,22.44 102.13,22.38
|
||||||
|
73.63,39.45 73.82,39.49 73.91,39.58 73.84,39.80 73.99,40.04 74.41,40.14 74.68,40.31 74.83,40.33 74.84,40.48 75.11,40.45 75.56,40.63 75.68,40.31 75.87,40.30 76.26,40.43 76.32,40.35 76.48,40.45 76.66,40.78 76.91,41.02 77.58,40.99 78.12,41.08 78.44,41.42 79.29,41.78 79.77,41.90 79.84,42.00 80.22,42.03 80.21,42.19
|
||||||
|
43.44,41.11 44.23,41.21 44.84,41.21 44.81,41.26 45.00,41.29
|
||||||
|
80.21,42.19 79.92,42.41 79.43,42.48 79.13,42.78 76.99,42.97 75.84,42.94 75.64,42.81 75.37,42.84 74.82,42.98 74.21,43.24 73.95,43.20 73.56,43.00 73.42,42.59 73.49,42.41 72.28,42.76 71.76,42.82 71.26,42.73 70.89,42.34 70.95,42.25
|
||||||
|
-56.48,1.94 -56.84,1.88 -57.12,2.01 -57.32,1.96 -57.55,1.73 -57.98,1.65 -58.03,1.52 -58.34,1.59 -58.40,1.48 -58.51,1.44 -58.51,1.28 -58.68,1.28 -58.82,1.20 -59.23,1.38 -59.54,1.70 -59.67,1.75 -59.67,1.84 -59.76,1.90 -59.76,2.27 -59.89,2.36 -59.99,2.69 -59.97,2.99 -59.83,3.35 -59.85,3.59 -59.68,3.70 -59.55,3.93 -59.74,4.23 -59.70,4.38 -59.83,4.48 -60.15,4.53 -60.03,4.74 -59.99,5.08 -60.14,5.24 -60.74,5.20
|
||||||
|
8.39,55.10 8.59,54.92 9.25,54.81 9.74,54.83
|
||||||
|
9.52,30.23 9.90,30.39 10.22,30.78 10.26,30.94 10.11,31.46 10.27,31.68 10.48,31.74 10.61,31.93 10.83,32.08 11.51,32.41 11.53,32.52 11.45,32.64 11.50,33.18
|
||||||
|
45.00,41.29 45.19,41.15 45.07,41.08 45.42,40.99 45.59,40.85 45.38,40.67 45.45,40.53 45.57,40.42 45.96,40.23 45.89,40.02 45.58,39.98 46.20,39.59 46.48,39.56 46.48,39.48 46.37,39.40 46.58,39.22 46.40,39.19 46.49,39.07 46.49,38.91
|
||||||
|
44.77,39.70 45.03,39.77 45.17,39.57 45.25,39.60 45.46,39.49 45.78,39.55 45.77,39.38 45.98,39.24 45.95,39.18 46.11,38.88
|
||||||
|
20.24,46.11 20.51,46.17 20.61,46.13 20.76,46.25 21.12,46.28 21.26,46.41 21.32,46.61 21.50,46.70 21.66,47.04 22.00,47.40 22.00,47.51 22.29,47.73 22.61,47.77 22.88,47.95
|
||||||
|
70.96,40.24 71.30,40.29 71.63,40.22 71.69,40.15 71.96,40.26 72.13,40.44 72.36,40.40 72.40,40.58 72.60,40.53 73.14,40.81 72.66,40.87 72.36,41.04 72.19,41.03 72.16,41.17 71.88,41.20 71.86,41.31 71.66,41.54 71.60,41.50 71.59,41.33 71.42,41.34 71.39,41.12 70.86,41.22 70.69,41.45 70.47,41.41 70.20,41.51 70.18,41.57 70.63,41.88 70.80,41.92 70.86,42.03 71.23,42.16 71.04,42.28 70.95,42.25
|
||||||
|
18.83,49.51 18.94,49.50 18.97,49.40 19.15,49.40 19.44,49.60 19.63,49.41 19.77,49.37 19.76,49.20 20.06,49.18 20.16,49.32 20.36,49.39 20.62,49.39 20.87,49.31 21.08,49.42 21.64,49.41 21.89,49.34 22.02,49.21 22.54,49.07
|
||||||
|
-7.22,55.09 -7.38,55.03 -7.55,54.77 -7.91,54.70 -7.75,54.59 -8.12,54.48 -8.12,54.41 -7.85,54.22 -7.61,54.14 -7.32,54.13 -7.16,54.24 -7.20,54.30 -7.01,54.41 -6.80,54.21 -6.65,54.16 -6.65,54.06 -6.22,54.09
|
||||||
|
18.83,49.51 18.81,49.61 18.59,49.76 18.56,49.88 18.03,50.04 17.87,49.97 17.63,50.12 17.59,50.16 17.74,50.23 17.70,50.31 17.42,50.25 16.88,50.43 16.99,50.24 16.64,50.10 16.21,50.42 16.42,50.57 16.28,50.66 16.01,50.61 15.73,50.74 15.35,50.81 15.26,50.96 14.99,51.01 14.98,50.89 14.81,50.86
|
||||||
|
70.96,40.24 70.95,40.19 70.64,40.08 70.62,40.00 70.52,39.95 70.45,40.05 69.97,40.20 69.53,40.10 69.48,39.92 69.31,39.97 69.28,39.92 69.23,39.76 69.30,39.52 70.50,39.59 70.80,39.39 71.00,39.41 71.07,39.49 71.47,39.60 71.50,39.48 71.73,39.42 71.73,39.31 71.78,39.28 72.04,39.35 72.23,39.21 72.36,39.34 72.56,39.38 73.11,39.36 73.63,39.45
|
||||||
|
20.00,39.71 20.25,39.68 20.31,39.80 20.38,39.80 20.31,39.98 20.66,40.12 20.81,40.45 20.95,40.49 21.03,40.62 20.96,40.85
|
||||||
|
33.20,-14.01 33.15,-13.94 32.99,-14.02 32.87,-13.82 32.77,-13.76 32.80,-13.69 32.67,-13.59 32.85,-13.46 32.97,-13.22 33.00,-12.90 32.95,-12.80 33.02,-12.63 33.40,-12.49 33.51,-12.35 33.34,-12.31 33.25,-12.11 33.30,-11.69 33.23,-11.42 33.38,-11.16 33.26,-10.89 33.46,-10.78 33.66,-10.55 33.53,-10.23 33.31,-10.04 33.35,-9.86 33.20,-9.63 33.00,-9.62 32.92,-9.41
|
||||||
|
22.13,48.41 22.14,48.57 22.30,48.69 22.54,49.07
|
||||||
|
-11.51,6.91 -11.27,7.23 -10.65,7.76 -10.57,8.07 -10.36,8.19 -10.28,8.49
|
||||||
|
24.15,8.67 24.22,8.61 24.21,8.37 24.29,8.29 24.85,8.14 25.20,7.81 25.25,7.72 25.18,7.56 25.28,7.43 25.89,7.06 26.36,6.64 26.31,6.46 26.51,6.07 27.14,5.72 27.33,5.19
|
||||||
|
42.92,11.00 43.25,11.50
|
||||||
|
42.92,11.00 42.56,11.08 41.96,10.94 41.80,10.98 41.79,11.69 42.38,12.47
|
||||||
|
29.40,-4.45 29.32,-4.90 29.61,-5.72 29.48,-6.03 29.54,-6.31 29.71,-6.62 30.21,-7.04 30.75,-8.19
|
||||||
|
42.38,12.47 42.48,12.51 42.70,12.38 42.87,12.62 43.12,12.71
|
||||||
|
13.81,48.77 14.05,48.60 14.69,48.60 14.79,48.75 14.92,48.77 14.97,48.98 15.07,49.00 15.83,48.86 16.06,48.75 16.37,48.74 16.54,48.80 16.88,48.70 16.95,48.60
|
||||||
|
42.36,37.11 42.77,37.37 42.94,37.32 43.09,37.37 43.68,37.23 44.11,37.30 44.19,37.25 44.20,37.05 44.28,36.98 44.61,37.18 44.77,37.14
|
||||||
|
16.09,46.86 16.25,46.97 16.45,47.01 16.49,47.12 16.42,47.22 16.44,47.40 16.62,47.45 16.68,47.54 16.64,47.61 16.42,47.67 16.59,47.75 16.79,47.68 17.07,47.71 17.03,47.84 17.15,48.01
|
||||||
|
7.02,45.93 7.13,45.88 7.54,45.98 7.79,45.92 7.99,46.02 8.13,46.16 8.10,46.27 8.42,46.45 8.46,46.25 8.64,46.11 8.82,46.08 8.78,46.00 8.95,45.83 9.02,45.85 9.00,46.01 9.25,46.29 9.26,46.48 9.43,46.48 9.53,46.31 9.94,46.36 10.04,46.24 10.13,46.24 10.04,46.48 10.09,46.60 10.43,46.55 10.40,46.67 10.45,46.86
|
||||||
|
10.45,46.86 10.35,46.98 10.13,46.85 9.88,46.94 9.85,47.01 9.58,47.06
|
||||||
|
44.77,37.14 44.79,37.29 44.57,37.44 44.55,37.66 44.59,37.71 44.21,37.91 44.45,38.33 44.30,38.39 44.27,38.84 44.17,38.93 44.18,39.14 44.08,39.22 44.02,39.38 44.39,39.42 44.46,39.67 44.59,39.77 44.82,39.65
|
||||||
|
23.48,53.94 23.45,54.14 23.37,54.20 22.89,54.39 22.77,54.36
|
||||||
|
30.75,-8.19 30.89,-8.47 31.03,-8.60 31.35,-8.61 31.53,-8.71 31.67,-8.91 31.92,-8.94 31.94,-9.05 32.43,-9.16 32.92,-9.41
|
||||||
|
7.20,53.28 7.19,53.00 7.03,52.65 6.71,52.62 6.75,52.46 6.97,52.44 7.04,52.38 6.98,52.21 6.72,52.08 6.80,51.97 6.52,51.85 6.36,51.82 6.17,51.88 5.95,51.80 6.20,51.45 6.08,51.22 6.13,51.15 5.86,51.03 6.05,50.90 5.99,50.75
|
||||||
|
9.58,47.06 9.61,47.11 9.53,47.27
|
||||||
|
-8.49,7.56 -8.43,7.60 -8.23,7.56 -8.01,8.08 -8.05,8.17 -8.26,8.25 -8.24,8.46 -8.05,8.50 -7.82,8.47 -7.74,8.38 -7.68,8.41 -7.72,8.64 -7.95,8.79 -7.94,8.98 -7.78,9.08 -7.92,9.19 -7.90,9.42 -8.03,9.40 -8.14,9.50 -8.16,9.97 -7.99,10.16
|
||||||
|
7.49,43.77 7.48,43.86 7.68,44.08 7.64,44.16 7.32,44.14 6.90,44.34 6.84,44.51 7.03,44.72 6.99,44.83 6.74,44.92 6.63,45.07 6.69,45.14 6.84,45.14 7.08,45.24 7.15,45.38 6.79,45.74 6.80,45.81 7.02,45.93
|
||||||
|
28.97,69.02 28.41,68.90 28.77,68.84 28.48,68.54 28.56,68.35 28.69,68.19 29.34,68.06 29.99,67.67 29.94,67.55 29.24,67.10 29.07,66.89 29.90,66.09 30.09,65.79 30.10,65.68 29.72,65.62 29.82,65.57 29.73,65.47 29.71,65.34 29.61,65.25 29.81,65.20 29.83,65.15 29.62,65.04 29.60,64.97 29.78,64.80 30.11,64.73 30.12,64.64 29.99,64.52 30.11,64.37 30.49,64.24 30.53,64.08 30.21,63.80 29.99,63.74 31.18,63.21 31.53,62.89 31.19,62.48 29.25,61.29 28.41,60.90 27.73,60.46
|
||||||
|
30.87,69.78 30.92,69.61 30.86,69.54 30.62,69.53 30.18,69.64 30.19,69.54 30.09,69.43 29.39,69.30 29.21,69.10 28.97,69.02
|
||||||
|
55.19,22.70 55.20,23.03 55.53,23.82 55.47,23.94 55.99,24.06 55.93,24.22 55.76,24.24 55.80,24.87 55.92,24.97 56.00,24.95 55.97,24.86 56.06,24.74 56.39,24.98
|
||||||
|
48.57,41.84 48.39,41.60 48.06,41.46 47.79,41.20 47.32,41.28 47.14,41.52 46.75,41.81 46.57,41.80 46.54,41.87 46.43,41.89
|
||||||
|
-54.62,2.33 -54.72,2.44 -54.88,2.45 -54.98,2.60 -55.39,2.44 -55.73,2.41 -55.96,2.52 -56.14,2.26 -55.92,2.04 -55.93,1.89 -56.02,1.84 -56.48,1.94
|
||||||
|
46.43,41.89 46.20,41.74 46.18,41.66 46.31,41.51 46.67,41.29 46.53,41.09 46.43,41.08 46.17,41.20 45.92,41.19 45.73,41.26 45.72,41.34 45.28,41.45 45.00,41.29
|
||||||
|
-11.39,12.40 -11.45,12.56 -11.39,12.94 -11.63,13.37 -11.76,13.39 -11.83,13.32 -12.05,13.63 -11.96,13.88 -12.02,13.97 -12.02,14.21 -12.23,14.46 -12.19,14.65 -12.28,14.81
|
||||||
|
-54.62,2.33 -54.13,2.12 -53.77,2.35 -53.51,2.25 -53.33,2.34 -53.23,2.20 -52.90,2.21 -52.58,2.53 -52.33,3.18 -51.99,3.70 -51.65,4.06
|
||||||
|
2.71,6.37 2.77,6.71 2.72,6.98 2.79,7.48 2.69,7.87 2.77,9.05 3.04,9.08 3.15,9.32 3.14,9.45 3.33,9.67 3.35,9.81 3.56,9.91 3.65,10.16 3.58,10.29 3.65,10.41 3.77,10.42 3.83,10.61 3.72,11.08 3.49,11.40 3.60,11.70
|
||||||
|
13.07,-4.63 12.80,-4.43 12.38,-4.62 12.31,-4.77 12.20,-4.78 12.02,-5.00
|
||||||
|
16.52,46.50 16.32,46.53 16.24,46.48 16.23,46.37 16.07,46.37 15.64,46.20 15.59,46.14 15.67,46.05 15.65,45.86 15.28,45.73 15.35,45.66 15.28,45.58 15.34,45.47 14.79,45.48 14.57,45.66 14.37,45.48 13.99,45.51 13.88,45.43 13.58,45.52
|
||||||
|
18.91,45.93 18.67,45.91 18.53,45.80 18.36,45.75 17.81,45.79 17.31,46.00 16.87,46.34 16.52,46.50
|
||||||
|
20.24,46.11 19.61,46.17 19.28,46.00 19.09,46.02 18.91,45.93
|
||||||
|
16.52,46.50 16.28,46.86 16.09,46.86
|
||||||
|
51.27,24.61 50.97,24.57 50.80,24.79
|
||||||
|
25.26,-17.79 25.24,-17.97 25.94,-18.94 25.95,-19.08 26.17,-19.54 26.68,-19.89 27.18,-20.10 27.26,-20.23 27.28,-20.48 27.68,-20.50 27.67,-21.06 28.01,-21.55 29.03,-21.80 29.04,-22.02 29.36,-22.19
|
||||||
|
77.80,35.50 77.45,35.48 76.77,35.66
|
||||||
|
28.01,41.97 27.53,41.92 27.24,42.09 27.01,42.06 26.58,41.95 26.51,41.83 26.36,41.80 26.32,41.72
|
||||||
|
102.93,11.71 102.74,12.09 102.76,12.43 102.50,12.67 102.46,13.02 102.33,13.29 102.34,13.56 102.55,13.59 102.54,13.66 102.91,14.14 103.20,14.33 103.60,14.42 103.98,14.36 104.78,14.43 104.97,14.37 105.00,14.25 105.07,14.23 105.18,14.35
|
||||||
|
88.89,27.32 88.74,27.18 88.86,26.96 89.15,26.82 89.33,26.85 89.55,26.80 89.61,26.72 90.12,26.75 90.35,26.89 90.74,26.77 91.29,26.79 91.43,26.87 91.67,26.80 92.05,26.87 92.07,26.98 91.99,27.10 92.08,27.29 91.99,27.45 91.74,27.44 91.59,27.56 91.63,27.76
|
||||||
|
104.07,1.28 104.06,1.40 103.82,1.46 103.70,1.44 103.60,1.21
|
||||||
|
100.12,6.44 100.18,6.67 100.26,6.68 100.35,6.55 100.75,6.46 100.87,6.25 101.05,6.24 101.08,5.96 100.98,5.77 101.11,5.64 101.26,5.79 101.56,5.91 101.68,5.78 101.87,5.83 102.10,6.24
|
||||||
|
5.99,50.75 6.24,50.60 6.17,50.55 6.20,50.50 6.34,50.45 6.36,50.32 6.18,50.23 6.12,50.12
|
||||||
|
-71.78,19.72 -71.71,19.49 -71.75,19.29 -71.65,19.16 -71.81,18.99 -71.74,18.73 -71.87,18.61 -72.00,18.60 -71.76,18.34 -71.77,18.04
|
||||||
|
14.06,13.08 13.61,13.70
|
||||||
|
46.53,29.10 46.91,29.54 47.15,30.00 47.33,30.08 47.64,30.10 47.98,29.98
|
||||||
|
88.11,27.87 88.14,27.95 88.58,28.09 88.80,28.01 88.85,27.87 88.75,27.52 88.89,27.32
|
||||||
|
-90.10,13.74 -90.05,13.90 -89.55,14.24 -89.57,14.39 -89.36,14.42
|
||||||
|
124.04,-9.34 124.09,-9.42 124.28,-9.43 124.41,-9.31 124.44,-9.19
|
||||||
|
124.92,-8.94 124.94,-9.05 125.10,-9.00 125.15,-9.04 125.10,-9.19 124.96,-9.21 125.07,-9.51
|
||||||
|
115.14,4.90 115.27,4.63 115.29,4.35 115.11,4.39 115.03,4.90
|
||||||
|
109.63,2.03 109.54,1.90 109.64,1.78 109.65,1.61 110.51,0.86 111.10,1.05 111.81,1.01 111.92,1.11 112.08,1.14 112.19,1.44 112.48,1.56 112.94,1.57 113.01,1.43 113.62,1.24 113.90,1.43 114.39,1.50 114.51,1.45 114.70,1.85 114.83,1.98 114.76,2.16 114.79,2.25 115.18,2.52 115.08,2.63 115.12,2.89 115.25,3.03 115.45,3.03 115.57,3.45 115.57,3.94 115.68,4.19 115.84,4.33 116.02,4.29 116.24,4.36 116.41,4.31 116.51,4.37 117.10,4.34 117.54,4.17 117.88,4.19
|
||||||
|
10.45,46.86 10.99,46.78 11.24,46.98 11.78,46.99 12.17,47.08 12.15,46.94 12.39,46.70 13.70,46.52
|
||||||
|
13.70,46.52 13.38,46.26 13.63,46.18 13.48,46.01 13.61,45.96 13.58,45.81 13.87,45.61 13.72,45.59
|
||||||
|
29.01,-2.72 29.10,-2.60 29.30,-2.67 29.39,-2.81 29.70,-2.79 29.87,-2.72 29.93,-2.34 30.12,-2.42 30.41,-2.31 30.55,-2.40
|
||||||
|
92.57,21.98 92.63,21.31 92.57,21.26 92.33,21.44 92.18,21.29 92.32,20.79
|
||||||
|
1.43,42.60 1.29,42.71 0.70,42.85 0.63,42.69 -0.04,42.69 -0.30,42.83 -0.59,42.80 -0.76,42.94 -1.18,43.02 -1.30,43.10 -1.46,43.05 -1.41,43.24 -1.71,43.31 -1.79,43.41
|
||||||
|
1.43,42.60 1.50,42.64 1.71,42.60 1.71,42.50
|
||||||
|
1.71,42.50 2.03,42.35 2.20,42.42 2.65,42.34 2.89,42.46 3.21,42.43
|
||||||
|
1.43,42.60 1.45,42.44 1.71,42.50
|
||||||
|
74.89,37.23 75.12,37.39 74.89,37.60 74.94,37.77 74.78,38.19 74.81,38.46 74.28,38.66 74.13,38.66 74.03,38.54 73.80,38.61 73.70,38.85 73.81,38.97 73.61,39.23 73.63,39.45
|
||||||
|
74.89,37.23 74.73,37.29 74.37,37.16 74.54,37.02
|
||||||
|
20.96,40.85 21.40,40.91 21.58,40.87 21.99,41.13 22.60,41.14 22.72,41.18 22.78,41.33 22.92,41.34
|
||||||
|
21.56,42.25 21.39,42.22 21.29,42.10 21.06,42.17 20.78,42.07 20.72,41.87 20.57,41.87
|
||||||
|
22.34,42.31 22.24,42.36 21.56,42.25
|
||||||
|
22.34,42.31 22.52,42.44 22.44,42.63 22.47,42.84 22.71,42.88 22.98,43.19 22.55,43.45 22.37,43.78 22.42,44.01 22.60,44.08 22.63,44.19 22.71,44.24
|
||||||
|
22.92,41.34 23.00,41.74 22.84,41.99 22.58,42.10 22.34,42.31
|
||||||
|
18.44,42.56 18.52,42.43
|
||||||
|
18.44,42.56 17.80,42.90 17.67,42.90
|
||||||
|
78.92,33.39 79.12,33.23
|
||||||
|
20.34,42.83 20.47,42.86 20.48,42.95 20.62,43.03 20.62,43.20 20.80,43.26 20.84,43.17 21.06,43.09 21.40,42.83 21.39,42.75 21.75,42.67 21.61,42.39 21.52,42.33 21.56,42.25
|
||||||
|
20.34,42.83 20.05,42.76 20.06,42.55
|
||||||
|
19.19,43.53 19.22,43.45 19.61,43.17 20.27,42.94 20.34,42.83
|
||||||
|
-89.36,14.42 -89.12,14.37 -88.71,14.03 -88.51,13.98 -88.48,13.85 -88.15,13.99 -87.99,13.88 -87.80,13.89 -87.72,13.81 -87.78,13.52 -87.74,13.45 -87.81,13.40
|
||||||
|
-87.34,12.98 -87.01,13.01 -86.92,13.22 -86.73,13.28 -86.76,13.75 -86.33,13.77 -86.04,14.05 -85.98,13.97 -85.73,13.86 -85.68,13.98 -85.18,14.34 -85.16,14.53 -85.04,14.61 -84.99,14.75 -84.86,14.81 -84.65,14.66 -84.45,14.64 -84.24,14.75 -84.15,14.72 -83.64,14.88 -83.54,14.98 -83.16,14.99
|
||||||
|
-75.28,-0.11 -75.78,0.09 -76.07,0.35 -76.27,0.44 -76.39,0.40 -76.43,0.26 -76.49,0.24 -76.83,0.25 -77.40,0.39 -77.47,0.64 -77.65,0.72 -77.70,0.84 -77.83,0.83 -78.18,0.97 -78.86,1.46
|
||||||
|
-57.60,3.37 -57.65,3.52 -57.83,3.68 -58.03,4.00 -58.05,4.17 -57.85,4.67 -57.92,4.82 -57.88,4.88 -57.71,4.99 -57.33,5.02 -57.21,5.20 -57.32,5.34 -57.19,5.55
|
||||||
|
-54.08,3.33 -53.99,3.59 -54.35,4.05 -54.48,4.84 -54.45,5.01 -54.16,5.36
|
||||||
|
-58.41,-33.94 -58.48,-33.65 -58.38,-33.14 -58.24,-33.11 -58.13,-32.98 -58.20,-32.47
|
||||||
|
-57.61,-30.19 -57.87,-30.59 -57.81,-30.86 -57.90,-30.98 -57.89,-31.20 -58.05,-31.49 -57.99,-31.58 -58.01,-31.68 -58.19,-31.92 -58.12,-32.25 -58.20,-32.47
|
||||||
|
-54.62,-25.58 -54.44,-25.62 -54.15,-25.52 -53.89,-25.67 -53.67,-26.29 -53.75,-26.75 -53.72,-26.88 -53.84,-27.12 -54.16,-27.25 -54.33,-27.42 -54.83,-27.55 -54.91,-27.71 -55.10,-27.87 -55.73,-28.20 -55.69,-28.38 -55.89,-28.37 -56.94,-29.59 -57.22,-29.78 -57.41,-30.03 -57.61,-30.19
|
||||||
|
20.62,69.04 20.90,68.98 20.92,68.91 22.00,68.52 22.85,68.37 23.10,68.26 23.18,68.14 23.32,68.13 23.64,67.95 23.50,67.88 23.54,67.61 23.45,67.46 23.73,67.42 23.77,67.33 23.63,67.23 23.64,67.13 23.99,66.81 23.67,66.38 23.72,66.22 23.91,66.15 24.16,65.81 24.19,65.61 24.12,65.51
|
||||||
|
4.23,51.39 4.17,51.31 3.90,51.21 3.58,51.29 3.43,51.25 3.35,51.38
|
||||||
|
5.99,50.75 5.75,50.76 5.64,50.84 5.75,50.95 5.80,51.15 5.48,51.29 5.21,51.28 5.03,51.47 4.85,51.40 4.76,51.49 4.63,51.42 4.50,51.47 4.38,51.43 4.37,51.36 4.23,51.39
|
||||||
|
6.34,49.45 6.53,49.39 6.74,49.16 6.89,49.21 7.04,49.11 7.45,49.15 7.61,49.06 8.13,48.97 8.14,48.89 7.84,48.64 7.62,48.16 7.53,47.67 7.62,47.59
|
||||||
|
9.52,47.52 9.18,47.67 8.88,47.66 8.57,47.78 8.40,47.69 8.57,47.65 8.45,47.60 7.62,47.59
|
||||||
|
9.53,47.27 9.63,47.47 9.52,47.52
|
||||||
|
9.58,47.06 9.49,47.06 9.53,47.27
|
||||||
|
7.02,45.93 6.77,46.17 6.82,46.28 6.76,46.42 6.43,46.43 6.23,46.33 6.27,46.25 6.20,46.19 6.01,46.14 5.97,46.21 6.10,46.28 6.12,46.38 6.07,46.46 6.16,46.61 6.41,46.76 6.46,46.95 6.67,47.03 7.00,47.32 6.90,47.39 6.97,47.45 7.14,47.49 7.34,47.43 7.62,47.59
|
||||||
|
-7.41,37.18 -7.50,37.59 -7.44,37.73 -7.19,38.01 -7.02,38.04 -6.96,38.19 -7.11,38.18 -7.34,38.46 -7.28,38.71 -7.05,38.91 -7.00,39.06 -7.17,39.14 -7.34,39.47 -7.54,39.66 -7.12,39.68 -6.98,39.80 -6.90,40.02 -7.03,40.17 -6.81,40.34 -6.84,40.78 -6.93,41.01 -6.21,41.53 -6.31,41.64 -6.54,41.67 -6.62,41.94 -7.15,41.98 -7.21,41.90 -7.40,41.83 -7.92,41.88 -8.15,41.81 -8.22,41.90 -8.13,42.02 -8.27,42.14 -8.78,41.94
|
||||||
|
13.81,48.77 13.79,48.59 13.72,48.54 13.49,48.58 13.37,48.36 12.76,48.11 12.95,47.89 12.90,47.72 13.05,47.66 13.01,47.48 12.81,47.54 12.77,47.64 12.69,47.67 12.48,47.64 12.21,47.72 12.19,47.62 11.72,47.58 11.04,47.39 10.87,47.52 10.44,47.55 10.37,47.37 10.18,47.28 10.20,47.36 10.07,47.39 9.97,47.51 9.75,47.58 9.52,47.52
|
||||||
|
16.09,46.86 15.98,46.80 15.96,46.68 15.77,46.71 15.44,46.63 14.89,46.61 14.55,46.40 13.70,46.52
|
||||||
|
18.44,42.56 18.55,42.64 18.47,42.78 18.46,43.00 18.62,43.03 18.67,43.23 18.85,43.35 19.03,43.29 18.95,43.53 19.19,43.53
|
||||||
|
19.19,43.53 19.30,43.59 19.45,43.56 19.50,43.64 19.25,43.97 19.58,44.01 19.12,44.36 19.35,44.88 18.84,44.88 18.66,45.08 18.00,45.14 17.81,45.08 17.65,45.16 17.50,45.12 17.13,45.17 16.92,45.28 16.79,45.20 16.53,45.22 16.29,45.01 16.03,45.19 15.79,45.18 15.74,44.77 16.10,44.52 16.30,44.12 17.08,43.52 17.27,43.45 17.29,43.31 17.65,43.01 17.59,42.94
|
||||||
|
19.01,44.87 19.10,44.97 19.06,45.14 19.14,45.20 19.40,45.21 19.00,45.40 19.06,45.52 18.92,45.60 18.95,45.66 18.84,45.84 18.91,45.93
|
||||||
|
16.95,48.60 17.14,48.84 17.76,48.89 18.09,49.07 18.16,49.26 18.60,49.49 18.83,49.51
|
||||||
|
17.15,48.01 16.87,48.39 16.95,48.60
|
||||||
|
17.15,48.01 17.32,47.99 17.76,47.77 18.72,47.79 18.79,48.00 19.47,48.11 19.63,48.22 19.95,48.15 20.33,48.30 20.49,48.53 21.45,48.55 21.72,48.35 22.13,48.41
|
||||||
|
14.81,50.86 15.02,51.25 14.91,51.46 14.72,51.52 14.74,51.63 14.60,51.83 14.75,52.08 14.55,52.36 14.62,52.53 14.13,52.88 14.41,53.22 14.21,53.95
|
||||||
|
22.88,47.95 22.77,48.11 22.58,48.13 22.35,48.26 22.25,48.41 22.13,48.41
|
||||||
|
28.59,43.74 28.22,43.77 28.05,43.82 27.88,43.99 27.74,43.96 27.43,44.02 27.09,44.17 26.22,44.01 25.82,43.77 25.50,43.67 23.22,43.87 22.92,43.83 22.87,43.95 23.03,44.08 22.71,44.24
|
||||||
|
20.24,46.11 20.71,45.74 20.77,45.75 20.79,45.47 21.49,45.15 21.35,45.01 21.53,44.90 21.36,44.83
|
||||||
|
22.92,41.34 23.64,41.39 24.01,41.46 24.06,41.53 24.49,41.56 24.77,41.36 24.99,41.36 25.25,41.24 25.92,41.31 26.14,41.39 26.07,41.67 26.11,41.73 26.41,41.70 26.58,41.60 26.62,41.40 26.33,41.24 26.33,40.95 26.04,40.73
|
||||||
|
20.06,42.55 19.79,42.48 19.73,42.63 19.65,42.63 19.28,42.17 19.36,42.07 19.34,41.87
|
||||||
|
26.62,48.26 26.79,48.26 26.98,48.16 27.61,47.34 28.07,46.98 28.24,46.64 28.24,46.45 28.10,45.97 28.16,45.65 28.07,45.60 28.21,45.45
|
||||||
|
22.88,47.95 23.20,48.08 23.41,47.99 24.18,47.91 24.58,47.93 24.89,47.72 24.98,47.72 25.46,47.91 26.16,47.99 26.31,48.20 26.62,48.26
|
||||||
|
22.54,49.07 22.81,49.02 22.85,49.06 22.71,49.17 22.73,49.30 22.65,49.54 22.71,49.61 23.71,50.38 23.97,50.41 24.09,50.53 23.98,50.79 24.10,50.87 23.66,51.31 23.61,51.52
|
||||||
|
20.90,55.29 21.39,55.28 22.07,55.06 22.57,55.06 22.82,54.87 22.68,54.56 22.77,54.36
|
||||||
|
31.76,52.10 31.58,52.31 31.62,52.55 31.52,52.70 31.56,52.76 31.26,53.02 31.42,53.20 31.67,53.20 31.85,53.11 32.14,53.09 32.70,53.34 32.71,53.42 32.47,53.55 32.45,53.69 32.20,53.78 31.75,53.81 31.83,54.03 31.40,54.20 31.18,54.45 31.07,54.49 31.15,54.63 30.80,54.78 30.83,54.91 30.98,55.05 30.81,55.31 30.90,55.40 30.91,55.57 30.23,55.85 29.94,55.85 29.48,55.68 29.35,55.78 29.38,55.94 29.03,56.02 28.79,55.94 28.56,56.09 28.28,56.06 28.15,56.14
|
||||||
|
31.76,52.10 32.12,52.05 32.28,52.11 32.44,52.31 32.81,52.25 33.15,52.34 33.74,52.34 33.92,52.25 34.11,51.98 34.40,51.78 34.38,51.72 34.12,51.68 34.27,51.34 34.21,51.26 34.71,51.17 35.06,51.20 35.16,51.06 35.31,51.04 35.44,50.73 35.41,50.54 35.59,50.37 35.67,50.35 35.89,50.44 36.12,50.41 36.31,50.28 36.50,50.28 36.62,50.21 36.76,50.29 37.42,50.41 37.70,50.11 38.05,49.92 38.26,50.05 38.92,49.82 39.17,49.86 39.30,49.74 39.46,49.73 39.78,49.57 40.08,49.58 40.11,49.25 39.89,49.06 39.69,49.01 39.75,48.91 40.00,48.82 39.79,48.81 39.64,48.59 39.84,48.54 39.89,48.36 39.85,48.30 39.96,48.27 39.78,47.89 39.66,47.84 38.90,47.86 38.64,47.67 38.37,47.61 38.29,47.56 38.20,47.32 38.28,47.28 38.20,47.18 38.21,47.09
|
||||||
|
43.44,41.11 43.63,40.93 43.72,40.72 43.57,40.48 43.71,40.17 43.67,40.13 44.01,40.01 44.29,40.04 44.78,39.68
|
||||||
|
87.82,49.16 88.12,49.26 88.19,49.45 88.83,49.45 88.90,49.54 89.01,49.47 89.11,49.50 89.24,49.63 89.40,49.61 89.65,49.72 89.64,49.90 89.98,49.98 90.05,50.09 90.66,50.22 91.02,50.42 91.42,50.47 91.80,50.69 92.19,50.70 92.35,50.86 92.63,50.69 92.78,50.78 92.94,50.78 93.10,50.60 94.25,50.56 94.35,50.22 94.61,50.02 94.93,50.04 95.11,49.94 95.52,49.91 95.79,50.01 95.94,49.96 96.07,50.00 96.32,49.90 96.99,49.88 97.21,49.73 97.36,49.74 97.59,49.91 98.00,50.01 98.25,50.30 98.28,50.53 98.03,50.64 97.84,51.05 98.04,51.45 98.22,51.51 98.35,51.72 98.64,51.80 98.89,52.12 99.72,51.87 99.92,51.76 100.47,51.73 101.38,51.45 102.11,51.35 102.22,50.83 102.32,50.72 102.29,50.59 102.68,50.39 103.16,50.29 103.30,50.20 103.63,50.14 104.08,50.15 104.47,50.31 105.38,50.47 106.22,50.30 106.71,50.31 107.23,49.99 107.92,49.95 107.97,49.65 108.61,49.32 109.24,49.33 110.71,49.14 111.34,49.36 112.08,49.42 112.49,49.53 112.81,49.52 113.06,49.62 113.16,49.80 113.57,50.01 114.30,50.27 114.74,50.23 115.43,49.90 115.72,49.88 116.22,50.01 116.55,49.92 116.68,49.82
|
||||||
|
116.68,49.82 117.25,49.62 117.87,49.51 118.45,49.84 119.26,50.07 119.35,50.28 119.16,50.41 119.45,50.70 120.07,51.60 120.51,51.85 120.75,52.10 120.67,52.30 120.70,52.49 120.66,52.57 120.17,52.60 120.07,52.63 120.04,52.72 120.70,53.17 120.99,53.28 122.34,53.49 123.61,53.55 124.81,53.13 125.08,53.20 125.65,53.04 125.73,52.89 125.87,52.87 126.05,52.74 126.02,52.61 126.34,52.36 126.51,51.93 126.70,51.70 126.71,51.57 126.81,51.51 126.92,51.10 127.31,50.71 127.34,50.35 127.59,50.21 127.49,49.98 127.55,49.80 127.71,49.67 128.00,49.57 128.70,49.60 128.82,49.46 129.07,49.37 129.50,49.39 130.20,48.89 130.55,48.86 130.62,48.77 130.55,48.60 130.80,48.34 130.71,48.13 130.73,48.02 130.92,47.84 130.96,47.71 132.48,47.71 132.71,47.95 133.14,48.11 133.47,48.10 133.84,48.27 134.29,48.37 134.67,48.25 134.67,48.15 134.57,48.02 134.75,47.72 134.54,47.49 134.29,47.41 134.17,47.30 134.20,47.13 133.87,46.50 133.86,46.25 133.70,46.14 133.65,45.96 133.51,45.88 133.44,45.60 133.19,45.49 133.11,45.13 132.94,45.03 131.98,45.24 131.85,45.33 131.45,44.98 130.98,44.84 131.26,44.07 131.17,43.70 131.18,43.51 131.26,43.38 131.07,42.90 130.58,42.81 130.42,42.73 130.58,42.62 130.53,42.54
|
||||||
|
130.53,42.54 130.25,42.74 130.24,42.89 129.90,43.00 129.70,42.45 129.52,42.38 129.31,42.41 129.20,42.22 128.92,42.04 128.05,41.99 128.03,41.90 128.29,41.61 128.15,41.39 128.01,41.45 127.69,41.44 127.18,41.53 126.95,41.77 126.72,41.72 126.60,41.64 126.49,41.36 125.99,40.90 125.73,40.87 125.42,40.66 124.89,40.46 124.39,40.10 124.36,40.00
|
||||||
|
130.53,42.54 130.69,42.30
|
||||||
|
70.96,40.24 70.65,40.20 70.37,40.38 70.38,40.45 70.75,40.72 70.40,41.04 70.29,40.89 69.71,40.66 69.41,40.80 69.36,40.77 69.31,40.63 69.21,40.57 69.30,40.33 69.22,40.29 69.27,40.20 68.95,40.22 68.63,40.17 68.97,40.09 68.80,40.05 68.87,39.91 68.64,39.84 68.59,39.63 68.46,39.54 67.72,39.62 67.43,39.47 67.36,39.22 67.65,39.13 67.69,38.99 68.04,38.98 68.13,38.93 68.05,38.67 68.09,38.47 68.35,38.21 68.29,38.03 67.81,37.49 67.76,37.17
|
||||||
|
35.89,35.92 36.15,35.83 36.20,35.94 36.35,36.00 36.38,36.17 36.64,36.23 36.54,36.46 36.66,36.80 36.94,36.76 37.07,36.65 37.44,36.64 38.19,36.90 38.44,36.86 38.77,36.69 39.36,36.68 40.02,36.83 40.71,37.10 41.52,37.09 42.06,37.21 42.20,37.30 42.31,37.23 42.36,37.11
|
||||||
|
35.45,31.48 35.40,31.23 35.44,31.13 35.14,30.42 35.14,30.14 34.97,29.56
|
||||||
|
35.55,32.40 35.56,31.77 35.45,31.48
|
||||||
|
35.61,32.68 35.55,32.40
|
||||||
|
88.11,27.87 87.14,27.84 86.61,28.10 86.52,27.96 86.41,27.93 86.14,28.11 86.08,28.08 86.06,27.93 85.99,27.91 85.68,28.28 85.12,28.32 85.09,28.37 85.16,28.59 84.80,28.56 84.23,28.91 84.10,29.22 83.94,29.28 83.58,29.18 83.16,29.61 82.85,29.68 82.22,30.06 82.04,30.33 81.64,30.39 81.42,30.34 81.18,30.04 81.11,30.04 81.01,30.16
|
||||||
|
97.32,28.22 97.34,27.94 96.88,27.59 96.90,27.44 97.10,27.12 96.95,27.13 96.73,27.33 96.19,27.26 95.46,26.76 95.13,26.60 95.05,26.35 95.13,26.04 95.02,25.91 94.95,25.70 94.58,25.32 94.55,25.22 94.71,25.05 94.29,24.32 94.13,23.88 93.68,24.01 93.49,23.97 93.33,24.06 93.41,23.68 93.39,23.34 93.35,23.08 93.31,23.03 93.16,23.03 93.08,22.72 93.15,22.23 93.04,22.18 92.96,22.00 92.85,22.01 92.69,22.13 92.57,21.98
|
||||||
|
100.12,20.32 99.89,20.42 99.72,20.33 99.46,20.36 99.49,20.15 99.34,20.08 99.07,20.10 98.92,19.77 98.37,19.69 98.02,19.75 97.82,19.46 97.80,19.13 97.71,18.93 97.75,18.59 97.52,18.50 97.37,18.52 97.45,18.36 97.63,18.29 97.74,17.94 97.71,17.80 98.44,16.98 98.48,16.73 98.66,16.33 98.84,16.42 98.89,16.35 98.82,16.18 98.59,16.05 98.56,15.37 98.19,15.20 98.20,14.98 98.25,14.81 98.57,14.36 98.93,14.05 99.14,13.72 99.18,13.23 99.12,13.03 99.22,12.74 99.41,12.55 99.61,11.78 99.19,11.11 98.76,10.66 98.70,10.19
|
||||||
|
103.09,18.14 103.09,18.14
|
||||||
|
103.09,18.14 103.26,18.28 103.29,18.41 103.95,18.32 104.43,17.70 104.74,17.46 104.82,17.30 104.74,16.88 104.82,16.47 105.05,16.16 105.41,15.99 105.40,15.83 105.64,15.66 105.49,15.26 105.55,14.93 105.50,14.59 105.42,14.47 105.18,14.35
|
||||||
|
100.12,20.32 100.14,20.25 100.32,20.39 100.52,20.18 100.54,20.09 100.40,19.76 100.51,19.55 100.74,19.51 100.97,19.61 101.21,19.55 101.20,19.33 101.29,18.98 101.05,18.44 101.14,18.14 100.91,17.58 100.96,17.54 101.17,17.50 101.69,17.89 101.77,18.03 101.95,18.08 102.10,18.21 102.55,17.97 102.66,17.82 102.81,17.95 103.05,18.03 103.09,18.14
|
||||||
|
101.14,21.57 101.08,21.47 100.70,21.25 100.54,20.99 100.52,20.92 100.62,20.86 100.25,20.73 100.12,20.32
|
||||||
|
97.32,28.22 97.54,28.51 97.60,28.52 98.06,28.19 98.30,27.55 98.45,27.66 98.65,27.57 98.74,26.79 98.69,26.19 98.56,26.07 98.66,25.86 98.47,25.79 98.33,25.59 98.14,25.57 98.01,25.29 97.82,25.25 97.71,25.03 97.74,24.87 97.58,24.77 97.53,24.49 97.67,24.38 97.71,24.23 97.56,23.91 97.69,23.90 98.21,24.11 98.58,24.07 98.84,24.12 98.68,23.91 98.83,23.62 98.80,23.52 98.88,23.38 98.86,23.19 99.42,23.07 99.51,22.96 99.39,22.83 99.34,22.50 99.21,22.28 99.19,22.13 99.92,22.03 99.94,21.76 100.10,21.66 100.15,21.48 100.60,21.47 101.08,21.76 101.13,21.74 101.14,21.57
|
||||||
|
102.13,22.38 102.58,21.90 102.66,21.68 102.74,21.68 102.82,21.81 102.95,21.68 102.85,21.27 103.10,20.89 103.64,20.70 104.10,20.95 104.58,20.65 104.37,20.44 104.62,20.37 104.70,20.21 104.85,20.20 104.93,20.02 104.59,19.62 104.03,19.68 104.06,19.48 103.89,19.30 104.72,18.80 105.15,18.65 105.11,18.41 105.33,18.19 105.46,18.15 105.69,17.74 106.50,16.95 106.55,16.65 106.66,16.49 106.74,16.45 106.85,16.52 106.93,16.35 107.40,16.04 107.39,15.95 107.17,15.80 107.65,15.26 107.48,14.98 107.52,14.71
|
||||||
|
27.40,5.11 27.07,5.20 26.82,5.06 25.53,5.31 25.40,5.26 25.25,5.02 25.07,4.97 24.77,4.93 24.32,4.99 23.42,4.66 23.12,4.74 22.86,4.72 22.71,4.59 22.42,4.13 21.69,4.28 21.54,4.24 20.56,4.46 20.23,4.83 19.81,5.09 19.50,5.13 19.07,4.89 18.83,4.52 18.59,4.35 18.61,3.48
|
||||||
|
30.55,-2.40 30.42,-2.64 30.47,-2.69 30.43,-2.87 30.78,-2.98 30.79,-3.27 30.63,-3.35 30.63,-3.42 30.43,-3.59 30.15,-4.09 29.95,-4.31 29.72,-4.46 29.40,-4.45
|
||||||
|
29.01,-2.72 29.02,-2.80 29.22,-3.05 29.21,-3.83 29.40,-4.45
|
||||||
|
29.58,-1.39 29.35,-1.52 29.20,-1.72 29.13,-1.86 29.13,-2.20 28.88,-2.40 28.89,-2.64 29.01,-2.72
|
||||||
|
35.27,5.49 34.98,5.86 34.71,6.66 34.06,7.23 33.90,7.51 33.67,7.67 33.23,7.76 33.00,7.90 33.28,8.44 33.95,8.44 34.07,8.55 34.08,9.46
|
||||||
|
22.86,10.92 22.49,11.00 22.04,10.82 21.77,10.64 21.71,10.57 21.68,10.29 21.53,10.21 21.40,10.00 21.26,9.97 20.77,9.41 20.34,9.13 20.07,9.13 19.67,9.02 19.15,9.02 18.96,8.94 18.89,8.84 19.11,8.66 18.56,8.05 17.65,7.98 16.78,7.55 16.55,7.87 16.40,7.77 16.38,7.68 15.96,7.51 15.48,7.52
|
||||||
|
13.29,2.16 13.22,2.26 11.56,2.30 11.35,2.30 11.33,2.17
|
||||||
|
11.13,-3.92 11.23,-3.69 11.50,-3.52 11.69,-3.68 11.88,-3.67 11.83,-3.53 11.93,-3.32 11.72,-3.18 11.76,-2.98 11.64,-2.86 11.54,-2.84 11.61,-2.34 11.73,-2.39 11.95,-2.34 12.06,-2.41 12.45,-2.33 12.43,-1.93 12.59,-1.83 12.79,-1.93 12.99,-2.31 13.46,-2.40 13.73,-2.14 13.88,-2.33 13.89,-2.47 13.99,-2.49 14.20,-2.35 14.16,-2.22 14.25,-2.00 14.38,-1.89 14.47,-0.57 14.21,-0.43 14.07,-0.27 13.86,-0.20 13.95,0.35 14.09,0.54 14.32,0.62 14.43,0.90 14.18,1.37 13.85,1.42 13.22,1.25 13.25,1.37 13.17,1.79 13.29,2.16
|
||||||
|
15.48,7.52 15.55,7.79 15.44,7.85 15.12,8.56 14.33,9.20 13.98,9.69 14.24,9.98 14.84,9.94 15.65,10.01 15.28,10.36 15.13,10.65 15.03,11.11 15.12,11.54 15.08,11.85 14.88,12.27 14.85,12.50 14.76,12.66 14.54,12.82 14.46,13.02 14.06,13.08
|
||||||
|
14.06,13.08 14.20,12.38 14.52,12.30 14.62,12.15 14.58,11.53 14.20,11.27 13.98,11.21 13.70,10.87 13.54,10.61 13.41,10.17 13.27,10.04 13.20,9.56 12.93,9.43 12.78,8.82 12.58,8.62 12.40,8.60 12.23,8.28 12.02,7.59 11.77,7.27 11.86,7.12 11.58,6.89 11.48,6.60 11.24,6.45 11.11,6.46 11.01,6.74 10.61,7.06 10.48,6.89 10.21,6.89 10.14,7.00 9.78,6.76 9.66,6.53 9.00,5.92 8.80,5.20 8.56,4.76
|
||||||
|
3.60,11.70 3.66,11.76 3.61,11.89 3.65,12.53 3.95,12.78 4.04,12.93 4.15,13.46 4.66,13.73 5.24,13.76 5.49,13.87 6.30,13.66 6.59,13.41 6.87,13.04 7.01,13.00 7.83,13.34 8.10,13.29 8.75,12.91 9.62,12.81 10.05,13.21 10.48,13.33 11.41,13.35 11.99,13.19 12.12,13.09 12.46,13.09 12.65,13.33 12.87,13.45 13.32,13.67 13.61,13.70
|
||||||
|
1.19,6.09 0.98,6.32 0.74,6.45 0.53,6.85 0.63,7.35 0.51,7.44 0.50,7.55 0.61,7.73 0.58,8.15 0.69,8.35 0.37,8.76 0.49,8.85 0.47,9.12 0.53,9.40 0.41,9.49 0.26,9.43 0.23,9.46 0.34,9.60 0.26,9.64 0.32,9.69 0.38,10.29 -0.09,10.67 0.01,11.02 -0.07,11.12
|
||||||
|
-2.70,9.48 -2.67,9.28 -2.75,9.05 -2.60,8.80 -2.51,8.21 -2.61,8.15 -2.61,8.05 -2.79,7.93 -2.99,7.20 -3.24,6.81 -3.24,6.65 -3.00,5.71 -2.96,5.64 -2.79,5.60 -2.76,5.36 -2.82,5.15 -3.11,5.09
|
||||||
|
-5.52,10.43 -5.46,10.77 -5.49,11.04 -5.35,11.13 -5.25,11.38 -5.29,11.83 -5.11,11.97 -4.70,12.08 -4.43,12.34 -4.42,12.58 -4.48,12.67 -4.23,12.79 -4.33,13.12 -4.15,13.31 -3.95,13.40 -3.53,13.18 -3.30,13.28 -3.25,13.66 -2.95,13.65 -2.87,13.95 -2.59,14.23 -2.46,14.27 -2.11,14.17 -1.97,14.46 -1.70,14.51 -1.05,14.82 -0.76,15.05 -0.54,15.08 -0.41,15.01 -0.24,15.06 0.22,14.91
|
||||||
|
-2.70,9.48 -2.82,9.43 -2.99,9.69 -3.22,9.90 -3.79,9.92 -4.27,9.74 -4.33,9.65 -4.63,9.71 -4.97,9.93 -5.10,10.24 -5.38,10.31 -5.52,10.43
|
||||||
|
-8.49,7.56 -8.30,6.98 -8.33,6.80 -8.60,6.51 -8.29,6.32 -7.89,6.23 -7.80,5.98 -7.45,5.84 -7.40,5.55 -7.43,5.32 -7.57,5.08 -7.59,4.82 -7.54,4.35
|
||||||
|
-8.49,7.56 -8.66,7.69 -8.89,7.26 -9.12,7.22 -9.26,7.38 -9.46,7.42 -9.37,7.70 -9.52,8.35 -9.68,8.48 -9.74,8.45 -9.78,8.54 -10.06,8.43 -10.15,8.52 -10.28,8.49
|
||||||
|
-5.52,10.43 -5.84,10.39 -6.03,10.19 -6.20,10.23 -6.26,10.72 -6.43,10.67 -6.42,10.56 -6.65,10.66 -6.69,10.35 -6.95,10.34 -7.02,10.14 -7.36,10.26 -7.50,10.44 -7.66,10.43 -7.81,10.24 -7.99,10.16
|
||||||
|
-15.04,10.94 -14.68,11.51 -14.27,11.66 -13.73,11.74 -13.74,12.01 -13.95,12.18 -13.71,12.31 -13.67,12.48 -13.73,12.67
|
||||||
|
-11.39,12.40 -11.50,12.20 -11.31,12.02 -10.93,12.21 -10.71,11.90 -10.47,12.14 -10.27,12.21 -9.75,12.03 -9.66,12.14 -9.36,12.26 -9.40,12.46 -9.22,12.48 -9.04,12.40 -8.82,11.92 -8.82,11.67 -8.40,11.37 -8.67,11.01 -8.47,11.05 -8.34,10.99 -8.27,10.49 -8.01,10.32 -7.99,10.16
|
||||||
|
107.52,14.71 107.41,14.56 107.29,14.59 106.94,14.33 106.78,14.34 106.50,14.58 106.35,14.45 106.23,14.48 106.17,14.37 105.98,14.34 106.12,14.05 106.07,13.92 105.90,13.92 105.74,14.08 105.53,14.16 105.35,14.11 105.18,14.35
|
||||||
|
114.06,4.60 114.22,4.48 114.32,4.26 114.42,4.26 114.57,4.05 114.65,4.04 114.78,4.17 114.84,4.39 114.75,4.72 115.03,4.90
|
||||||
|
-88.23,15.73 -88.96,15.15 -89.14,15.07 -89.22,14.87 -89.17,14.61 -89.36,14.42
|
||||||
|
-77.37,8.66 -77.48,8.50 -77.20,7.97 -77.35,7.84 -77.35,7.71 -77.54,7.57 -77.59,7.54 -77.76,7.70 -77.74,7.54 -77.90,7.23
|
||||||
|
-60.74,5.20 -61.39,5.94 -61.13,6.21 -61.20,6.59 -61.15,6.69 -60.87,6.79 -60.72,6.77 -60.35,7.00 -60.35,7.15 -60.58,7.16 -60.63,7.21 -60.61,7.32 -60.72,7.54 -60.51,7.81 -60.38,7.83 -60.03,8.05 -59.96,8.19 -59.85,8.25 -59.83,8.31 -60.02,8.55
|
||||||
|
-60.74,5.20 -60.67,5.16 -60.60,4.95 -61.00,4.54 -61.28,4.52 -61.55,4.29 -61.82,4.20 -62.15,4.10 -62.41,4.16 -62.71,4.02 -62.76,3.67 -62.86,3.59 -62.97,3.59 -63.34,3.94 -63.53,3.89 -64.02,3.93 -64.19,4.13 -64.58,4.14 -64.79,4.28 -64.82,4.23 -64.67,4.01 -64.22,3.59 -64.22,3.20 -64.01,2.67 -64.05,2.50 -63.39,2.41 -63.43,2.16 -64.01,1.93 -64.11,1.62 -64.21,1.53 -64.30,1.46 -64.49,1.45 -64.73,1.25 -65.10,1.11 -65.47,0.69 -65.56,0.69 -65.52,0.84 -65.68,0.98 -66.06,0.79 -66.35,0.77 -66.88,1.22
|
||||||
|
52.49,41.78 53.06,42.15 54.12,42.34 54.85,41.97 55.43,41.30 55.98,41.32
|
||||||
|
55.98,41.32 57.02,41.26 57.12,41.35 57.02,41.45 56.96,41.86 57.29,42.12 57.81,42.19 58.03,42.49 58.28,42.43 58.40,42.29 58.47,42.30 58.15,42.63 58.26,42.69 58.48,42.66 58.59,42.78 58.88,42.56 59.16,42.51 59.35,42.32 59.86,42.30 59.99,42.21 59.94,41.97 60.20,41.80 60.08,41.76 60.14,41.59 60.09,41.40 60.45,41.22 60.87,41.25 61.24,41.19 61.50,41.28 61.90,41.09 62.10,40.68 62.38,40.33 62.48,39.98 63.51,39.38 63.76,39.16 64.16,38.95 64.31,38.98 65.61,38.24 65.97,38.24 66.57,38.01 66.63,37.93 66.53,37.79 66.52,37.35
|
||||||
|
49.23,46.34 48.54,46.61 48.50,46.70 48.56,46.76 48.88,46.71 48.96,46.77 48.55,47.32 48.17,47.71 47.48,47.80 47.29,47.74 47.09,47.95 47.12,48.13 47.06,48.23 46.66,48.41 46.61,48.57 46.70,48.81 47.03,49.15 46.80,49.37 46.89,49.70 46.99,49.85 47.25,50.00 47.33,50.27 47.50,50.40 47.71,50.38 48.33,49.86 48.43,49.83 48.76,49.93 48.84,50.01 48.63,50.61 48.81,50.60 49.32,50.85 49.50,51.08 49.82,51.13 50.31,51.32 50.79,51.73 51.16,51.65 51.34,51.48 51.61,51.48 52.22,51.71 52.57,51.48 53.34,51.48 54.14,51.04 54.42,50.78 54.47,50.58 54.56,50.54 54.65,50.66 54.55,50.95 54.64,51.01 55.36,50.67 55.69,50.58 56.05,50.71 56.14,50.84 56.49,51.02 56.62,50.98 57.01,51.07 57.44,50.89 57.65,50.93 57.84,51.09 58.36,51.06 58.88,50.69 59.45,50.62 59.52,50.58 59.52,50.49 59.81,50.58 59.96,50.80 60.06,50.85 60.42,50.68 60.94,50.70 61.39,50.86 61.59,51.23 61.55,51.32 61.36,51.44 60.46,51.65 60.39,51.77 60.03,51.93 60.43,52.13 60.67,52.15 60.99,52.34 60.77,52.68 61.05,52.97 61.97,52.94 62.08,53.01 62.01,53.11 61.66,53.23 61.20,53.29 61.16,53.34 61.23,53.45 61.53,53.52 61.47,53.58 61.25,53.55 60.98,53.62 61.11,53.75 61.14,53.96 61.23,54.02 61.33,54.05 61.93,53.95 63.85,54.24 64.46,54.38 64.93,54.40 65.09,54.34 65.24,54.52 65.48,54.62 65.71,54.62 65.91,54.69 66.22,54.67 68.16,54.98 68.24,55.05 68.21,55.16 68.52,55.20 68.98,55.39 69.49,55.36 70.18,55.16 70.49,55.28 70.74,55.31 70.91,55.13 71.19,54.60 71.15,54.36 71.05,54.26 71.09,54.21 71.34,54.16 72.00,54.21 72.19,54.33 72.39,54.12 72.45,53.94 72.59,54.00 72.56,54.09 72.62,54.13 72.91,54.11 73.23,53.96 73.62,54.07 73.71,54.04 73.68,53.93 73.40,53.81 73.31,53.71 73.29,53.60 73.41,53.45 73.86,53.62 74.21,53.58 74.35,53.49 74.45,53.65 74.83,53.83 75.22,53.89 75.38,53.97 75.44,54.09 76.84,54.44 76.65,54.15 76.42,54.15 76.48,54.02 77.86,53.27 79.47,51.49 79.99,50.77 80.07,50.76 80.09,50.84 80.22,50.91 80.42,50.95 80.45,51.18 80.74,51.29 80.88,51.28 80.97,51.19 81.13,51.19 81.07,50.97 81.39,50.96 81.47,50.74 82.49,50.73 82.76,50.89 83.36,50.99 83.95,50.77 84.18,50.52 84.32,50.24 84.99,50.06 85.00,49.89 85.23,49.62 86.18,49.50 86.68,49.78 86.73,49.70 86.63,49.56 86.81,49.49 87.00,49.29 87.23,49.22 87.32,49.09
|
||||||
|
34.20,31.32 34.90,29.48
|
||||||
|
33.98,4.22 34.38,4.62
|
||||||
|
41.88,3.98 41.14,3.96 40.77,4.27 39.84,3.85 39.54,3.47 39.23,3.48 38.09,3.65 36.91,4.41 36.08,4.45 35.92,4.62
|
||||||
|
23.38,-17.64 21.42,-18.00 21.11,-17.96 20.75,-18.02 20.39,-17.89 18.96,-17.80 18.72,-17.70 18.40,-17.40 14.02,-17.41 13.48,-17.04 13.10,-16.97 12.55,-17.21 12.01,-17.17 11.74,-17.25
|
||||||
|
25.26,-17.79 25.64,-17.82 26.00,-17.97 26.14,-17.91 26.33,-17.93 26.78,-18.04 27.02,-17.96 27.93,-16.90 28.76,-16.53 28.83,-16.42 28.91,-15.99 29.49,-15.70 30.40,-15.64
|
||||||
|
23.38,-17.64 24.27,-17.48 24.73,-17.52 25.00,-17.57 25.26,-17.79
|
||||||
|
31.29,-22.40 32.43,-21.30 32.35,-21.14 32.48,-20.95 32.49,-20.66 32.67,-20.52 32.99,-19.98 33.01,-19.87 32.78,-19.39 32.85,-19.10 32.70,-18.94 32.72,-18.83 32.88,-18.73 32.99,-18.36 32.97,-17.25 32.88,-16.88 32.95,-16.71 32.24,-16.45 31.94,-16.43 31.69,-16.21 31.49,-16.18 31.24,-16.02 30.44,-16.00 30.40,-15.64
|
||||||
|
12.28,-6.00 12.42,-6.05 13.18,-5.86 16.43,-5.90 16.70,-6.16 16.74,-6.62 16.92,-6.93 16.98,-7.26 17.58,-8.10 18.01,-8.11 18.56,-7.94 18.94,-8.00 19.34,-7.97 19.53,-7.14 19.66,-7.04 19.88,-6.99 20.59,-6.92 20.54,-7.18 20.61,-7.28 21.78,-7.31 21.84,-7.42 21.80,-8.11 21.90,-8.34 21.91,-8.69 21.81,-9.47 22.20,-10.04 22.27,-10.26 22.31,-10.69 22.18,-10.89 22.23,-11.12 22.31,-11.20 22.56,-11.06 23.08,-11.09 23.46,-10.97 23.83,-11.01 23.97,-10.87
|
||||||
|
-16.56,13.59 -15.51,13.59 -15.43,13.73 -15.11,13.81 -14.94,13.79 -14.41,13.50 -13.98,13.54 -13.83,13.41 -13.85,13.34 -14.25,13.24 -15.15,13.56 -15.29,13.40 -15.81,13.33 -15.83,13.16 -16.65,13.15 -16.76,13.06
|
||||||
|
0.22,14.91 0.16,14.50 0.38,14.25 0.35,14.14 0.43,13.97 0.62,13.70 0.90,13.61 1.20,13.36 0.99,13.36 0.99,13.04 1.56,12.64 1.79,12.61 1.96,12.71 2.10,12.70 2.23,12.47 2.07,12.38 2.07,12.31 2.39,11.90
|
||||||
|
18.61,3.48 18.47,3.62 18.16,3.50 17.49,3.69 16.61,3.51 16.48,3.17 16.47,2.83 16.18,2.27
|
||||||
|
16.18,2.26 16.08,2.11 16.14,1.72 16.06,1.68 15.74,1.92 15.34,1.94 15.16,2.04 14.90,2.01 14.88,2.08 14.76,2.08 14.58,2.20 14.48,2.15 13.29,2.16
|
||||||
|
15.48,7.52 15.21,7.21 14.74,6.28 14.51,6.16 14.43,6.04 14.62,5.87 14.56,5.28 14.64,5.18 14.73,4.60 15.06,4.28 15.14,4.04 15.03,4.02 15.13,3.83 15.77,3.13 15.90,3.10 16.06,2.91 16.18,2.26
|
||||||
|
33.98,4.22 33.49,3.76 33.15,3.77 33.00,3.88 32.34,3.71 32.14,3.52 31.94,3.61 31.80,3.80 31.55,3.68 31.15,3.79 30.93,3.63 30.84,3.49
|
||||||
|
13.07,-4.63 12.83,-4.74 12.45,-5.07 12.52,-5.15 12.50,-5.70 12.21,-5.76
|
||||||
|
31.95,-25.96 31.99,-24.46 31.80,-23.89 31.55,-23.48 31.29,-22.40
|
||||||
|
33.20,-14.01 30.23,-14.99 30.40,-15.64
|
||||||
|
34.96,-11.58 34.62,-11.62 34.36,-12.16 34.56,-13.36 34.66,-13.49 34.91,-13.55 35.25,-13.90 35.85,-14.67 35.89,-14.89 35.81,-15.27 35.82,-15.68 35.76,-16.06 35.36,-16.16 35.17,-16.56 35.28,-16.81 35.27,-17.12 35.06,-17.08 35.08,-16.83 34.93,-16.76 34.42,-16.25 34.40,-16.08 34.25,-15.89 34.54,-15.30 34.51,-14.60 34.33,-14.41 33.64,-14.57 33.20,-14.01
|
||||||
|
29.58,-1.39 29.56,-0.98 29.72,0.10 29.93,0.50 29.94,0.82 30.18,0.97 30.32,1.19 30.48,1.24 31.25,2.04 31.27,2.15 31.18,2.27 30.96,2.40 30.73,2.46 30.85,2.89 30.75,3.04 30.91,3.41 30.84,3.49
|
||||||
|
36.52,14.26 36.43,15.13 36.52,15.25 36.91,16.30 36.89,16.62 36.98,16.80 37.01,17.06 37.41,17.06 37.55,17.32 37.78,17.46 38.25,17.58 38.61,18.01
|
||||||
|
42.38,12.47 42.13,12.77 41.95,12.88 41.77,13.18 41.36,13.50 40.82,14.11 40.14,14.46 39.90,14.44 39.53,14.54 39.20,14.48 39.02,14.63 38.81,14.48 38.43,14.43 38.22,14.65 37.88,14.85 37.57,14.15 37.26,14.45 37.02,14.27 36.81,14.32 36.52,14.26
|
||||||
|
27.35,57.53 27.40,57.67 27.54,57.80 27.78,57.87 27.67,57.93 27.50,58.22 27.53,58.44 27.43,58.79 27.76,59.05 27.90,59.28 28.15,59.37 28.01,59.48
|
||||||
|
74.31,32.81 74.66,32.76 74.69,32.49 74.99,32.46 75.30,32.32
|
||||||
|
-84.35,10.98 -84.70,11.05 -84.91,10.95 -85.58,11.19 -85.74,11.06
|
||||||
|
-82.88,8.07 -83.03,8.34 -82.86,8.45 -82.86,8.64 -82.92,8.74 -82.73,8.92 -82.94,9.06 -82.94,9.45 -82.80,9.59 -82.64,9.51 -82.56,9.58
|
||||||
|
-66.88,1.22 -67.21,2.39 -67.62,2.79 -67.86,2.79 -67.83,2.89 -67.31,3.42 -67.66,3.86 -67.86,4.51 -67.82,5.27 -67.64,5.56 -67.58,5.83 -67.47,5.93 -67.48,6.18 -67.86,6.29 -68.47,6.16 -68.94,6.20 -69.27,6.10 -69.43,6.12 -70.13,6.95 -70.74,7.09 -71.13,6.99 -72.01,7.03 -72.21,7.37 -72.39,7.42 -72.47,7.52 -72.45,7.97 -72.36,8.09 -72.39,8.29 -72.67,8.63 -72.80,9.11 -72.96,9.14 -73.06,9.26 -73.37,9.19 -73.01,9.79 -72.87,10.49 -72.69,10.84 -72.45,11.11 -72.25,11.20 -71.96,11.67 -71.32,11.86
|
||||||
|
-68.44,-52.36 -68.46,-52.29 -69.96,-52.01 -71.97,-51.96 -71.95,-51.88 -72.41,-51.54 -72.30,-51.30 -72.38,-51.10 -72.28,-50.91 -72.34,-50.68 -72.51,-50.61 -73.15,-50.74 -73.31,-50.36 -73.50,-50.13 -73.48,-49.77
|
||||||
|
-68.63,-52.65 -68.65,-54.92 -68.06,-54.88 -67.35,-54.93 -66.48,-55.11
|
||||||
|
22.77,54.36 19.60,54.46
|
||||||
|
48.44,28.54 47.67,28.53 47.43,28.99 46.53,29.10
|
||||||
|
44.77,37.14 44.88,36.80 45.02,36.70 45.05,36.47 45.24,36.36 45.36,36.02 45.56,35.98 45.78,35.82 46.27,35.77 46.00,35.61 45.98,35.48 46.11,35.32 46.13,35.13 45.92,35.03 45.68,34.80 45.64,34.57 45.50,34.58 45.44,34.42 45.54,34.22 45.40,33.97 45.74,33.60 45.88,33.61 45.87,33.49 46.02,33.42 46.15,33.23 46.08,33.09 46.11,32.96 46.38,32.93 47.12,32.47 47.37,32.42 47.51,32.15 47.83,31.79 47.68,31.40 47.68,31.00 48.01,30.99 48.01,30.47 48.33,30.29 48.43,30.04 48.55,29.96
|
||||||
|
61.26,35.62 61.25,35.87 61.15,35.98 61.21,36.19 61.12,36.64 60.34,36.64 60.06,36.96 59.45,37.25 59.30,37.51 58.82,37.68 58.26,37.67 57.98,37.83 57.35,37.97 57.31,38.13 57.19,38.22 56.44,38.25 56.32,38.19 56.27,38.08 55.58,38.10 55.38,38.05 54.90,37.78 54.70,37.47 54.19,37.33 53.91,37.34
|
||||||
|
140.97,-2.60 140.97,-6.35 140.86,-6.74 140.98,-6.91 140.98,-9.12
|
||||||
|
9.52,30.23 9.31,30.12 9.64,29.64 9.81,29.18 9.92,27.79 9.75,27.33 9.89,26.85 9.88,26.63 9.86,26.55 9.49,26.33 9.45,26.07 10.00,25.33 10.03,25.05 10.26,24.59 10.40,24.49 10.69,24.55 11.51,24.31 11.97,23.52
|
||||||
|
-13.73,12.67 -15.20,12.68 -15.57,12.49 -16.71,12.35
|
||||||
|
-11.39,12.40 -12.40,12.34 -12.93,12.53 -13.06,12.49 -13.08,12.63 -13.73,12.67
|
||||||
|
0.90,10.99 0.49,10.95 -0.07,11.12
|
||||||
|
-139.06,60.00 -139.19,60.08 -139.08,60.34 -139.68,60.33 -139.97,60.18 -140.45,60.30 -140.53,60.22 -141.00,60.30 -141.00,69.65
|
||||||
|
-130.62,54.71 -130.21,55.03 -130.02,55.28 -130.12,55.69 -130.03,55.89 -130.06,56.07 -130.41,56.12 -130.48,56.23 -130.74,56.34 -131.58,56.60 -131.82,56.59 -131.87,56.79 -132.10,56.86 -132.03,57.03 -132.34,57.08 -132.23,57.20 -133.42,58.34 -133.40,58.41 -133.82,58.71 -134.30,58.90 -134.44,59.09 -134.94,59.29 -135.07,59.44 -135.05,59.58 -135.48,59.79 -136.32,59.60 -136.25,59.53 -136.35,59.46 -136.47,59.46 -136.47,59.28 -136.58,59.15 -136.81,59.15 -137.44,58.90 -137.52,58.92 -137.48,58.99 -137.59,59.23 -138.63,59.78 -138.71,59.90 -139.06,60.00
|
||||||
|
-123.31,48.99 -122.69,48.99
|
||||||
|
-88.89,15.89 -89.24,15.89 -89.16,17.81
|
||||||
|
-75.28,-0.11 -75.48,-0.16 -75.63,-0.12 -75.26,-0.59 -75.25,-0.95 -75.41,-0.92 -75.57,-1.53 -76.09,-2.13 -76.68,-2.56 -77.86,-2.98 -78.18,-3.35 -78.16,-3.47 -78.23,-3.49 -78.35,-3.40 -78.42,-3.78 -78.68,-4.33 -78.69,-4.56 -78.91,-4.71 -78.92,-4.86 -79.03,-4.97 -79.33,-4.93 -79.50,-4.67 -79.52,-4.54 -79.64,-4.45 -79.80,-4.48 -80.14,-4.30 -80.38,-4.46 -80.48,-4.43 -80.35,-4.21 -80.49,-4.17 -80.49,-4.01 -80.30,-4.01 -80.18,-3.88 -80.32,-3.39
|
||||||
|
-69.97,-4.24 -70.34,-3.81 -70.53,-3.87 -70.74,-3.78 -70.07,-2.75 -70.10,-2.66 -70.97,-2.21 -71.40,-2.33 -71.75,-2.15 -71.98,-2.33 -72.22,-2.40 -72.40,-2.43 -72.63,-2.35 -72.94,-2.39 -73.15,-2.28 -73.13,-2.08 -73.20,-1.83 -73.50,-1.69 -73.52,-1.45 -73.66,-1.25 -74.25,-0.97 -74.42,-0.58 -74.80,-0.20 -74.95,-0.19 -75.14,-0.05 -75.28,-0.11
|
||||||
|
-66.88,1.22 -67.08,1.19 -67.12,1.70 -67.40,2.12 -67.56,2.07 -67.82,1.79 -67.94,1.75 -68.19,1.99 -68.26,1.85 -68.18,1.72 -69.32,1.72 -69.54,1.77 -69.85,1.71 -69.85,1.06 -69.31,1.05 -69.16,0.86 -69.15,0.66 -69.28,0.63 -69.47,0.73 -70.05,0.58 -70.07,-0.14 -69.92,-0.32 -69.63,-0.51 -69.61,-0.76 -69.45,-1.00 -69.40,-1.19 -69.97,-4.24
|
||||||
|
-69.58,-10.95 -68.69,-12.50 -68.76,-12.69 -68.98,-12.88 -68.98,-13.50 -69.07,-13.68 -68.87,-14.17 -69.00,-14.27 -69.01,-14.38 -69.36,-14.80 -69.37,-14.96 -69.17,-15.24 -69.42,-15.64 -69.22,-16.15 -68.84,-16.34 -69.03,-16.48 -69.02,-16.64 -69.20,-16.77 -69.44,-17.09 -69.62,-17.20 -69.51,-17.50
|
||||||
|
-69.58,-10.95 -68.85,-11.01 -68.73,-11.12 -68.62,-11.11 -68.31,-10.98 -68.07,-10.70 -67.72,-10.68 -67.42,-10.39 -67.11,-10.27 -66.58,-9.90 -65.92,-9.79 -65.56,-9.80 -65.40,-9.71 -65.31,-9.87 -65.30,-10.15 -65.45,-10.51 -65.32,-11.02 -65.39,-11.25 -65.18,-11.65 -65.19,-11.75 -65.09,-11.74 -64.99,-11.98 -64.51,-12.25 -64.42,-12.44 -63.94,-12.53 -63.69,-12.48 -63.35,-12.68 -63.07,-12.67 -63.02,-12.81 -62.77,-13.00 -62.12,-13.16 -61.79,-13.53 -61.08,-13.49 -60.51,-13.79 -60.40,-14.02 -60.47,-14.18 -60.30,-14.62 -60.27,-15.09 -60.58,-15.10 -60.24,-15.48 -60.18,-16.27 -58.54,-16.33 -58.35,-16.28 -58.35,-16.49 -58.48,-16.70 -58.40,-17.23 -57.99,-17.51 -57.83,-17.51 -57.59,-18.12 -57.50,-18.21 -57.57,-18.28 -57.78,-18.91 -57.73,-18.92 -57.72,-19.04 -57.80,-19.08 -58.13,-19.74 -57.86,-19.98 -58.16,-20.16
|
||||||
|
-69.97,-4.24 -70.05,-4.33 -70.24,-4.30 -70.40,-4.15 -70.80,-4.17 -70.97,-4.35 -71.84,-4.50 -72.35,-4.79 -72.70,-5.07 -72.89,-5.12 -72.98,-5.63 -73.24,-6.10 -73.14,-6.47 -73.76,-6.91 -73.80,-7.08 -73.72,-7.31 -73.96,-7.38 -74.00,-7.56 -73.72,-7.78 -73.78,-7.94 -73.64,-8.07 -73.55,-8.35 -73.36,-8.48 -73.30,-8.65 -72.97,-8.99 -72.97,-9.12 -73.21,-9.41 -72.81,-9.41 -72.38,-9.51 -72.17,-9.84 -72.14,-10.01 -71.24,-9.97 -70.54,-9.44 -70.64,-9.82 -70.64,-11.01 -70.53,-10.95 -70.29,-11.06 -69.96,-10.93 -69.58,-10.95
|
||||||
|
-62.65,-22.23 -62.28,-21.07 -62.28,-20.56 -61.92,-20.06 -61.76,-19.65 -60.01,-19.30 -59.09,-19.29 -58.18,-19.82 -58.16,-20.16
|
||||||
|
-17.05,20.81 -16.96,21.33 -13.02,21.33 -13.15,22.82 -13.03,23.00 -12.62,23.27 -12.02,23.47 -12.02,26.00 -8.68,26.00 -8.68,27.29
|
||||||
|
-8.68,27.29 -4.82,25.00
|
||||||
|
-8.68,27.66 -8.66,28.72 -7.69,29.35 -7.49,29.39 -7.16,29.61 -6.64,29.57 -6.52,29.66 -6.48,29.82 -6.00,29.83 -5.45,29.96 -5.18,30.17 -4.97,30.47 -4.32,30.70 -3.99,30.91 -3.67,30.96 -3.62,31.07 -3.83,31.20 -3.79,31.36 -3.83,31.66 -3.02,31.83 -2.86,32.07 -2.45,32.13 -1.28,32.09
|
||||||
|
-8.68,27.29 -8.68,27.66
|
||||||
|
-12.28,14.81 -12.86,15.24 -12.93,15.45 -13.21,15.62 -13.41,16.06 -13.51,16.14 -13.87,16.15 -14.09,16.42 -14.30,16.58 -14.53,16.66 -14.99,16.68 -15.77,16.49 -16.24,16.53 -16.44,16.20 -16.54,15.84
|
||||||
|
-4.82,25.00 -6.59,24.99 -5.63,16.57 -5.36,16.28 -5.51,15.50 -9.18,15.50 -9.34,15.53 -9.35,15.68 -9.43,15.62 -9.45,15.46 -9.94,15.37 -10.70,15.42 -10.95,15.15 -11.50,15.64 -11.76,15.43 -11.94,14.89 -12.10,14.75 -12.28,14.81
|
||||||
|
4.23,19.14 3.40,18.99 3.26,19.01 3.11,19.15 3.26,19.41 3.20,19.79 3.13,19.85 2.41,20.06 2.22,20.25 1.75,20.33 1.61,20.56 1.17,20.82 1.15,21.10 -4.82,25.00
|
||||||
|
4.23,19.14 5.84,19.48 7.48,20.87 11.97,23.52
|
||||||
|
9.52,30.23 9.04,32.07 8.33,32.54 8.11,33.06 7.73,33.27 7.50,33.83 7.51,34.08 7.75,34.25 7.84,34.41 8.12,34.56 8.25,34.73 8.28,34.98 8.39,35.20 8.25,35.80 8.35,36.37 8.21,36.52 8.44,36.76 8.60,36.83 8.58,36.94
|
||||||
|
23.98,19.50 15.98,23.45 14.98,23.00
|
||||||
|
24.98,22.00 24.98,20.00 23.98,20.00 23.98,19.50
|
||||||
|
25.15,31.65 24.85,31.33 24.96,30.68 24.70,30.20 24.98,29.18 24.98,22.00
|
||||||
|
22.86,10.92 22.92,11.34 22.59,11.58 22.58,11.99 22.47,12.07 22.35,12.66 22.23,12.71 21.93,12.68 21.83,12.79 21.99,13.11 22.23,13.33 22.11,13.80 22.17,13.91 22.54,14.16 22.38,14.55 22.67,14.72 22.76,15.00 22.93,15.16 22.93,15.53 23.11,15.70 23.97,15.72 23.98,19.50
|
||||||
|
24.98,22.00 31.21,21.99 31.40,22.20 31.49,22.15 31.43,22.00 36.87,22.00
|
||||||
|
41.88,3.98 41.34,3.20 40.96,2.81 40.98,-0.87 41.52,-1.57 41.53,-1.70
|
||||||
|
42.92,11.00 42.66,10.60 42.84,10.20 43.07,9.93 43.18,9.88 43.48,9.38 43.62,9.34 43.98,9.01 46.98,8.00 47.98,8.00
|
||||||
|
30.75,-8.19 28.90,-8.49 28.93,-8.59 28.87,-8.79 28.40,-9.22 28.60,-9.68 28.65,-10.55 28.36,-11.48 28.38,-11.57 28.48,-11.81 29.06,-12.35 29.49,-12.42 29.51,-12.23 29.80,-12.16 29.78,-13.44 29.65,-13.41 29.63,-13.30 29.55,-13.25 29.20,-13.40 29.01,-13.37 28.73,-12.93 28.55,-12.84 28.41,-12.52 27.57,-12.23 27.42,-11.94 27.24,-11.78 27.16,-11.58 27.05,-11.62 26.95,-11.90 26.82,-11.97 26.03,-11.89 25.51,-11.75 25.35,-11.62 25.29,-11.21 24.52,-11.44 24.38,-11.42 24.34,-11.37 24.40,-11.26 24.37,-11.13 24.14,-11.03 24.08,-10.89 23.97,-10.87
|
||||||
|
23.38,-17.64 22.15,-16.60 22.04,-16.26 21.98,-15.96 21.98,-13.00 23.96,-12.99 23.88,-12.80 23.99,-12.42 23.97,-11.64 24.05,-11.41 23.97,-10.87
|
||||||
|
33.90,-1.00 33.94,0.17 34.16,0.61 34.41,0.87 34.48,1.04 34.80,1.24 34.78,1.38 34.97,1.64 34.98,1.77 34.91,2.48 34.74,2.82 34.59,2.92 34.52,3.12 34.45,3.16 34.40,3.41 34.44,3.65 34.17,3.81 34.19,3.87 33.98,4.22
|
||||||
|
33.90,-1.00 37.64,-3.05 37.69,-3.25 37.62,-3.51 39.22,-4.69
|
||||||
|
11.33,2.17 9.98,2.17 9.80,2.30
|
||||||
|
11.33,2.17 11.34,1.00 10.03,1.00 9.91,0.96 9.76,1.07 9.59,1.03
|
||||||
|
25.26,-17.79 24.91,-17.82 24.53,-18.05 24.36,-17.98 24.24,-18.02 23.60,-18.46 23.30,-18.03 23.22,-18.00 20.97,-18.32 20.98,-21.96 19.98,-22.00 19.98,-24.75
|
||||||
|
19.98,-24.78 19.98,-28.45 19.54,-28.57 19.31,-28.73 19.25,-28.90 19.16,-28.94 18.84,-28.87 18.10,-28.87 17.45,-28.70 17.35,-28.50 17.36,-28.27 17.06,-28.03 16.88,-28.13 16.76,-28.45 16.45,-28.62
|
||||||
|
19.98,-24.75 19.98,-24.78
|
||||||
|
0.90,10.99 0.79,10.71 0.76,10.39 1.33,10.00 1.39,9.36 1.60,9.05 1.62,7.00 1.53,6.99 1.60,6.61 1.78,6.29 1.62,6.22
|
||||||
|
42.80,16.37 43.17,16.69 43.18,16.81 43.12,16.94 43.16,17.21 43.24,17.27 43.19,17.36 43.42,17.52 43.92,17.32 44.16,17.40 45.15,17.43 45.54,17.30 46.31,17.23 46.73,17.27 46.98,16.95 47.14,16.95 47.44,17.11 47.58,17.45 48.17,18.16 49.04,18.58 51.98,19.00
|
||||||
|
51.98,19.00 52.73,17.30 52.80,17.27 53.09,16.65
|
||||||
|
55.19,22.70 55.64,22.00 54.98,20.00 51.98,19.00
|
||||||
|
55.19,22.70 55.10,22.62 52.56,22.93 51.59,24.08 51.57,24.29
|
||||||
|
42.36,37.11 41.79,36.60 41.42,36.51 41.30,36.38 41.25,36.07 41.35,35.64 41.22,35.29 41.19,34.77 40.99,34.43 40.69,34.33 38.77,33.37
|
||||||
|
35.79,32.73 36.37,32.39 36.82,32.32 38.77,33.37
|
||||||
|
35.61,32.68 35.79,32.73
|
||||||
|
39.15,32.12 39.29,32.24 39.25,32.35 39.04,32.31 38.98,32.47 39.06,32.49 38.77,33.37
|
||||||
|
34.95,29.35 36.07,29.20 36.48,29.50 36.76,29.87 37.47,30.00 37.65,30.33 37.98,30.50 36.96,31.49 38.96,31.99 39.15,32.12
|
||||||
|
46.53,29.10 46.36,29.06 44.69,29.20 42.07,31.08 40.37,31.94 39.15,32.12
|
||||||
|
61.59,25.20 61.66,25.75 61.75,25.84 61.84,26.23 62.13,26.37 62.24,26.36 62.31,26.49 62.44,26.56 63.16,26.65 63.30,27.15 63.17,27.25 62.76,27.25 62.81,27.50 62.76,28.24 62.56,28.24 62.35,28.41 61.89,28.55 61.62,28.79 61.32,29.37 60.84,29.86
|
||||||
|
60.84,29.86 61.81,30.91 61.76,31.29 61.66,31.38 60.82,31.50 60.83,32.25 60.56,33.06 60.56,33.14 60.92,33.51 60.51,33.64 60.49,34.09 60.64,34.31 60.89,34.32 60.73,34.52 60.95,34.65 61.08,34.86 61.15,35.09 61.10,35.27 61.19,35.31 61.28,35.51 61.26,35.62
|
||||||
|
73.73,36.89 74.04,36.83 74.54,37.02
|
||||||
|
60.84,29.86 62.48,29.41 63.57,29.50 64.10,29.39 64.39,29.54 65.10,29.56 66.18,29.84 66.31,29.97 66.24,30.11 66.35,30.80 66.40,30.91 66.60,31.02 66.83,31.26 66.92,31.31 67.45,31.23 67.74,31.34 67.58,31.51 68.02,31.68 68.16,31.80 68.44,31.75 68.60,31.80 68.87,31.63 69.08,31.74 69.28,31.94 69.24,32.43 69.41,32.68 69.50,33.02 69.92,33.11 70.26,33.29 70.28,33.37 70.13,33.62 69.87,33.90 69.89,34.01 69.99,34.05 70.33,33.96 70.65,33.95 71.05,34.05 71.10,34.37 70.97,34.53 71.62,35.18 71.55,35.29 71.60,35.41 71.57,35.55 71.40,35.88 71.19,36.04 71.23,36.12 71.62,36.44 71.77,36.43 72.25,36.73 72.62,36.83 73.73,36.89
|
||||||
|
70.95,42.25 70.72,42.17 70.58,42.04 70.42,42.08 70.10,41.82 69.15,41.43 68.99,41.21 68.58,40.88 68.57,40.62 68.42,40.62 68.05,40.81 68.11,41.03 67.94,41.20 66.71,41.18 66.50,41.99 66.01,42.00 66.10,42.99 65.80,42.88 65.50,43.31 64.91,43.71 64.44,43.55 63.21,43.63 61.99,43.49 61.16,44.17 61.01,44.39 58.56,45.56 55.98,44.99 55.98,41.32
|
||||||
|
13.61,13.70 13.45,14.38 14.37,15.75 15.47,16.91 15.74,19.90 15.96,20.35 15.59,20.73 15.54,20.87 15.61,20.95 15.18,21.52 15.17,21.92 14.98,23.00
|
||||||
|
11.97,23.52 13.48,23.18 14.22,22.62 14.98,23.00
|
||||||
|
0.22,14.91 0.29,14.98 0.95,14.98 1.30,15.27 3.00,15.34 3.06,15.43 3.50,15.36 3.52,15.48 3.84,15.70 4.12,16.36 4.23,17.00 4.23,19.14
|
||||||
|
-0.07,11.12 -0.30,11.17 -0.63,10.93 -0.70,10.99 -1.04,11.01 -2.83,11.00 -2.91,10.59 -2.79,10.43 -2.78,9.75 -2.70,9.48
|
||||||
|
-13.29,9.05 -13.03,9.10 -12.96,9.26 -12.76,9.37 -12.50,9.86 -12.43,9.90 -12.28,9.93 -12.14,9.88 -11.92,9.92 -11.91,9.99 -11.27,10.00 -10.69,9.31 -10.75,9.10 -10.62,9.06 -10.61,8.87 -10.50,8.69 -10.71,8.34 -10.56,8.32 -10.39,8.48 -10.28,8.49
|
||||||
|
5.79,49.54 6.01,49.45 6.18,49.50 6.34,49.45
|
||||||
|
56.08,26.06 56.17,26.05 56.14,25.69 56.30,25.65
|
||||||
|
30.51,-1.07 30.36,-1.07 30.10,-1.37 29.93,-1.47 29.83,-1.34 29.58,-1.39
|
||||||
|
30.51,-1.07 30.51,-1.21 30.81,-1.56 30.88,-2.14 30.83,-2.34 30.55,-2.40
|
||||||
|
33.90,-1.00 30.81,-0.99 30.51,-1.07
|
||||||
|
34.59,-11.97 34.54,-12.02 34.62,-12.07 34.59,-11.97
|
||||||
|
87.32,49.09 87.82,49.16
|
||||||
|
45.50,40.66 45.48,40.61 45.55,40.62 45.50,40.66
|
||||||
|
44.99,41.09 45.00,41.02 44.99,41.09
|
||||||
|
12.44,43.98 12.40,43.93 12.49,43.90 12.50,43.99 12.44,43.98
|
||||||
|
70.57,41.02 70.65,40.94 70.57,41.02
|
||||||
|
-117.13,32.53 -114.72,32.72 -114.84,32.51 -111.04,31.32 -108.21,31.33 -108.21,31.78 -106.45,31.77 -106.15,31.45 -104.92,30.58 -104.68,30.13 -104.62,29.85 -104.40,29.57 -103.26,29.00 -103.09,29.04 -102.89,29.22 -102.73,29.64 -102.34,29.86 -101.99,29.80 -101.55,29.81 -101.38,29.74 -100.75,29.18 -100.33,28.50 -100.30,28.33 -99.75,27.73 -99.51,27.55 -99.46,27.06 -99.23,26.76 -99.11,26.45 -98.28,26.11 -97.80,26.04 -97.38,25.87 -97.15,25.96
|
||||||
|
-92.24,14.55 -92.16,14.69 -92.16,14.96 -92.07,15.07 -92.20,15.28 -91.74,16.07 -90.45,16.07 -90.42,16.39 -90.63,16.51 -90.71,16.71 -91.41,17.26 -90.99,17.25 -90.99,17.82 -89.16,17.81 -89.13,17.97 -89.05,18.00 -88.90,17.91 -88.81,17.97 -88.46,18.48 -88.04,18.42 -88.04,18.20 -87.83,18.18
|
||||||
|
-54.62,-25.58 -54.61,-25.43 -54.44,-25.12 -54.28,-24.31 -54.32,-24.13 -54.24,-24.05 -54.63,-23.81 -55.19,-24.02 -55.42,-23.95 -55.65,-22.89 -55.62,-22.67 -55.85,-22.31 -56.25,-22.26 -56.45,-22.08 -56.63,-22.23 -56.94,-22.27 -57.57,-22.18 -57.72,-22.10 -57.96,-22.11 -57.95,-21.49 -57.83,-21.00 -58.16,-20.16
|
||||||
|
-62.65,-22.23 -62.21,-22.61 -61.80,-23.18 -61.21,-23.56 -61.03,-23.76 -60.51,-23.96 -59.89,-24.09 -59.19,-24.56 -58.37,-24.96 -57.96,-25.05 -57.64,-25.33 -57.57,-25.53 -57.75,-25.70 -57.89,-26.01 -58.11,-26.18 -58.20,-26.38 -58.19,-26.63 -58.32,-26.86 -58.62,-27.13 -58.60,-27.31 -58.17,-27.27 -57.11,-27.47 -56.60,-27.47 -56.44,-27.55 -56.16,-27.32 -55.71,-27.41 -55.43,-27.01 -55.14,-26.93 -54.83,-26.65 -54.68,-26.31 -54.62,-25.58
|
||||||
|
-62.65,-22.23 -62.84,-22.00 -63.92,-22.03 -64.27,-22.60 -64.33,-22.83 -64.61,-22.23 -64.99,-22.11 -65.77,-22.10 -66.22,-21.80 -66.37,-22.11 -66.71,-22.22 -66.80,-22.41 -67.03,-22.55 -67.19,-22.82
|
||||||
|
26.62,48.26 26.85,48.39 27.23,48.37 27.55,48.48 27.82,48.42 28.09,48.26 28.29,48.24 28.46,48.09 28.53,48.15 28.77,48.12 28.92,47.95 29.13,47.96 29.21,47.78 29.13,47.49 29.54,47.27 29.51,47.13 29.57,46.96 29.88,46.83 29.94,46.72 29.92,46.54 30.13,46.42 29.84,46.35 29.71,46.45 29.61,46.40 29.30,46.47 29.20,46.38 29.19,46.52 28.96,46.46 28.93,46.36 29.01,46.18 28.95,46.05 28.74,45.94 28.73,45.85 28.49,45.67 28.50,45.52 28.21,45.45
|
||||||
|
67.76,37.17 67.96,36.97 68.07,36.95 68.91,37.33 69.30,37.12 69.41,37.21 69.40,37.40 69.49,37.55 69.82,37.61 70.04,37.55 70.19,37.58 70.25,37.66 70.21,37.92 70.62,38.33 70.88,38.46 71.26,38.31 71.33,38.17 71.28,37.92 71.58,37.91 71.43,37.13 71.53,36.85 71.67,36.70 71.80,36.69 72.36,36.98 72.66,37.03 72.90,37.27 73.38,37.46 73.72,37.42 73.65,37.24 74.17,37.33 74.35,37.42 74.66,37.39 74.89,37.23
|
||||||
|
66.52,37.35 66.83,37.37 67.07,37.33 67.20,37.24 67.52,37.27 67.76,37.17
|
||||||
|
61.26,35.62 61.34,35.63 61.62,35.43 61.98,35.44 62.31,35.17 62.46,35.25 62.69,35.26 63.06,35.45 63.17,35.68 63.13,35.85 64.01,36.01 64.51,36.34 64.82,37.13 65.09,37.24 65.55,37.25 65.64,37.47 65.77,37.57 66.11,37.41 66.52,37.35
|
||||||
|
92.57,21.98 92.49,22.69 92.36,22.93 92.25,23.68 91.93,23.69 91.94,23.50 91.75,23.29 91.75,23.05 91.62,22.98 91.51,23.03 91.44,23.20 91.37,23.20 91.37,23.07 91.32,23.10 91.16,23.66 91.23,23.92 91.37,24.09 91.88,24.20 91.95,24.36 92.10,24.41 92.23,24.88 92.44,24.85 92.47,24.94 92.05,25.17 90.44,25.16 89.83,25.29 89.82,25.94 89.67,26.21 89.59,26.19 89.55,26.01 89.37,26.01 89.19,26.11 89.02,26.41 88.92,26.38 88.97,26.25 88.83,26.25 88.68,26.29 88.62,26.43 88.42,26.57 88.35,26.50 88.44,26.37 88.15,26.09 88.08,25.89 88.50,25.54 88.77,25.49 88.95,25.26 88.82,25.18 88.46,25.19 88.31,24.88 88.15,24.91 88.02,24.63 88.15,24.49 88.29,24.48 88.72,24.27 88.70,24.00 88.57,23.67 88.74,23.44 88.72,23.25 88.93,23.19 88.85,23.04 89.05,22.09
|
||||||
|
2.39,11.90 2.37,12.22 2.81,12.38 3.60,11.70
|
||||||
|
18.61,3.48 18.62,3.30 18.49,2.92 18.21,2.41 18.07,2.01 18.06,1.53 17.90,1.12 17.89,0.23 17.72,-0.28 17.75,-0.55 17.28,-1.00 16.88,-1.23 16.54,-1.84 16.22,-2.18 16.22,-3.03 16.15,-3.46 15.99,-3.77 15.87,-3.93 15.60,-4.03 15.27,-4.31 14.71,-4.88 14.41,-4.83 14.37,-4.59 14.45,-4.45 14.36,-4.30 13.94,-4.48 13.72,-4.45 13.66,-4.72 13.41,-4.84 13.14,-4.60 13.07,-4.63
|
||||||
|
28.98,-30.00 29.10,-29.92 29.14,-29.70 29.29,-29.57 29.39,-29.27 29.30,-29.09 28.95,-28.88 28.63,-28.58 27.74,-28.94 27.29,-29.52 27.06,-29.63 27.39,-30.32 27.75,-30.60 28.06,-30.63 28.39,-30.15 28.65,-30.13 28.98,-30.00
|
||||||
|
29.36,-22.19 29.66,-22.15 30.19,-22.29 30.92,-22.29 31.29,-22.40
|
||||||
|
19.98,-24.78 20.43,-25.15 20.79,-25.92 20.82,-26.16 20.63,-26.44 20.69,-26.82 21.69,-26.84 21.79,-26.71 22.09,-26.58 22.22,-26.39 22.60,-26.13 22.88,-25.46 23.06,-25.31 23.39,-25.29 23.89,-25.60 24.19,-25.63 24.33,-25.74 24.75,-25.82 25.44,-25.71 25.58,-25.61 25.91,-24.75 26.40,-24.61 26.84,-24.24 26.99,-23.70 27.09,-23.58 27.76,-23.20 28.21,-22.69 28.38,-22.59 28.84,-22.48 29.13,-22.21 29.36,-22.19
|
||||||
|
34.96,-11.58 35.56,-11.60 35.79,-11.45 35.91,-11.45 36.31,-11.71 36.67,-11.68 36.98,-11.57 37.37,-11.71 37.72,-11.58 37.92,-11.29 38.18,-11.28 38.49,-11.41 38.79,-11.23 39.32,-11.12 39.99,-10.82 40.46,-10.46
|
||||||
|
-84.35,10.98 -84.17,10.78 -83.92,10.74 -83.71,10.79 -83.64,10.92
|
||||||
|
28.15,56.14 27.90,56.08 27.64,55.91 27.58,55.80 27.05,55.83 26.59,55.67
|
||||||
|
28.21,45.45 28.45,45.29 28.76,45.23 28.78,45.31 29.40,45.42 29.71,45.26
|
||||||
|
21.36,44.83 21.91,44.67 22.09,44.54 22.50,44.71 22.64,44.65 22.73,44.57 22.55,44.54 22.49,44.44 22.71,44.24
|
||||||
|
56.23,25.30 56.24,25.21 56.29,25.28 56.23,25.30
|
||||||
|
32.11,-26.84 32.89,-26.85
|
||||||
|
71.74,39.98 71.67,39.95 71.75,39.91 71.79,40.00 71.74,39.98
|
||||||
|
-122.69,48.99 -95.16,48.99 -95.16,49.37 -94.85,49.30 -94.80,49.00 -94.62,48.74 -94.06,48.66 -93.71,48.53 -93.38,48.62 -93.00,48.61 -92.50,48.44 -92.41,48.28 -92.17,48.34 -92.01,48.30 -91.52,48.06 -90.92,48.21 -90.74,48.10 -90.09,48.12 -89.90,48.00 -89.46,48.00 -88.38,48.30 -84.88,46.90 -84.78,46.64 -84.56,46.46 -84.15,46.54 -84.09,46.23 -83.98,46.08 -83.62,46.12 -83.47,45.99 -83.59,45.82 -82.55,45.35 -82.14,43.57 -82.41,43.07 -82.55,42.62 -83.07,42.30 -83.15,42.14 -83.14,41.98 -83.03,41.83 -82.69,41.68 -82.44,41.67 -81.28,42.21 -80.25,42.37 -79.04,42.80 -78.92,42.91 -79.03,43.02 -79.06,43.28 -79.17,43.47 -78.85,43.58 -76.82,43.63 -76.46,44.06 -76.15,44.30 -75.00,44.97 -71.52,45.01 -71.33,45.29 -71.13,45.26 -71.00,45.34 -70.87,45.27 -70.80,45.40 -70.69,45.43 -70.70,45.55 -70.42,45.74 -70.30,45.91 -70.25,46.25 -70.07,46.44 -70.01,46.71 -69.24,47.46 -69.05,47.43 -69.05,47.27 -68.94,47.21 -68.24,47.35 -67.81,47.08 -67.80,45.73 -67.43,45.60 -67.49,45.47 -67.43,45.38 -67.47,45.28 -67.37,45.17 -67.12,45.17
|
||||||
|
34.96,-11.58 34.89,-11.39 34.77,-11.34 34.61,-11.08 34.66,-10.71 34.58,-10.53 34.52,-10.03 34.32,-9.73 34.09,-9.54 34.00,-9.50 33.95,-9.66 33.89,-9.67
|
||||||
|
107.97,21.51 107.76,21.66 107.35,21.61 107.27,21.71 107.06,21.79 106.97,21.92 106.66,21.98 106.65,22.24 106.55,22.34 106.55,22.50 106.78,22.78 106.54,22.91 106.28,22.86 106.15,22.97 105.84,22.92 105.55,23.07 105.28,23.35 104.86,23.14 104.80,22.91 104.69,22.82 104.37,22.70 104.14,22.80 104.05,22.75 103.94,22.54 103.62,22.78 103.49,22.59 103.33,22.77 102.98,22.45 102.83,22.59 102.47,22.75 102.13,22.38
|
||||||
|
7.44,43.75 7.38,43.73
|
||||||
|
12.43,41.91 12.43,41.91
|
||||||
|
113.55,22.22 113.48,22.20
|
||||||
|
114.02,22.51 114.12,22.57 114.27,22.54
|
||||||
|
70.61,39.79 70.48,39.88 70.70,39.85 70.61,39.79
|
||||||
|
71.22,39.91 71.01,39.90 71.04,39.99 70.97,40.04 70.98,40.13 71.23,40.05 71.18,39.98 71.22,39.91
|
||||||
|
145.94,43.10 145.78,43.48 145.69,43.55 145.54,43.51 145.24,43.78 145.27,43.91 145.62,44.29 145.66,44.45 145.58,44.79
|
||||||
|
-1.28,32.09 -1.23,32.11 -1.24,32.34 -1.07,32.47 -1.45,32.78 -1.68,33.32 -1.63,33.57 -1.71,33.78 -1.71,34.18 -1.79,34.37 -1.73,34.47 -1.85,34.61 -1.80,34.75 -2.13,34.97 -2.22,35.10
|
||||||
|
29.00,9.61 29.47,9.77 29.60,9.92 29.61,10.07 30.00,10.28 30.76,9.73 31.22,9.80 31.79,10.38 31.93,10.66 32.42,11.09 32.34,11.31 32.34,11.72 32.07,12.01 32.74,12.01 32.72,12.22 33.20,12.22 33.07,11.59 33.17,10.85 33.13,10.76 33.38,10.65 33.91,10.18 33.96,9.86 33.87,9.51 34.08,9.46
|
||||||
|
24.15,8.67 24.21,8.77 24.53,8.89 24.67,9.39 24.78,9.53 24.79,9.81 24.96,9.99 25.02,10.24 25.10,10.31 25.86,10.41 25.92,10.17 26.55,9.53 26.66,9.48 27.07,9.61 27.88,9.60
|
||||||
|
27.88,9.60 28.05,9.33 28.84,9.33 28.84,9.46 29.00,9.61
|
||||||
|
-56.48,1.94 -56.70,2.04 -57.20,2.85 -57.30,3.38 -57.60,3.37
|
||||||
|
-54.62,2.33 -54.40,2.46 -54.20,2.82 -54.20,3.14 -54.08,3.33
|
||||||
|
33.89,-9.67 33.70,-9.60 33.42,-9.61 33.33,-9.52 32.92,-9.41
|
||||||
|
33.59,46.10 33.66,46.22 34.22,46.10 34.45,45.97 34.69,45.98 34.79,45.89 34.80,45.79 35.00,45.73
|
||||||
|
77.05,35.11 76.77,35.66
|
||||||
|
77.05,35.11 77.80,35.50
|
||||||
|
79.12,33.23 79.38,33.15 79.33,32.98 79.50,32.73 79.38,32.57 79.22,32.50
|
||||||
|
79.22,32.50 78.92,32.36 78.70,32.60 78.41,32.56 78.49,32.23
|
||||||
|
78.49,32.23 78.73,31.98 78.72,31.88
|
||||||
|
78.72,31.88 78.69,31.74 78.80,31.62 78.73,31.47 78.74,31.32 78.84,31.30
|
||||||
|
78.84,31.30 78.97,31.33 79.01,31.41 79.11,31.40 79.37,31.08
|
||||||
|
79.37,31.08 79.57,30.95 79.79,30.97 79.91,30.90
|
||||||
|
79.91,30.90 80.19,30.76 80.19,30.57
|
||||||
|
80.19,30.57 80.68,30.41 80.99,30.24 81.01,30.16
|
||||||
|
90.33,28.12 90.35,28.24 90.22,28.28 89.98,28.31 89.80,28.24
|
||||||
|
89.80,28.24 89.48,28.06 89.12,27.62
|
||||||
|
89.12,27.62 89.02,27.51
|
||||||
|
89.02,27.51 88.89,27.32
|
||||||
|
75.30,32.32 75.32,32.22 75.25,32.14 74.74,31.95 74.56,31.82 74.51,31.71 74.59,31.47 74.52,31.19 74.63,31.03 74.34,30.89 73.90,30.44 73.93,30.22 73.81,30.09 73.38,29.93 73.23,29.55 72.90,29.03 72.34,28.75 72.13,28.35 71.95,28.18 71.87,27.96 70.80,27.71 70.69,27.77 70.63,27.94 70.40,28.03 70.14,27.85 69.90,27.47 69.54,27.12 69.47,26.80 69.51,26.74 70.15,26.51 70.10,25.91 70.26,25.71 70.65,25.67 70.65,25.42 71.05,24.69 70.97,24.57 71.04,24.40 70.72,24.24 70.58,24.28 70.55,24.42 70.49,24.41 70.10,24.29 70.02,24.19 69.81,24.17 69.56,24.27 68.83,24.26 68.78,24.31 68.73,24.27 68.72,23.96 68.38,23.95 68.17,23.86
|
||||||
|
36.69,45.62 36.69,45.43 36.59,45.30 36.64,45.26 36.51,45.20 36.61,44.95
|
||||||
|
-55.81,-31.03 -56.00,-31.08 -56.00,-30.85
|
||||||
|
-56.00,-30.85 -56.18,-30.63 -56.83,-30.11 -57.12,-30.14 -57.21,-30.28 -57.55,-30.26 -57.61,-30.19
|
||||||
|
-73.48,-49.77 -73.58,-49.58 -73.46,-49.31 -73.14,-49.30
|
||||||
|
-73.14,-49.30 -73.15,-49.19 -73.03,-49.01 -72.65,-48.84 -72.58,-48.48 -72.35,-48.37 -72.29,-48.23 -72.33,-48.11 -72.51,-47.97 -72.52,-47.88 -72.35,-47.49 -72.04,-47.24 -71.90,-47.20 -71.96,-47.02 -71.94,-46.83 -71.70,-46.65 -71.76,-46.32 -71.88,-46.16 -71.63,-45.95 -71.75,-45.84 -71.75,-45.58 -71.51,-45.51 -71.35,-45.33 -71.35,-45.23 -71.60,-44.98 -72.04,-44.90 -72.06,-44.77 -71.26,-44.76 -71.16,-44.56 -71.21,-44.44 -71.82,-44.38 -71.81,-44.11 -71.68,-43.93 -71.79,-43.75 -71.74,-43.70 -71.75,-43.59 -71.90,-43.44 -71.90,-43.35 -71.75,-43.24 -71.78,-43.17 -72.10,-43.07 -72.14,-42.58 -72.05,-42.47 -72.11,-42.25 -72.03,-42.15 -71.86,-42.15 -71.75,-42.05 -71.91,-41.65 -71.87,-40.89 -71.94,-40.79 -71.80,-40.44 -71.70,-40.34 -71.82,-40.18 -71.66,-40.02 -71.64,-39.89 -71.72,-39.64 -71.54,-39.60 -71.42,-39.29 -71.40,-38.94 -70.95,-38.74 -70.85,-38.54 -70.97,-38.45 -71.03,-38.04 -71.17,-37.76 -71.13,-37.45 -71.20,-37.30 -71.12,-37.11 -71.19,-36.84 -71.06,-36.52 -70.75,-36.39 -70.72,-36.28 -70.40,-36.06 -70.42,-35.52 -70.56,-35.25 -70.39,-35.15 -70.29,-34.73 -70.05,-34.30 -69.85,-34.22 -69.89,-33.73 -69.80,-33.40 -69.82,-33.28 -70.02,-33.27 -70.08,-33.20 -70.09,-33.03 -70.02,-32.88 -70.12,-32.81 -70.17,-32.47 -70.32,-32.27 -70.36,-32.08 -70.25,-31.96 -70.45,-31.84 -70.59,-31.57 -70.52,-31.15 -70.39,-31.12 -70.31,-31.02 -70.35,-30.90 -70.15,-30.36 -69.96,-30.36 -69.84,-30.18 -69.96,-30.08 -69.93,-29.77 -70.03,-29.32 -69.83,-29.10 -69.66,-28.41 -69.17,-27.92 -68.85,-27.15 -68.71,-27.10 -68.59,-27.14 -68.32,-26.97 -68.32,-26.88 -68.59,-26.47 -68.58,-26.35 -68.41,-26.15 -68.59,-25.42 -68.50,-25.16 -68.38,-25.09 -68.56,-24.84 -68.56,-24.75 -68.25,-24.39 -67.36,-24.03 -67.01,-23.00 -67.19,-22.82
|
||||||
|
75.32,34.58 74.30,34.77 73.96,34.65 73.81,34.42 73.81,34.33 73.97,34.24 73.92,34.04 74.25,33.99 74.22,33.89 74.00,33.79 73.98,33.72 74.15,33.51 73.99,33.22 74.30,32.99 74.31,32.81
|
||||||
|
23.61,51.52 23.54,51.71 23.63,51.81 23.65,52.04 23.18,52.29 23.41,52.52 23.84,52.66 23.92,52.77 23.86,53.11 23.48,53.94
|
||||||
|
34.08,9.46 34.16,9.85 34.31,10.19 34.28,10.53 34.34,10.66 34.57,10.88 34.77,10.75 34.93,10.86 34.96,11.28 35.11,11.82 35.67,12.62 36.13,12.76 36.16,13.09 36.39,13.63 36.52,14.26
|
||||||
|
2.39,11.90 2.29,11.69 1.98,11.42 1.43,11.45 1.32,11.30 1.15,11.25 1.08,11.08 0.99,11.08 0.90,10.99
|
||||||
|
31.95,-25.96 31.87,-25.98 31.42,-25.75 31.21,-25.84 30.80,-26.41 30.79,-26.76 30.88,-26.79 31.06,-27.11 31.47,-27.30 31.96,-27.31 31.99,-26.82 32.11,-26.84
|
||||||
|
32.11,-26.84 32.06,-26.02 31.95,-25.96
|
||||||
|
76.77,35.66 76.56,35.77 76.55,35.89 76.15,35.83 76.07,35.98 75.91,36.05 75.97,36.38
|
||||||
|
27.33,5.19 27.79,4.64 28.19,4.35 28.43,4.32 28.73,4.50 29.22,4.39 29.47,4.61 29.68,4.59 30.19,3.98 30.51,3.84 30.59,3.62 30.76,3.62 30.84,3.49
|
||||||
|
22.86,10.92 23.31,10.39 23.65,9.82 23.62,9.34 23.47,9.11 23.55,8.94 23.54,8.82 23.68,8.73 24.15,8.67
|
||||||
|
46.11,38.88 46.49,38.91
|
||||||
|
44.78,39.68 45.14,39.25 45.48,39.01 46.11,38.88
|
||||||
|
46.49,38.91 47.48,39.50 47.77,39.65 48.00,39.68 48.32,39.40 48.10,39.24 48.29,39.02 48.05,38.93 48.00,38.85 48.59,38.41 48.87,38.44
|
||||||
|
81.01,30.16 80.85,30.14 80.40,29.73 80.25,29.42 80.23,29.19 80.08,28.99 80.07,28.83 80.42,28.61 80.59,28.65 81.85,27.87 81.99,27.91 82.45,27.67 82.68,27.67 82.73,27.52 83.29,27.37 83.45,27.47 83.83,27.38 84.09,27.49 84.61,27.30 84.69,27.04 85.13,26.86 85.24,26.75 85.57,26.84 85.70,26.78 85.79,26.60 86.01,26.65 86.70,26.44 87.02,26.56 87.09,26.43 87.29,26.36 87.41,26.42 87.85,26.44 88.00,26.38 88.16,26.72 87.98,27.13 88.15,27.75 88.11,27.87
|
||||||
|
75.97,36.38 75.84,36.65 75.67,36.74 75.42,36.74 75.35,36.91 74.54,37.02
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
|
||||||
|
symbolic fill lets GTK recolour it with the surrounding control. -->
|
||||||
|
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A copy is on this computer: a check in an outlined circle. Adwaita 50
|
||||||
|
no longer ships emblem-ok-symbolic. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 8,2.5 A 5.5,5.5 0 1 1 8,13.5 A 5.5,5.5 0 1 1 8,2.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.1,8.6 L 5.2,7.5 L 7,9.3 L 10.8,5.4 L 11.9,6.5 L 7,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 450 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show or hide the details pane: a window with its right-hand pane
|
||||||
|
filled in. Some themes draw sidebar-show-right-symbolic as a solid block. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 3,2 H 13 A 2,2 0 0 1 15,4 V 12 A 2,2 0 0 1 13,14 H 3 A 2,2 0 0 1 1,12 V 4 A 2,2 0 0 1 3,2 Z M 3,3.5 A 0.5,0.5 0 0 0 2.5,4 V 12 A 0.5,0.5 0 0 0 3,12.5 H 13 A 0.5,0.5 0 0 0 13.5,12 V 4 A 0.5,0.5 0 0 0 13,3.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.5,5 H 12.5 V 11 H 9.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Filters": a funnel, wide at the top and narrowing to a short spout.
|
||||||
|
No stock icon theme ships one under a standard name. -->
|
||||||
|
<path fill="#222222" d="M 1.5,2 H 14.5 L 9.5,8.5 V 13.5 L 6.5,15 V 8.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- A share link: two open chain ends and the bar that joins them, laid
|
||||||
|
on the diagonal. Written out in plain coordinates: GTK's symbolic icon
|
||||||
|
renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 4.82,6.23 L 3.05,8 A 3.5,3.5 0 0 0 8,12.95 L 9.77,11.18 L 8.53,9.94 L 6.76,11.71 A 1.75,1.75 0 0 1 4.29,9.24 L 6.06,7.47 Z"/>
|
||||||
|
<path fill="#222222" d="M 6.23,4.82 L 8,3.05 A 3.5,3.5 0 0 1 12.95,8 L 11.18,9.77 L 9.94,8.53 L 11.71,6.76 A 1.75,1.75 0 0 0 9.24,4.29 L 7.47,6.06 Z"/>
|
||||||
|
<path fill="#222222" d="M 5.26,9.5 L 9.5,5.26 L 10.74,6.5 L 6.5,10.74 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Show on map: a folded map in three panels. No stock theme has a
|
||||||
|
map-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 1,3.2 L 5,1.5 V 12.8 L 1,14.5 Z M 11,3.2 L 15,1.5 V 12.8 L 11,14.5 Z"/>
|
||||||
|
<path fill="#222222" opacity="0.55" d="M 6,1.5 L 10,3.2 V 14.5 L 6,12.8 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
|
||||||
|
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
|
||||||
|
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 441 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||||
|
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||||
|
Replaces the star, which every desktop uses for "favourite". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||||
|
folder full of cloud-only files does not shout. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared with me: two people. system-users-symbolic survives only in
|
||||||
|
Adwaita's legacy folder. -->
|
||||||
|
<path fill="#222222" d="M 6,2.5 A 2.5,2.5 0 1 0 6,7.5 A 2.5,2.5 0 1 0 6,2.5 Z M 1.5,14.5 C 1.5,11 3.5,9 6,9 C 8.5,9 10.5,11 10.5,14.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 11,2.4 A 2.1,2.1 0 1 0 11,6.6 A 2.1,2.1 0 1 0 11,2.4 Z M 9.8,8.4 C 10.2,8.3 10.6,8.25 11,8.25 C 13.3,8.25 15,10 15,13.5 H 11.8 C 11.7,11.5 11,9.7 9.8,8.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Shared by me: one node passing to two others. Adwaita 50 no longer
|
||||||
|
ships emblem-shared-symbolic. -->
|
||||||
|
<path fill="#222222" d="M 12,1.25 A 2.25,2.25 0 1 0 12,5.75 A 2.25,2.25 0 1 0 12,1.25 Z M 4,5.75 A 2.25,2.25 0 1 0 4,10.25 A 2.25,2.25 0 1 0 4,5.75 Z"/>
|
||||||
|
<path fill="#222222" d="M 4.37,8.65 L 12.37,4.15 L 11.63,2.85 L 3.63,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 12,10.25 A 2.25,2.25 0 1 0 12,14.75 A 2.25,2.25 0 1 0 12,10.25 Z"/>
|
||||||
|
<path fill="#222222" d="M 3.63,8.65 L 11.63,13.15 L 12.37,11.85 L 4.37,7.35 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 621 B |