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 |
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.9.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.6.1" }
|
proton-sdk = { version = "0.7.0" }
|
||||||
proton-drive-rs = { version = "0.6.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,24 +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
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
# directories and streams entries out without extracting to disk first.
|
# directories and streams entries out without extracting to disk first.
|
||||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
# import has to speak SHA-1 whatever its age.
|
# import has to speak SHA-1 whatever its age.
|
||||||
sha1 = "0.10"
|
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]
|
||||||
@@ -21,7 +23,8 @@ sha2.workspace = true
|
|||||||
sha1.workspace = true
|
sha1.workspace = true
|
||||||
zip.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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -297,6 +298,10 @@ pub struct Pin {
|
|||||||
/// which is why it is defaulted.
|
/// which is why it is defaulted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_dir: Option<bool>,
|
pub is_dir: Option<bool>,
|
||||||
|
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||||
|
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
@@ -415,6 +420,10 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||||
thumb_dir: PathBuf,
|
thumb_dir: PathBuf,
|
||||||
|
/// Owner-protected scratch space for decrypted camera RAWs passed to
|
||||||
|
/// exiftool. Files are removed on drop and swept on the next cache open after
|
||||||
|
/// an unclean shutdown.
|
||||||
|
raw_thumb_dir: PathBuf,
|
||||||
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||||
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||||
/// sees them; blocks carry their own LRU budget.
|
/// sees them; blocks carry their own LRU budget.
|
||||||
@@ -507,6 +516,19 @@ impl ContentCache {
|
|||||||
std::fs::create_dir_all(&content_dir)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
|
let raw_thumb_dir = content_dir.join("raw-thumbnails");
|
||||||
|
std::fs::create_dir_all(&raw_thumb_dir)?;
|
||||||
|
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_string_lossy().starts_with("pdfs-raw-")
|
||||||
|
&& entry.file_type().is_ok_and(|kind| kind.is_file())
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let block_dir = content_dir.join("blocks");
|
let block_dir = content_dir.join("blocks");
|
||||||
std::fs::create_dir_all(&block_dir)?;
|
std::fs::create_dir_all(&block_dir)?;
|
||||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||||
@@ -530,6 +552,7 @@ impl ContentCache {
|
|||||||
let cache = Self {
|
let cache = Self {
|
||||||
content_dir,
|
content_dir,
|
||||||
thumb_dir,
|
thumb_dir,
|
||||||
|
raw_thumb_dir,
|
||||||
block_dir,
|
block_dir,
|
||||||
scratch_dir,
|
scratch_dir,
|
||||||
staging_dir,
|
staging_dir,
|
||||||
@@ -1666,6 +1689,12 @@ impl ContentCache {
|
|||||||
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owner-protected transient directory used to stage decrypted camera RAWs
|
||||||
|
/// for metadata helpers. Callers still remove individual files promptly.
|
||||||
|
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
|
||||||
|
&self.raw_thumb_dir
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
||||||
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
||||||
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
||||||
@@ -1829,6 +1858,7 @@ impl ContentCache {
|
|||||||
path: row.path,
|
path: row.path,
|
||||||
recursive: row.recursive,
|
recursive: row.recursive,
|
||||||
is_dir: row.is_dir,
|
is_dir: row.is_dir,
|
||||||
|
cached: false,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +29,17 @@ 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.
|
||||||
|
|||||||
@@ -178,16 +178,33 @@ 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.read();
|
let conn = self.read();
|
||||||
@@ -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<_>>()?;
|
||||||
|
|||||||
@@ -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 = 28;
|
pub(super) const SCHEMA_VERSION: i64 = 35;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -218,6 +218,120 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V28)?;
|
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",
|
||||||
@@ -632,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
|
||||||
@@ -846,3 +960,102 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
|||||||
const MIGRATION_V28: &str = "
|
const MIGRATION_V28: &str = "
|
||||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
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;
|
||||||
|
";
|
||||||
@@ -56,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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -355,6 +355,22 @@ 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());
|
||||||
@@ -365,9 +381,10 @@ impl Db {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid, path 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_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
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.
|
||||||
@@ -380,9 +397,10 @@ impl Db {
|
|||||||
"SELECT n.node_json, n.uid, n.path
|
"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_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
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());
|
||||||
@@ -392,6 +410,13 @@ impl Db {
|
|||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
None => walk_path_of(&conn, &uid)?,
|
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)
|
||||||
|
|||||||
@@ -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).
|
||||||
///
|
///
|
||||||
@@ -661,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
|
||||||
@@ -719,6 +772,31 @@ 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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.read();
|
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)
|
||||||
}
|
}
|
||||||
@@ -175,9 +600,15 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.read();
|
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.read();
|
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 {
|
||||||
@@ -225,21 +912,9 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let conn = self.read();
|
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<()> {
|
||||||
|
|||||||
@@ -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
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +53,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
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) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -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),
|
||||||
@@ -66,6 +66,20 @@ impl Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,13 +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 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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -393,13 +393,8 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.unwrap_or(&file_name)
|
.unwrap_or(&file_name)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
scan.photos.push(TakeoutPhoto {
|
// Sidecar first, then the date stamped into the name.
|
||||||
archive: raw.archive,
|
let capture_time = sidecar
|
||||||
entry: raw.entry.clone(),
|
|
||||||
name,
|
|
||||||
size: raw.size,
|
|
||||||
media_type: media_type.to_string(),
|
|
||||||
capture_time: sidecar
|
|
||||||
.photo_taken_time
|
.photo_taken_time
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
.and_then(SidecarTime::epoch_seconds)
|
||||||
@@ -408,7 +403,17 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
|||||||
.creation_time
|
.creation_time
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(SidecarTime::epoch_seconds)
|
.and_then(SidecarTime::epoch_seconds)
|
||||||
}),
|
})
|
||||||
|
.or_else(|| capture_time_from_name(&name))
|
||||||
|
.or_else(|| capture_time_from_name(&file_name));
|
||||||
|
|
||||||
|
scan.photos.push(TakeoutPhoto {
|
||||||
|
archive: raw.archive,
|
||||||
|
entry: raw.entry.clone(),
|
||||||
|
name,
|
||||||
|
size: raw.size,
|
||||||
|
media_type: media_type.to_string(),
|
||||||
|
capture_time,
|
||||||
favorite: sidecar.favorited,
|
favorite: sidecar.favorited,
|
||||||
bucket,
|
bucket,
|
||||||
});
|
});
|
||||||
@@ -507,34 +512,218 @@ fn unswap_duplicate_marker(stem: &str) -> Option<String> {
|
|||||||
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
|
Some(format!("{}{}{}", &head[..dot], marker, &head[dot..]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The capture time a *file name* implies, in epoch seconds, or `None` when it
|
||||||
|
/// carries no plausible date.
|
||||||
|
///
|
||||||
|
/// The last resort when a photo has no sidecar. Camera and messenger apps stamp
|
||||||
|
/// the date into the name — `PXL_20260818_171030868.jpg`,
|
||||||
|
/// `IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`,
|
||||||
|
/// `IMG-20230219-WA0001.jpg`, `Screenshot from 2023-01-01 10-29-50.png` — and a
|
||||||
|
/// date that is merely *approximately* right still files the photo in the right
|
||||||
|
/// year, which "the moment it was imported" does not.
|
||||||
|
///
|
||||||
|
/// The wall clock in a name has no zone attached, so it is read as UTC. That can
|
||||||
|
/// be a few hours out; it is never the years out that the import-time fallback
|
||||||
|
/// is. A sidecar always wins over this.
|
||||||
|
pub fn capture_time_from_name(name: &str) -> Option<i64> {
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
let digit = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit);
|
||||||
|
let num =
|
||||||
|
|start: usize, len: usize| -> Option<i64> { name.get(start..start + len)?.parse().ok() };
|
||||||
|
|
||||||
|
for start in 0..bytes.len() {
|
||||||
|
if !digit(start) || (start > 0 && digit(start - 1)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let run = |from: usize, len: usize| (0..len).all(|k| digit(from + k));
|
||||||
|
// `YYYYMMDD` as one run of exactly eight digits, or `YYYY-MM-DD` with
|
||||||
|
// any single separator repeated between the parts.
|
||||||
|
let (year, month, day, after) = if run(start, 8) && !digit(start + 8) {
|
||||||
|
(
|
||||||
|
num(start, 4)?,
|
||||||
|
num(start + 4, 2)?,
|
||||||
|
num(start + 6, 2)?,
|
||||||
|
start + 8,
|
||||||
|
)
|
||||||
|
} else if run(start, 4)
|
||||||
|
&& !digit(start + 4)
|
||||||
|
&& bytes.get(start + 4) == bytes.get(start + 7)
|
||||||
|
&& run(start + 5, 2)
|
||||||
|
&& run(start + 8, 2)
|
||||||
|
&& !digit(start + 10)
|
||||||
|
{
|
||||||
|
(
|
||||||
|
num(start, 4)?,
|
||||||
|
num(start + 5, 2)?,
|
||||||
|
num(start + 8, 2)?,
|
||||||
|
start + 10,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !(1990..=2100).contains(&year) || !(1..=12).contains(&month) || !(1..=31).contains(&day)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An optional time right behind the date, past one separator character.
|
||||||
|
let after = match bytes.get(after) {
|
||||||
|
Some(b'_' | b'-' | b' ' | b'.' | b',' | b'T' | b't' | b'@') => after + 1,
|
||||||
|
_ => after,
|
||||||
|
};
|
||||||
|
let time = if run(after, 6) {
|
||||||
|
// Six digits, possibly with the milliseconds tail Pixel appends.
|
||||||
|
Some((num(after, 2)?, num(after + 2, 2)?, num(after + 4, 2)?))
|
||||||
|
} else if run(after, 2)
|
||||||
|
&& !digit(after + 2)
|
||||||
|
&& bytes.get(after + 2) == bytes.get(after + 5)
|
||||||
|
&& run(after + 3, 2)
|
||||||
|
&& run(after + 6, 2)
|
||||||
|
{
|
||||||
|
Some((num(after, 2)?, num(after + 3, 2)?, num(after + 6, 2)?))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (hour, minute, second) = match time {
|
||||||
|
Some((h, m, sec)) if h < 24 && m < 60 && sec < 60 => (h, m, sec),
|
||||||
|
_ => (0, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Some(
|
||||||
|
days_from_civil(year, month, day) * 86_400 + hour * 3_600 + minute * 60 + second,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days between 1970-01-01 and a proleptic-Gregorian date (Howard Hinnant's
|
||||||
|
/// `days_from_civil`). Avoids pulling a date crate in for one conversion.
|
||||||
|
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||||
|
let year = year - i64::from(month <= 2);
|
||||||
|
let era = if year >= 0 { year } else { year - 399 } / 400;
|
||||||
|
let year_of_era = year - era * 400;
|
||||||
|
let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
|
||||||
|
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||||
|
era * 146_097 + day_of_era - 719_468
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inverse of [`unswap_duplicate_marker`]: `IMG_1234(1).jpg` as Google
|
||||||
|
/// writes its sidecar, `IMG_1234.jpg(1)`. `None` when the name carries no
|
||||||
|
/// duplicate marker.
|
||||||
|
fn swap_duplicate_marker(file_name: &str) -> Option<String> {
|
||||||
|
let marker_start = file_name.rfind('(')?;
|
||||||
|
let marker_end = marker_start + file_name[marker_start..].find(')')? + 1;
|
||||||
|
let marker = &file_name[marker_start..marker_end];
|
||||||
|
if !marker[1..marker.len() - 1]
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let tail = &file_name[marker_end..];
|
||||||
|
if !tail.starts_with('.') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(format!("{}{}{}", &file_name[..marker_start], tail, marker))
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
/// Find the sidecar belonging to `file_name` among the JSON files of its folder.
|
||||||
///
|
///
|
||||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a
|
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||||
/// prefix match for the case where truncation cut into the photo name itself.
|
/// unknown trailing suffix, then a prefix match for the case where truncation
|
||||||
|
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
||||||
fn find_sidecar<'a>(
|
fn find_sidecar<'a>(
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||||
) -> Option<&'a Vec<u8>> {
|
) -> Option<&'a Vec<u8>> {
|
||||||
let mut prefix_match: Option<&'a Vec<u8>> = None;
|
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||||
let mut prefix_len = 0usize;
|
|
||||||
|
|
||||||
for (sidecar_name, body) in in_folder {
|
for (sidecar_name, body) in in_folder {
|
||||||
let stem = strip_sidecar_suffix(sidecar_name);
|
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||||
if stem == file_name {
|
continue;
|
||||||
return Some(body);
|
};
|
||||||
}
|
if best.as_ref().is_none_or(|(current, _)| rank < *current) {
|
||||||
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
|
best = Some((rank, body));
|
||||||
return Some(body);
|
|
||||||
}
|
|
||||||
// Truncated: the stem is a prefix of the photo's name. Keep the longest
|
|
||||||
// such prefix — a folder holding `IMG_1.jpg` and `IMG_12.jpg` must not
|
|
||||||
// let the shorter stem claim the longer photo.
|
|
||||||
if !stem.is_empty() && file_name.starts_with(&stem) && stem.len() > prefix_len {
|
|
||||||
prefix_len = stem.len();
|
|
||||||
prefix_match = Some(body);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prefix_match
|
best.map(|(_, body)| body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How well a sidecar's `.json`-stripped `stem` matches `file_name`, as a sort
|
||||||
|
/// key where **lower is better**: the leading number is the rule that matched,
|
||||||
|
/// the second breaks ties within it.
|
||||||
|
fn sidecar_rank(stem: &str, file_name: &str) -> Option<(u8, usize)> {
|
||||||
|
// 0 — the stem is the photo name once the (English) suffix is off.
|
||||||
|
let plain = strip_sidecar_suffix(stem);
|
||||||
|
if plain == file_name {
|
||||||
|
return Some((0, 0));
|
||||||
|
}
|
||||||
|
// 1 — the same, for `IMG_1234.jpg(1).json` against `IMG_1234(1).jpg`.
|
||||||
|
if unswap_duplicate_marker(&plain).as_deref() == Some(file_name) {
|
||||||
|
return Some((1, 0));
|
||||||
|
}
|
||||||
|
// 2 — the photo name is intact but a suffix we do not recognise follows it.
|
||||||
|
// Google *localizes* `.supplemental-metadata` (a German export writes
|
||||||
|
// `.ergänzende-Metadaten`) and has shipped misspelt variants of it, so the
|
||||||
|
// suffix cannot be enumerated — matching on the intact name in front of it
|
||||||
|
// is what stays locale-agnostic. The shortest leftover wins, so a folder
|
||||||
|
// holding both `IMG_1.jpg.<suffix>` and `IMG_1.jpg(1).<suffix>` gives each
|
||||||
|
// photo its own.
|
||||||
|
for candidate in [
|
||||||
|
Some(file_name.to_string()),
|
||||||
|
swap_duplicate_marker(file_name),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
if let Some(rest) = stem.strip_prefix(&candidate)
|
||||||
|
&& looks_like_sidecar_suffix(rest)
|
||||||
|
{
|
||||||
|
return Some((2, rest.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3 — truncation ate into the photo name itself, leaving only a prefix of
|
||||||
|
// it. Every suffix boundary in the stem is tried, so this holds for a
|
||||||
|
// localized suffix too; the longest surviving prefix wins, because a folder
|
||||||
|
// with `IMG_1.jpg` and `IMG_12.jpg` must not let the shorter stem claim the
|
||||||
|
// longer photo.
|
||||||
|
let mut longest: Option<usize> = None;
|
||||||
|
for head in suffix_boundaries(stem) {
|
||||||
|
if !head.is_empty()
|
||||||
|
&& file_name.starts_with(head)
|
||||||
|
&& longest.is_none_or(|len| head.len() > len)
|
||||||
|
{
|
||||||
|
longest = Some(head.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longest.map(|len| (3, usize::MAX - len))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stem plus each of its prefixes ending before a `.`, longest first — the
|
||||||
|
/// possible boundaries between a photo name and a sidecar suffix.
|
||||||
|
fn suffix_boundaries(stem: &str) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(stem).chain(
|
||||||
|
stem.char_indices()
|
||||||
|
.filter(|(_, c)| *c == '.')
|
||||||
|
.map(|(index, _)| &stem[..index])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.rev(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `rest` — what follows an intact photo name in a sidecar's stem —
|
||||||
|
/// looks like a metadata suffix rather than the tail of a different file's name.
|
||||||
|
/// Google's suffixes all start at a `.`, or at the `(N)` duplicate marker.
|
||||||
|
fn looks_like_sidecar_suffix(rest: &str) -> bool {
|
||||||
|
rest.starts_with('.') || rest.starts_with('(')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a trailing `.json`, case-insensitively.
|
||||||
|
fn strip_json_suffix(sidecar: &str) -> &str {
|
||||||
|
match sidecar.len().checked_sub(5) {
|
||||||
|
Some(cut) if sidecar[cut..].eq_ignore_ascii_case(".json") => &sidecar[..cut],
|
||||||
|
_ => sidecar,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -623,7 +812,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sidecar_supplies_capture_time_and_favourite() {
|
fn sidecar_supplies_capture_time_and_favorite() {
|
||||||
let mut sidecars = HashMap::new();
|
let mut sidecars = HashMap::new();
|
||||||
sidecar(
|
sidecar(
|
||||||
&mut sidecars,
|
&mut sidecars,
|
||||||
@@ -663,6 +852,106 @@ mod tests {
|
|||||||
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
assert_eq!(scan.photos[0].capture_time, Some(1_500_000_000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_localized_supplemental_suffix_still_matches() {
|
||||||
|
// A German export writes `.ergänzende-Metadaten` where an English one
|
||||||
|
// writes `.supplemental-metadata`; the photo name in front of it is
|
||||||
|
// what the match hangs on.
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg.ergänzende-Metadaten.json",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"1676800000"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_676_800_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_localized_suffix_does_not_let_one_sidecar_claim_a_sibling() {
|
||||||
|
let mut sidecars = HashMap::new();
|
||||||
|
for name in ["IMG_1.jpg", "IMG_1(1).jpg"] {
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
&format!("Takeout/Google Fotos/Album/{name}.ergänzende-Metadaten.json"),
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"1"}}"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Google writes the duplicate's sidecar with the marker after the
|
||||||
|
// extension, so this is the spelling that actually ships.
|
||||||
|
sidecars.remove("Album/IMG_1(1).jpg.ergänzende-Metadaten.json");
|
||||||
|
sidecar(
|
||||||
|
&mut sidecars,
|
||||||
|
"Takeout/Google Fotos/Album/IMG_1.jpg(1).ergänzende-Metadaten.json",
|
||||||
|
r#"{"photoTakenTime":{"timestamp":"2"}}"#,
|
||||||
|
);
|
||||||
|
let scan = assemble(
|
||||||
|
vec![
|
||||||
|
entry(0, "Takeout/Google Fotos/Album/IMG_1.jpg", 10),
|
||||||
|
entry(0, "Takeout/Google Fotos/Album/IMG_1(1).jpg", 10),
|
||||||
|
],
|
||||||
|
&sidecars,
|
||||||
|
);
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1));
|
||||||
|
assert_eq!(scan.photos[1].capture_time, Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_name_date_is_the_fallback_when_no_sidecar_matches() {
|
||||||
|
let scan = assemble(
|
||||||
|
vec![entry(
|
||||||
|
0,
|
||||||
|
"Takeout/Google Fotos/Fotos von 2023/IMG-20230219-WA0001.jpg",
|
||||||
|
10,
|
||||||
|
)],
|
||||||
|
&HashMap::new(),
|
||||||
|
);
|
||||||
|
// 2023-02-19 00:00:00 UTC.
|
||||||
|
assert_eq!(scan.photos[0].capture_time, Some(1_676_764_800));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_name_dates_cover_the_common_camera_and_messenger_spellings() {
|
||||||
|
// 2026-08-18 17:10:30 UTC, with Pixel's milliseconds tail.
|
||||||
|
assert_eq!(
|
||||||
|
capture_time_from_name("PXL_20260818_171030868.RAW-01.COVER.jpg"),
|
||||||
|
Some(1_787_073_030)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capture_time_from_name("IMG_20260818_171030.jpg"),
|
||||||
|
Some(1_787_073_030)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capture_time_from_name("Screenshot_20260818-171030.png"),
|
||||||
|
Some(1_787_073_030)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capture_time_from_name("Screenshot from 2026-08-18 17-10-30.png"),
|
||||||
|
Some(1_787_073_030)
|
||||||
|
);
|
||||||
|
// A date with no usable time lands at midnight, not at import time.
|
||||||
|
assert_eq!(
|
||||||
|
capture_time_from_name("IMG-20260818-WA0001.jpg"),
|
||||||
|
Some(1_787_011_200)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_without_a_plausible_date_yields_nothing() {
|
||||||
|
assert_eq!(capture_time_from_name("100_1234.JPG"), None);
|
||||||
|
assert_eq!(capture_time_from_name("DSC00042.jpg"), None);
|
||||||
|
// A nine-digit run is not a date; neither is an out-of-range month.
|
||||||
|
assert_eq!(capture_time_from_name("IMG_202608181.jpg"), None);
|
||||||
|
assert_eq!(capture_time_from_name("IMG_20261818.jpg"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truncated_supplemental_suffixes_still_match() {
|
fn truncated_supplemental_suffixes_still_match() {
|
||||||
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
// Google caps the sidecar name at 51 chars, cutting into the suffix.
|
||||||
|
|||||||
@@ -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,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 = "FUSE on-demand filesystem for Proton Drive"
|
description = "FUSE on-demand filesystem for Proton Drive"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -18,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 {
|
||||||
|
|||||||
@@ -422,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,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();
|
||||||
|
|
||||||
@@ -541,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,24 +11,44 @@
|
|||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write as _};
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ActivityKind, Event, HEARTBEAT, PhotoMonth, RefreshScope, Request as CtlRequest,
|
||||||
TransferDirection,
|
Response as CtlResponse, Topic, TransferDirection, TrashFailure,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::transfers::CountingReader;
|
use super::transfers::CountingReader;
|
||||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
|
/// into an error that names what is still shared.
|
||||||
|
fn stop_sharing_reply(
|
||||||
|
core: &Core,
|
||||||
|
target: &str,
|
||||||
|
result: CoreResult<super::sharing::StopSharingOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
match result.map(|outcome| outcome.into_result()) {
|
||||||
|
Ok(Ok(message)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
|
||||||
|
CtlResponse::Ok { message }
|
||||||
|
}
|
||||||
|
Ok(Err(left)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, &left, false);
|
||||||
|
CtlResponse::error(CoreError::remote(left))
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A client that connects, sends a request and then never reads its reply used
|
/// A client that connects, sends a request and then never reads its reply used
|
||||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
@@ -46,6 +66,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
|||||||
fn clamp_limit(limit: usize) -> usize {
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
limit.min(MAX_ROW_LIMIT)
|
limit.min(MAX_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -67,6 +113,16 @@ impl Drop for ControlHandlerPermit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handler threads in use, for the diagnostics. At the limit the daemon refuses
|
||||||
|
/// connections, which a front-end cannot tell apart from a dead daemon.
|
||||||
|
pub(crate) fn active_handlers() -> usize {
|
||||||
|
ACTIVE_CONTROL_HANDLERS.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handler_limit() -> usize {
|
||||||
|
MAX_CONTROL_HANDLERS
|
||||||
|
}
|
||||||
|
|
||||||
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
@@ -129,22 +185,256 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`route_to_mount`] for a conflict copy. The listing names a copy inside a
|
||||||
|
/// synced folder by its absolute local path (`docs/BUGS.md` B99), so the
|
||||||
|
/// resolve has to run against the on-demand mount serving it. A mirror folder
|
||||||
|
/// has no mount to run against: its copy is an ordinary local file.
|
||||||
|
fn route_conflict(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let routed = route_to_mount(core, mountpoint, path).map_err(|e| {
|
||||||
|
let p = Path::new(path);
|
||||||
|
let mirrored = core
|
||||||
|
.db
|
||||||
|
.sync_folder_list()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|folder| folder.mode == "mirror" && p.starts_with(&folder.local_path));
|
||||||
|
if mirrored {
|
||||||
|
CoreError::invalid(format!(
|
||||||
|
"{path} is in a mirrored folder; rename or delete the local file there"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if routed
|
||||||
|
.1
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(routed)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() {
|
let relative = if p.is_absolute() {
|
||||||
p.strip_prefix(mountpoint)
|
p.strip_prefix(mountpoint)
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||||
} else {
|
} else {
|
||||||
Ok(p.to_path_buf())
|
Ok(p.to_path_buf())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(relative)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one control-socket connection: read a single JSON request line,
|
/// Handle one control-socket connection: read a single JSON request line,
|
||||||
/// dispatch it against `core`, and write back a JSON response line.
|
/// dispatch it against `core`, and write back a JSON response line.
|
||||||
|
/// Refresh scopes being served right now, one entry per running refresh.
|
||||||
|
///
|
||||||
|
/// A `Vec` because there are three scopes and at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// handlers, so a set would buy nothing.
|
||||||
|
static REFRESHING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// What makes two refreshes the same work: the scope, and for a directory the
|
||||||
|
/// folder, since refreshing one folder says nothing about another. A full
|
||||||
|
/// photos refresh is not the same work as an ordinary one — it forgets what the
|
||||||
|
/// daemon knows first — so it keys apart.
|
||||||
|
fn scope_key(scope: &RefreshScope) -> String {
|
||||||
|
match scope {
|
||||||
|
RefreshScope::Dir { path } => format!("dir:{path}"),
|
||||||
|
RefreshScope::Trash => "trash".to_string(),
|
||||||
|
RefreshScope::Photos { full: true } => "photos:full".to_string(),
|
||||||
|
RefreshScope::Photos { full: false } => "photos".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds one refresh scope for as long as it is being served. `acquire`
|
||||||
|
/// returns `None` when that scope is already in flight.
|
||||||
|
struct RefreshGuard(String);
|
||||||
|
|
||||||
|
impl RefreshGuard {
|
||||||
|
fn acquire(scope: &RefreshScope) -> Option<Self> {
|
||||||
|
let key = scope_key(scope);
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if running.contains(&key) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
running.push(key.clone());
|
||||||
|
Some(Self(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RefreshGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(at) = running.iter().position(|key| *key == self.0) {
|
||||||
|
running.swap_remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply to [`CtlRequest::Status`], which subscriptions are also sent
|
||||||
|
/// whenever it changes.
|
||||||
|
pub(crate) fn status_response(core: &Core, username: &str, mountpoint: &Path) -> CtlResponse {
|
||||||
|
let pins = core.cache.list_pins();
|
||||||
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
|
username: username.to_string(),
|
||||||
|
mountpoint: mountpoint.display().to_string(),
|
||||||
|
pinned: pins.len(),
|
||||||
|
used: core.cache.usage(),
|
||||||
|
budget: core.cache.budget(),
|
||||||
|
pins,
|
||||||
|
online: core.online.load(Ordering::Relaxed),
|
||||||
|
pending_uploads: queued.uploads.max(0) as u64,
|
||||||
|
pending_changes: queued.changes.max(0) as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a front-end shows that `request` changes when it succeeds. Requests
|
||||||
|
/// from the CLI publish the same way as the app's, so an open window follows
|
||||||
|
/// a change made in a terminal.
|
||||||
|
fn touched_topics(request: &CtlRequest) -> &'static [Topic] {
|
||||||
|
use CtlRequest as R;
|
||||||
|
match request {
|
||||||
|
R::Pin { .. } | R::Unpin { .. } => &[Topic::Files],
|
||||||
|
R::CreateAlbum { .. }
|
||||||
|
| R::RenameAlbum { .. }
|
||||||
|
| R::DeleteAlbum { .. }
|
||||||
|
| R::AddToAlbum { .. }
|
||||||
|
| R::RemoveFromAlbum { .. }
|
||||||
|
| R::SetPhotoFavorite { .. }
|
||||||
|
| R::UploadPhoto { .. } => &[Topic::Photos],
|
||||||
|
R::TrashNodes { .. } => &[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Rename { .. } | R::Move { .. } | R::CreateFolder { .. } | R::UploadPaths { .. } => {
|
||||||
|
&[Topic::Files, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Delete { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::Restore { .. } | R::DeleteForever { .. } | R::EmptyTrash => {
|
||||||
|
&[Topic::Files, Topic::Photos, Topic::Trash, Topic::Conflicts]
|
||||||
|
}
|
||||||
|
R::Refresh { scope } => match scope {
|
||||||
|
RefreshScope::Dir { .. } => &[Topic::Files],
|
||||||
|
RefreshScope::Trash => &[Topic::Trash],
|
||||||
|
// A photos refresh answers once it has started; the timeline
|
||||||
|
// publishes again when it lands.
|
||||||
|
RefreshScope::Photos { .. } => &[],
|
||||||
|
},
|
||||||
|
R::RenameDevice { .. } | R::DeleteDevice { .. } => &[Topic::Devices],
|
||||||
|
R::AdoptDevice { .. } => &[Topic::Devices, Topic::Locations],
|
||||||
|
R::AddSyncFolder { .. }
|
||||||
|
| R::RemoveSyncFolder { .. }
|
||||||
|
| R::SetSyncFolderMode { .. }
|
||||||
|
| R::SetSyncFolderPaused { .. }
|
||||||
|
| R::RestoreSyncFolders { .. }
|
||||||
|
| R::RestoreDeviceFolders { .. } => &[Topic::Locations, Topic::Devices],
|
||||||
|
R::RetryPendingOp { .. } => &[Topic::Queue],
|
||||||
|
R::ResolveConflict { .. } => &[Topic::Files, Topic::Trash, Topic::Conflicts],
|
||||||
|
R::ShareNode { .. }
|
||||||
|
| R::ShareNodeByUid { .. }
|
||||||
|
| R::UpdateShareRole { .. }
|
||||||
|
| R::UpdateShareRoleByUid { .. }
|
||||||
|
| R::RemoveShareEntry { .. }
|
||||||
|
| R::RemoveShareEntryByUid { .. }
|
||||||
|
| R::CreatePublicLink { .. }
|
||||||
|
| R::CreatePublicLinkByUid { .. }
|
||||||
|
| R::RemovePublicLink { .. }
|
||||||
|
| R::RemovePublicLinkByUid { .. }
|
||||||
|
| R::StopSharing { .. }
|
||||||
|
| R::StopSharingByUid { .. } => &[Topic::Shares, Topic::Files],
|
||||||
|
R::LeaveShared { .. }
|
||||||
|
| R::AcceptInvitation { .. }
|
||||||
|
| R::RejectInvitation { .. }
|
||||||
|
| R::CreateBookmark { .. }
|
||||||
|
| R::DeleteBookmark { .. } => &[Topic::Shares],
|
||||||
|
R::RestoreRevision { .. }
|
||||||
|
| R::RestoreRevisionByUid { .. }
|
||||||
|
| R::SaveRevisionAs { .. }
|
||||||
|
| R::SaveRevisionAsByUid { .. } => &[Topic::Files],
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a subscription's writes may block. A client that stops reading
|
||||||
|
/// is dropped after this rather than holding its handler forever.
|
||||||
|
const SUBSCRIPTION_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Serve a [`CtlRequest::Subscribe`]: send the current status and transfers
|
||||||
|
/// and say every topic changed, so the client catches up, then send whatever
|
||||||
|
/// the [`EventHub`](super::events::EventHub) collects, with a heartbeat when
|
||||||
|
/// nothing does. Ends when the client goes away or the daemon stops.
|
||||||
|
fn serve_subscription(core: &Core, username: &str, mountpoint: &Path, mut stream: UnixStream) {
|
||||||
|
let Some(mailbox) = core.events.subscribe() else {
|
||||||
|
warn!("control: refusing a subscription at the limit");
|
||||||
|
if let Ok(mut out) = serde_json::to_vec(&CtlResponse::error(CoreError::remote(
|
||||||
|
"too many subscriptions",
|
||||||
|
))) {
|
||||||
|
out.push(b'\n');
|
||||||
|
let _ = stream.write_all(&out);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
struct Unsubscribe<'a>(&'a Core, std::sync::Arc<super::events::Mailbox>);
|
||||||
|
impl Drop for Unsubscribe<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.events.unsubscribe(&self.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _unsubscribe = Unsubscribe(core, mailbox.clone());
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(SUBSCRIPTION_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set subscription timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let heartbeat = super::events::event_line(&Event::Heartbeat);
|
||||||
|
let mut lines: Vec<_> = [
|
||||||
|
super::events::event_line(&Event::Status(Box::new(status_response(
|
||||||
|
core, username, mountpoint,
|
||||||
|
)))),
|
||||||
|
super::events::transfers_line(core.transfers.snapshot(), core.jobs_snapshot()),
|
||||||
|
super::events::event_line(&Event::Changed {
|
||||||
|
topics: Topic::ALL.to_vec(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
loop {
|
||||||
|
for line in &lines {
|
||||||
|
if stream.write_all(line.as_bytes()).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines = mailbox.take(HEARTBEAT);
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.extend(heartbeat.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
@@ -169,27 +459,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let request = serde_json::from_str::<CtlRequest>(line.trim());
|
||||||
Ok(CtlRequest::Status) => {
|
// A subscription lasts as long as its client, so it is not a request in
|
||||||
let pins = core.cache.list_pins();
|
// flight and stays out of the diagnostics' list of them.
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
if let Ok(CtlRequest::Subscribe) = request {
|
||||||
let staging = core.cache.staging_usage();
|
serve_subscription(core, username, mountpoint, stream);
|
||||||
CtlResponse::Status {
|
return;
|
||||||
parked_uploads: queued.parked.max(0) as u64,
|
|
||||||
failing_ops: queued.failing.max(0) as u64,
|
|
||||||
failing_error: queued.last_error.clone(),
|
|
||||||
staged_bytes: staging.bytes,
|
|
||||||
staged_oldest_secs: staging.oldest_secs,
|
|
||||||
username: username.to_string(),
|
|
||||||
mountpoint: mountpoint.display().to_string(),
|
|
||||||
pinned: pins.len(),
|
|
||||||
used: core.cache.usage(),
|
|
||||||
budget: core.cache.budget(),
|
|
||||||
pins,
|
|
||||||
online: core.online.load(Ordering::Relaxed),
|
|
||||||
pending_uploads: queued.uploads.max(0) as u64,
|
|
||||||
pending_changes: queued.changes.max(0) as u64,
|
|
||||||
}
|
}
|
||||||
|
let touched = request.as_ref().map_or(&[][..], touched_topics);
|
||||||
|
// Registered before the request is even parsed, so a handler that wedges
|
||||||
|
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||||
|
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||||
|
let response = match request {
|
||||||
|
Ok(CtlRequest::Status) => status_response(core, username, mountpoint),
|
||||||
|
Ok(CtlRequest::Subscribe) => {
|
||||||
|
CtlResponse::error(CoreError::invalid("a subscription is served on its own"))
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.pin(&rel) {
|
Ok(rel) => match core.pin(&rel) {
|
||||||
@@ -216,7 +500,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
pins: core.cache.list_pins(),
|
pins: core.list_pins(),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
@@ -225,7 +509,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Refresh { scope }) => {
|
Ok(CtlRequest::Refresh { scope }) => match RefreshGuard::acquire(&scope) {
|
||||||
|
// The same scope is already being refreshed by another handler.
|
||||||
|
// Running it again cannot make the answer newer, and the duplicate
|
||||||
|
// would spend its whole run contending for the state and database
|
||||||
|
// locks the first one holds — which is how five stacked photo
|
||||||
|
// refreshes sat in flight for twenty-three minutes at mount time.
|
||||||
|
None => {
|
||||||
|
debug!(scope = %scope_key(&scope), "a refresh of this scope is already running");
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "refreshing".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_refreshing) => {
|
||||||
let result = match &scope {
|
let result = match &scope {
|
||||||
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||||
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||||
@@ -233,28 +529,64 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
core.invalidate_trash();
|
core.invalidate_trash();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
RefreshScope::Photos => {
|
RefreshScope::Photos { full } if !full && core.timeline_refreshing() => {
|
||||||
|
// A refresh is already running and will publish exactly the
|
||||||
|
// timeline this request asks for. Doing the work again
|
||||||
|
// cannot make it newer, and the invalidation plus the
|
||||||
|
// album read contend with the running refresh for the
|
||||||
|
// database lock — which is how a handful of stacked photo
|
||||||
|
// refreshes at mount time left five control requests
|
||||||
|
// in flight for twenty-three minutes.
|
||||||
|
debug!("a photos refresh is already running; joining it");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RefreshScope::Photos { full } => {
|
||||||
core.invalidate_photos();
|
core.invalidate_photos();
|
||||||
|
// A full refresh is "forget what you know", not a second code
|
||||||
|
// path: the ordinary refresh then finds every photo stale.
|
||||||
|
if let Some(Err(e)) = full.then(|| core.db.photos_mark_all_unresolved()) {
|
||||||
|
warn!(error = %e, "marking the whole timeline unresolved failed");
|
||||||
|
}
|
||||||
// Whatever changed the timeline can just as easily have
|
// Whatever changed the timeline can just as easily have
|
||||||
// changed an album; one scope covers the whole photos view.
|
// changed an album; one scope covers the whole photos view.
|
||||||
core.invalidate_albums();
|
core.invalidate_albums();
|
||||||
|
// The refresh is started here, not awaited. A library of
|
||||||
|
// twelve thousand photos is re-read in chunks of
|
||||||
|
// `TIMELINE_ENRICH_CHUNK` nodes, which takes minutes —
|
||||||
|
// longer than the client's read bound, so awaiting it
|
||||||
|
// returned `Resource temporarily unavailable` to a caller
|
||||||
|
// whose refresh was in fact running fine (B92). The front
|
||||||
|
// end follows it with `PhotosRefreshStatus`.
|
||||||
|
core.spawn_timeline_refresh();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: "refreshed".to_string(),
|
message: match scope {
|
||||||
|
RefreshScope::Photos { .. } => "refreshing".to_string(),
|
||||||
|
_ => "refreshed".to_string(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotosTimeline {
|
Ok(CtlRequest::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
favorites,
|
favorites,
|
||||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
not_in_album,
|
||||||
|
}) => match core.photos_timeline(
|
||||||
|
offset,
|
||||||
|
clamp_limit(limit),
|
||||||
|
kind,
|
||||||
|
range,
|
||||||
|
favorites,
|
||||||
|
not_in_album,
|
||||||
|
) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -267,13 +599,64 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||||
|
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
|
||||||
|
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
|
||||||
|
sets,
|
||||||
|
hashing,
|
||||||
|
uncompared,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
|
||||||
|
Ok(items) => CtlResponse::Places {
|
||||||
|
items,
|
||||||
|
mapping: core.timeline_progress(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PlacePhotos { id, offset, limit }) => {
|
||||||
|
match core.place_photos(id, offset, clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::PlaceLocations { id }) => match core.place_locations(id) {
|
||||||
|
Ok(items) => CtlResponse::PhotoLocations { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||||
|
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: if favorite {
|
message: if favorite {
|
||||||
"added to favourites".to_string()
|
"added to favorites".to_string()
|
||||||
} else {
|
} else {
|
||||||
"removed from favourites".to_string()
|
"removed from favorites".to_string()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -305,6 +688,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||||
|
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.rename_album(&album, &name) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed album to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: "album deleted".to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.add_to_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.remove_from_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
@@ -320,6 +735,50 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||||
|
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||||
|
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||||
|
trashed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||||
|
match core.file_thumbs(&items, generation) {
|
||||||
|
Some(items) => CtlResponse::Thumbs { items },
|
||||||
|
None => CtlResponse::FileThumbsStale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
|
||||||
|
generation: core.reserve_file_thumb_generation(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelFileThumbs { generation }) => {
|
||||||
|
core.cancel_file_thumbs(generation);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "thumbnail requests cancelled".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.start_thumbnail_build(rel) {
|
||||||
|
Ok(status) => CtlResponse::ThumbnailBuild { status },
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.cancel_thumbnail_build(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.thumbnail_build_status(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotosRefreshStatus) => CtlResponse::PhotosRefresh {
|
||||||
|
running: core.timeline_refreshing(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||||
Some(u) => match core.open_photo(&u) {
|
Some(u) => match core.open_photo(&u) {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
@@ -374,6 +833,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
CtlResponse::error(CoreError::invalid("no import is running"))
|
CtlResponse::error(CoreError::invalid("no import is running"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The scan reads the whole persisted timeline and every candidate is a
|
||||||
|
// download plus an upload, so this goes to a background thread and is
|
||||||
|
// read back through `RedateStatus`.
|
||||||
|
Ok(CtlRequest::RedatePhotos {
|
||||||
|
dry_run,
|
||||||
|
limit,
|
||||||
|
range,
|
||||||
|
}) => match core.spawn_redate(dry_run, limit, range) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: if dry_run {
|
||||||
|
"checking photo dates".to_string()
|
||||||
|
} else {
|
||||||
|
"fixing photo dates".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RedateStatus) => {
|
||||||
|
let (running, summary) = crate::redate::redate_status();
|
||||||
|
CtlResponse::RedateStatus { running, summary }
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::CancelRedate) => {
|
||||||
|
if crate::redate::cancel_redate() {
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "stopping the re-date".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CtlResponse::error(CoreError::invalid("no photo re-date is running"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::UploadPhoto {
|
Ok(CtlRequest::UploadPhoto {
|
||||||
name,
|
name,
|
||||||
media_type,
|
media_type,
|
||||||
@@ -441,7 +930,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
Ok(CtlRequest::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
@@ -590,6 +1083,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::Diagnostics) => CtlResponse::Diagnostics {
|
||||||
|
diagnostics: core.diagnostics(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -619,9 +1115,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
let items = if n == 1 {
|
||||||
|
"1 item".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} items")
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: format!("emptied trash ({n} item(s))"),
|
message: format!("emptied trash ({items})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -701,6 +1202,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetBandwidthLimits { upload, download }) => {
|
||||||
|
core.transfers.set_limits(upload, download);
|
||||||
|
// Persisted like the cache budget: best-effort, the live caps are
|
||||||
|
// already applied.
|
||||||
|
match AppDirs::new().map(|dirs| {
|
||||||
|
let mut cfg = dirs.load_config();
|
||||||
|
cfg.upload_limit = Some(upload);
|
||||||
|
cfg.download_limit = Some(download);
|
||||||
|
dirs.save_config(&cfg)
|
||||||
|
}) {
|
||||||
|
Ok(Ok(())) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"bandwidth limits set to {upload} B/s up, {download} B/s down"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config write failed: {e}"
|
||||||
|
))),
|
||||||
|
Err(e) => CtlResponse::error(CoreError::internal(format!(
|
||||||
|
"limits applied but config unavailable: {e}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
Ok(CtlRequest::ListDevices) => match core.list_devices() {
|
||||||
Ok(items) => CtlResponse::Devices { items },
|
Ok(items) => CtlResponse::Devices { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -784,6 +1308,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
|
||||||
|
match core.set_sync_folder_paused(id, paused) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match paused {
|
||||||
|
true => format!("folder {id} paused"),
|
||||||
|
false => format!("folder {id} resumed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SyncNow { id }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -793,15 +1328,69 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders() {
|
Ok(CtlRequest::SetSyncPaused { paused, until }) => {
|
||||||
|
match core.set_sync_paused(paused, until) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match (paused, until) {
|
||||||
|
(false, _) => "sync resumed".to_string(),
|
||||||
|
(true, None) => "sync paused until resumed".to_string(),
|
||||||
|
(true, Some(_)) => "sync paused".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListConflicts) => match core.list_conflicts() {
|
||||||
|
Ok(items) => CtlResponse::Conflicts { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ResolveConflict { path, keep }) => {
|
||||||
|
match route_conflict(core, mountpoint, &path) {
|
||||||
|
Ok((core, rel)) => match core.resolve_conflict(&rel, &keep) {
|
||||||
|
Ok(message) => CtlResponse::Ok { message },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() {
|
||||||
|
Ok(items) => CtlResponse::PendingOps { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RetryPendingOp { id }) => match core.retry_pending_ops(id) {
|
||||||
|
Ok(0) if id.is_some() => CtlResponse::Ok {
|
||||||
|
message: "nothing to retry: the operation is already due or waiting for a rename"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
Ok(moved) => CtlResponse::Ok {
|
||||||
|
message: format!("retrying {moved} queued operation(s) now"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ListRestorableFolders) => match core.list_restorable_folders(None) {
|
||||||
Ok(items) => CtlResponse::RestorableFolders { items },
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::RestoreSyncFolders { items }) => {
|
Ok(CtlRequest::ListDeviceRestorableFolders { device }) => {
|
||||||
|
match core.list_restorable_folders(Some(&device)) {
|
||||||
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
request @ (CtlRequest::RestoreSyncFolders { .. }
|
||||||
|
| CtlRequest::RestoreDeviceFolders { .. }),
|
||||||
|
) => {
|
||||||
|
let (device, items) = match request {
|
||||||
|
CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items),
|
||||||
|
CtlRequest::RestoreSyncFolders { items } => (None, items),
|
||||||
|
_ => unreachable!("matched above"),
|
||||||
|
};
|
||||||
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
||||||
// which outlasts the socket timeout. Ack, then work.
|
// which outlasts the socket timeout. Ack, then work.
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
std::thread::spawn(move || {
|
||||||
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
core.touch_profile();
|
core.touch_profile();
|
||||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||||
@@ -810,6 +1399,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
warn!(error = %e, "restore failed");
|
warn!(error = %e, "restore failed");
|
||||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: "restoring folders".to_string(),
|
message: "restoring folders".to_string(),
|
||||||
@@ -979,6 +1569,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::StopSharingByUid { uid }) => {
|
||||||
|
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_revisions(&rel) {
|
Ok(rel) => match core.list_revisions(&rel) {
|
||||||
Ok(items) => CtlResponse::Revisions { items },
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
@@ -1125,6 +1722,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
};
|
};
|
||||||
|
if !touched.is_empty() && !matches!(response, CtlResponse::Error { .. }) {
|
||||||
|
core.events.publish(touched);
|
||||||
|
}
|
||||||
let mut out = match serde_json::to_vec(&response) {
|
let mut out = match serde_json::to_vec(&response) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1221,4 +1821,54 @@ mod request_limit_tests {
|
|||||||
std::io::ErrorKind::InvalidData
|
std::io::ErrorKind::InvalidData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_paths_inside_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton").unwrap(),
|
||||||
|
Path::new("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_paths_that_escape_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert!(rel_to_mount(mountpoint, "../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Two refreshes of the same scope are one piece of work; two refreshes of
|
||||||
|
/// different scopes are not.
|
||||||
|
#[test]
|
||||||
|
fn a_scope_refreshes_once_at_a_time() {
|
||||||
|
let photos = RefreshScope::Photos { full: false };
|
||||||
|
let held = RefreshGuard::acquire(&photos).expect("first refresh runs");
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_none());
|
||||||
|
// A full refresh forgets what the daemon knows, so it is other work.
|
||||||
|
assert!(RefreshGuard::acquire(&RefreshScope::Photos { full: true }).is_some());
|
||||||
|
assert!(
|
||||||
|
RefreshGuard::acquire(&RefreshScope::Dir {
|
||||||
|
path: "a".to_string()
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(held);
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
@@ -561,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
|
||||||
@@ -625,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,12 +29,16 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
use pdfs_core::batch;
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
use pdfs_core::db::{
|
||||||
use proton_drive_rs::Node;
|
OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PendingOp, RenameMeta,
|
||||||
|
};
|
||||||
|
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{Node, NodeMoveItem};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::state::Intervals;
|
use super::state::Intervals;
|
||||||
@@ -281,8 +285,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let online = self.online.load(Ordering::Relaxed);
|
let online = self.online.load(Ordering::Relaxed);
|
||||||
|
let paused = self.sync_paused();
|
||||||
let op = match due {
|
let op = match due {
|
||||||
Some(op) if online => op,
|
Some(op) if online && !paused => op,
|
||||||
idle => {
|
idle => {
|
||||||
// Claimed but offline: hand it straight back, or it stays
|
// Claimed but offline: hand it straight back, or it stays
|
||||||
// invisible to every worker until this one happens to be
|
// invisible to every worker until this one happens to be
|
||||||
@@ -297,6 +302,18 @@ impl Core {
|
|||||||
// Cheap and a no-op when empty.
|
// Cheap and a no-op when empty.
|
||||||
self.cache.flush_touches();
|
self.cache.flush_touches();
|
||||||
self.recover_fsynced_writes();
|
self.recover_fsynced_writes();
|
||||||
|
// Parked rows are invisible to `claim_next_due_op`, so
|
||||||
|
// an idle queue is exactly when a park that has outlived
|
||||||
|
// its writer has to be noticed.
|
||||||
|
self.sweep_parked_creates();
|
||||||
|
}
|
||||||
|
// Paused, nothing due changes that: sleep until a resume
|
||||||
|
// wakes the worker (or the idle poll notices a timed pause
|
||||||
|
// ran out), not until the next op falls due — that is
|
||||||
|
// "now" for everything already queued.
|
||||||
|
if paused {
|
||||||
|
self.wait_for_drain_work();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
// A debounced or backed-off op may be waiting: sleep only
|
// A debounced or backed-off op may be waiting: sleep only
|
||||||
// until it becomes due rather than the full idle-poll.
|
// until it becomes due rather than the full idle-poll.
|
||||||
@@ -642,61 +659,46 @@ impl Core {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The name half goes first, so that a collision on the move half below is
|
let landed = if node.parent_uid.as_ref() == Some(&parent) {
|
||||||
// about the name the node will actually land under rather than the one it
|
self.drain_rename_in_place(&uid, &node.name, &name)?
|
||||||
// is about to lose.
|
} else {
|
||||||
let mut landed = node.name.clone();
|
// Move and rename land as one request (`move-multiple` takes a
|
||||||
if landed != name {
|
// target name), so there is no half-applied state between them and
|
||||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
// no window for the requirements to go stale between two calls —
|
||||||
Ok(()) => landed = name.clone(),
|
// the race B46 queued this op to get away from.
|
||||||
// Someone took the name while we were offline. Renaming to a
|
let target = (node.name != name).then_some(name.as_str());
|
||||||
|
match self.move_renaming(&uid, &parent, target) {
|
||||||
|
Ok(()) => name.clone(),
|
||||||
|
// The destination holds that name already. Landing under a
|
||||||
// *different* name is the non-destructive resolution: it neither
|
// *different* name is the non-destructive resolution: it neither
|
||||||
// clobbers their file nor drops ours, and it is visible.
|
// clobbers their file nor drops ours, and it is visible.
|
||||||
Err(e) if is_already_exists(&e) => {
|
Err(e) if is_already_exists(&e) => {
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||||
self.rt
|
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
landed = alt.clone();
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
self.adopt_drained_name(&uid, &alt);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&name,
|
&name,
|
||||||
format!("name was taken remotely; renamed to {alt}"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if node.parent_uid.as_ref() != Some(&parent) {
|
|
||||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
// The destination holds a `landed` of its own. Same resolution as
|
|
||||||
// a name collision, and it has to happen before the move: the API
|
|
||||||
// has no move-and-rename, so the node is renamed out of the way
|
|
||||||
// here and moved second.
|
|
||||||
Err(e) if is_already_exists(&e) => {
|
|
||||||
let alt = conflict_name(&landed, now_secs());
|
|
||||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
|
||||||
self.rt
|
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
|
||||||
self.log_activity(
|
|
||||||
ActivityKind::Rename,
|
|
||||||
&landed,
|
|
||||||
format!("destination already had that name; moved as {alt}"),
|
format!("destination already had that name; moved as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
alt
|
||||||
}
|
}
|
||||||
// The destination folder is gone. Leaving the node in its current
|
// The destination folder is gone. Leaving the node in its current
|
||||||
// parent is the honest outcome: it is not where the user asked for
|
// parent is the honest outcome: it is not where the user asked for
|
||||||
// it, but it exists, it is where it has always been, and the
|
// it, but it exists and it is where it has always been. The name
|
||||||
// rename half above still applied. Retrying could only fail again
|
// the user chose still applies there. Retrying the move could
|
||||||
// — the folder is not coming back — and would wedge the queue.
|
// only fail again — the folder is not coming back — and would
|
||||||
|
// wedge the queue.
|
||||||
Err(e) if is_gone(&e) => {
|
Err(e) if is_gone(&e) => {
|
||||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
|
||||||
|
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
|
||||||
|
Ok(landed) => landed,
|
||||||
|
// The node went with it; nothing is left to rename.
|
||||||
|
Err(e) if is_gone(&e) => node.name.clone(),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&landed,
|
&landed,
|
||||||
@@ -704,16 +706,73 @@ impl Core {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
landed
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.note_self_change(&uid);
|
self.note_self_change(&uid);
|
||||||
info!(%uid, name = %landed, "pending rename landed");
|
info!(%uid, name = %landed, "pending rename landed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename `uid` without moving it, from `current` to `name`, and return the
|
||||||
|
/// name it landed under. Someone may have taken the name while we were
|
||||||
|
/// offline; the node then lands under a conflict name instead.
|
||||||
|
fn drain_rename_in_place(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
current: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<String, ProtonError> {
|
||||||
|
if current == name {
|
||||||
|
return Ok(current.to_string());
|
||||||
|
}
|
||||||
|
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
|
||||||
|
Ok(()) => Ok(name.to_string()),
|
||||||
|
Err(e) if is_already_exists(&e) => {
|
||||||
|
let alt = conflict_name(name, now_secs());
|
||||||
|
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||||
|
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
|
||||||
|
self.adopt_drained_name(uid, &alt);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Rename,
|
||||||
|
name,
|
||||||
|
format!("name was taken remotely; renamed to {alt}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Ok(alt)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
|
||||||
|
/// the way when one is given.
|
||||||
|
fn move_renaming(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
parent: &NodeUid,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
) -> Result<(), ProtonError> {
|
||||||
|
let item = NodeMoveItem {
|
||||||
|
uid: uid.clone(),
|
||||||
|
target_name: target_name.map(str::to_string),
|
||||||
|
};
|
||||||
|
self.rt.block_on(async {
|
||||||
|
let mut outcomes =
|
||||||
|
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
|
||||||
|
match outcomes.next().await {
|
||||||
|
Some(Ok((_, outcome))) => outcome,
|
||||||
|
Some(Err(e)) => Err(e),
|
||||||
|
None => Err(ProtonError::invalid_operation(format!(
|
||||||
|
"move of {uid} reported no outcome"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a queued trash to the remote.
|
/// Apply a queued trash to the remote.
|
||||||
///
|
///
|
||||||
/// A node that is already gone is a success, not a failure: the outcome the
|
/// A node that is already gone is a success, not a failure: the outcome the
|
||||||
@@ -886,6 +945,7 @@ impl Core {
|
|||||||
if !meta.complete {
|
if !meta.complete {
|
||||||
return Err("queued create holds an incomplete blob".into());
|
return Err("queued create holds an incomplete blob".into());
|
||||||
}
|
}
|
||||||
|
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
|
||||||
let guard = self
|
let guard = self
|
||||||
.transfers
|
.transfers
|
||||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -896,7 +956,7 @@ impl Core {
|
|||||||
media_type_for(name),
|
media_type_for(name),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1086,6 +1146,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
|
let thumbnails = self.upload_thumbnails(blob, &alt);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1096,7 +1157,7 @@ impl Core {
|
|||||||
media_type_for(&alt),
|
media_type_for(&alt),
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
false,
|
false,
|
||||||
))?;
|
))?;
|
||||||
@@ -1123,6 +1184,10 @@ impl Core {
|
|||||||
format!("{reason}; local changes uploaded as {alt}"),
|
format!("{reason}; local changes uploaded as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
self.events.publish(&[
|
||||||
|
pdfs_core::control::Topic::Files,
|
||||||
|
pdfs_core::control::Topic::Conflicts,
|
||||||
|
]);
|
||||||
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
info!(%uid, name, alt, "queued write landed as a conflict copy");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1157,6 +1222,101 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any mount still holds an open handle on `uid`.
|
||||||
|
///
|
||||||
|
/// A transient file that is still open is still being written, however long
|
||||||
|
/// it has been parked, and the rename that finishes it has not happened yet.
|
||||||
|
fn uid_is_open(&self, uid: &NodeUid) -> bool {
|
||||||
|
let mut open = false;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
if open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open = st
|
||||||
|
.by_uid
|
||||||
|
.get(uid)
|
||||||
|
.and_then(|ino| st.entries.get(ino))
|
||||||
|
.is_some_and(|entry| entry.open_count > 0);
|
||||||
|
});
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire parks that are never going to end (docs/BUGS.md B70).
|
||||||
|
///
|
||||||
|
/// A parked create waits for one event: the rename from the transient name
|
||||||
|
/// to the finished one. Nothing else ever un-parks it, and plenty of writers
|
||||||
|
/// never perform it — an editor unlinks its swap file, a download is
|
||||||
|
/// abandoned, or the file was always going to be called `index.tmp`. Such a
|
||||||
|
/// row stayed at [`PARK_UNTIL`] for the life of the database, pinning the
|
||||||
|
/// only copy of its bytes in `staging/` and counting against the queue.
|
||||||
|
///
|
||||||
|
/// Two outcomes, and neither deletes bytes a user can still see:
|
||||||
|
///
|
||||||
|
/// * The node is gone from the tree — the file was deleted while its create
|
||||||
|
/// was parked, so the create is no longer wanted. The op and its blob go,
|
||||||
|
/// which is what [`Core::discard_queued_ops`] would have done had the
|
||||||
|
/// delete path reached it.
|
||||||
|
/// * The node is still there and has been parked past
|
||||||
|
/// [`PARK_EXPIRY_MS`] with nothing holding it open — the park has outlived
|
||||||
|
/// the writer it was protecting. Un-park it, and the ordinary drain
|
||||||
|
/// uploads it like any other create.
|
||||||
|
///
|
||||||
|
/// An open node is left alone whatever its age: it is still being written.
|
||||||
|
fn sweep_parked_creates(&self) {
|
||||||
|
let parked = match self.db.parked_create_ops() {
|
||||||
|
Ok(parked) if !parked.is_empty() => parked,
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading parked creates failed; skipping the park sweep");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let now = now_millis();
|
||||||
|
let (mut released, mut dropped) = (0usize, 0usize);
|
||||||
|
for op in &parked {
|
||||||
|
let Some(uid) = parse_node_uid(&op.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = op.name.as_deref().unwrap_or("?");
|
||||||
|
let known = match self.db.node_by_uid(&op.uid) {
|
||||||
|
Ok(known) => known.is_some(),
|
||||||
|
// Never infer "deleted" from a failed read: that would discard
|
||||||
|
// the bytes over a transient database error.
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%uid, %error, "cannot tell whether a parked node still exists");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !known {
|
||||||
|
if let Err(error) = self.discard_queued_ops(&uid) {
|
||||||
|
warn!(%uid, name, ?error, "dropping a deleted transient's parked create failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dropped += 1;
|
||||||
|
debug!(%uid, name, "dropped a parked create whose file was deleted");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if park_verdict(op.created_at, now, self.uid_is_open(&uid)) != ParkVerdict::Release {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.db.set_create_hold(&op.uid, false) {
|
||||||
|
Ok(true) => {
|
||||||
|
released += 1;
|
||||||
|
info!(%uid, name, parked_ms = now - op.created_at,
|
||||||
|
"un-parked a transient create that was never renamed; uploading it");
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(error) => warn!(%uid, name, %error, "un-parking an expired park failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if released > 0 || dropped > 0 {
|
||||||
|
info!(released, dropped, "swept parked creates");
|
||||||
|
}
|
||||||
|
if released > 0 {
|
||||||
|
self.wake_drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
||||||
///
|
///
|
||||||
/// A supersede can replace the entry while the op that owned it is in
|
/// A supersede can replace the entry while the op that owned it is in
|
||||||
@@ -1284,6 +1444,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = self.node_name(&uid);
|
let name = self.node_name(&uid);
|
||||||
|
let thumbnails = self.upload_thumbnails(&blob, &name);
|
||||||
let guard =
|
let guard =
|
||||||
self.transfers
|
self.transfers
|
||||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||||
@@ -1297,7 +1458,7 @@ impl Core {
|
|||||||
&uid,
|
&uid,
|
||||||
reader,
|
reader,
|
||||||
meta.len as i64,
|
meta.len as i64,
|
||||||
Vec::new(),
|
thumbnails,
|
||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
let took = started.elapsed();
|
let took = started.elapsed();
|
||||||
@@ -1529,6 +1690,24 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
|
|||||||
complete && remote_rev.is_some() && remote_rev == own_rev
|
complete && remote_rev.is_some() && remote_rev == own_rev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the park sweep does with one parked create whose node still exists.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ParkVerdict {
|
||||||
|
/// Leave it parked: the rename it waits for can still come.
|
||||||
|
Keep,
|
||||||
|
/// Un-park it: nothing will rename it now, so the bytes go up as they are.
|
||||||
|
Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Judge one parked create by age alone. An open node is kept whatever its age
|
||||||
|
/// — it is still being written, and the rename is the writer's last step.
|
||||||
|
fn park_verdict(created_at: i64, now: i64, open: bool) -> ParkVerdict {
|
||||||
|
if open || now - created_at < PARK_EXPIRY_MS {
|
||||||
|
return ParkVerdict::Keep;
|
||||||
|
}
|
||||||
|
ParkVerdict::Release
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1554,6 +1733,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The park's only ordinary exit is the transient-to-final rename. The
|
||||||
|
/// sweep is what happens when that rename never comes.
|
||||||
|
#[test]
|
||||||
|
fn a_park_expires_only_once_it_is_old_and_nobody_holds_the_file() {
|
||||||
|
let now = PARK_EXPIRY_MS * 10;
|
||||||
|
// Fresh: the writer may still rename it.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS + 1, now, false),
|
||||||
|
ParkVerdict::Keep
|
||||||
|
);
|
||||||
|
// Old, but still open: a slow write is not an abandoned one.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS, now, true),
|
||||||
|
ParkVerdict::Keep
|
||||||
|
);
|
||||||
|
// Old and closed: no rename is coming.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS, now, false),
|
||||||
|
ParkVerdict::Release
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drain_authorities_match_each_operation_kind() {
|
fn drain_authorities_match_each_operation_kind() {
|
||||||
for kind in [OP_REVISION, OP_TRASH] {
|
for kind in [OP_REVISION, OP_TRASH] {
|
||||||
@@ -1587,6 +1788,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1879,6 +2082,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ impl Filesystem for ProtonFs {
|
|||||||
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
||||||
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
||||||
let core = self.core.clone();
|
let core = self.core.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "statfs", move || {
|
||||||
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
||||||
let bsize = u64::from(REPORTED_BLKSIZE);
|
let bsize = u64::from(REPORTED_BLKSIZE);
|
||||||
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
||||||
@@ -89,7 +89,7 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "lookup", move || {
|
||||||
fs.serve_lookup(parent, &name, reply, true)
|
fs.serve_lookup(parent, &name, reply, true)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ impl Filesystem for ProtonFs {
|
|||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core
|
||||||
.workers
|
.workers
|
||||||
.run(Lane::Meta, move || fs.serve_opendir(ino, reply));
|
.run(Lane::Meta, "opendir", move || fs.serve_opendir(ino, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn readdir(
|
fn readdir(
|
||||||
@@ -210,9 +210,9 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "readdir", move || {
|
||||||
.workers
|
fs.serve_readdir(ino, offset, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn releasedir(
|
fn releasedir(
|
||||||
@@ -237,9 +237,9 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Transfer, "open", move || {
|
||||||
.workers
|
fs.serve_open(ino, flags, reply)
|
||||||
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(
|
fn read(
|
||||||
@@ -329,7 +329,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// even read off the FUSE device. It only reads `state`, so moving it
|
// even read off the FUSE device. It only reads `state`, so moving it
|
||||||
// races with nothing. FUSE does not require replies in request order.
|
// races with nothing. FUSE does not require replies in request order.
|
||||||
let core = self.core.clone();
|
let core = self.core.clone();
|
||||||
self.core.workers.run(Lane::Transfer, move || {
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
|
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
|
||||||
Ok(bytes) => reply.data(&bytes),
|
Ok(bytes) => reply.data(&bytes),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
@@ -366,9 +366,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// one create at a time, with `ls` on the same mount timing out for as
|
// one create at a time, with `ls` on the same mount timing out for as
|
||||||
// long as the burst lasted. See [`Workers`].
|
// long as the burst lasted. See [`Workers`].
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "create", move || {
|
||||||
.workers
|
fs.serve_create(parent, &name, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_create(parent, &name, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(
|
fn write(
|
||||||
@@ -506,7 +506,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// the scratch file).
|
// the scratch file).
|
||||||
let ino = ino.0;
|
let ino = ino.0;
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Transfer, move || {
|
self.core.workers.run(Lane::Transfer, "truncate", move || {
|
||||||
if let Err(e) = fs.core.queue_truncate(ino, size) {
|
if let Err(e) = fs.core.queue_truncate(ino, size) {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
@@ -620,7 +620,7 @@ impl Filesystem for ProtonFs {
|
|||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core
|
||||||
.workers
|
.workers
|
||||||
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
|
.run(Lane::Transfer, "fsync", move || fs.serve_fsync(fh, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release(
|
fn release(
|
||||||
@@ -680,7 +680,7 @@ impl Filesystem for ProtonFs {
|
|||||||
};
|
};
|
||||||
// `trash_child` is a remote call; see `serve_create`.
|
// `trash_child` is a remote call; see `serve_create`.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "unlink", move || {
|
||||||
fs.serve_unlink(parent, &name_str, reply)
|
fs.serve_unlink(parent, &name_str, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -701,9 +701,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
|
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
|
||||||
// it: two remote calls. See `serve_create`.
|
// it: two remote calls. See `serve_create`.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "rmdir", move || {
|
||||||
.workers
|
fs.serve_rmdir(parent, &name_str, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_rmdir(parent, &name_str, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mkdir(
|
fn mkdir(
|
||||||
@@ -731,9 +731,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// remote round trip, and inline it blocks every other request on the
|
// remote round trip, and inline it blocks every other request on the
|
||||||
// mount until it answers.
|
// mount until it answers.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "mkdir", move || {
|
||||||
.workers
|
fs.serve_mkdir(parent, &name, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_mkdir(parent, &name, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rename(
|
fn rename(
|
||||||
@@ -774,7 +774,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// move), and the replaced-destination path adds a trash on top. See
|
// move), and the replaced-destination path adds a trash on top. See
|
||||||
// `serve_create` for why none of that may run on the dispatch loop.
|
// `serve_create` for why none of that may run on the dispatch loop.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "rename", move || {
|
||||||
fs.serve_rename(parent, &name, newparent, &newname, flags, reply)
|
fs.serve_rename(parent, &name, newparent, &newname, flags, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -799,7 +799,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// network per call (B5).
|
// network per call (B5).
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
let name = name.to_os_string();
|
let name = name.to_os_string();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "getxattr", move || {
|
||||||
fs.serve_getxattr(ino, &name, size, reply)
|
fs.serve_getxattr(ino, &name, size, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1099,7 @@ impl ProtonFs {
|
|||||||
if !off_loop {
|
if !off_loop {
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
let name = name.to_string();
|
let name = name.to_string();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "lookup", move || {
|
||||||
fs.serve_lookup(parent, &name, reply, true)
|
fs.serve_lookup(parent, &name, reply, true)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
|||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use fuser::ReplyXattr;
|
use fuser::ReplyXattr;
|
||||||
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
TransferDirection,
|
ThumbnailBuildStatus, Topic, TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
@@ -80,28 +80,36 @@ use proton_drive_rs::{
|
|||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
mod background;
|
mod background;
|
||||||
|
mod conflicts;
|
||||||
mod control;
|
mod control;
|
||||||
use control::run_control_socket;
|
use control::run_control_socket;
|
||||||
mod devices;
|
mod devices;
|
||||||
|
mod diagnostics;
|
||||||
mod drain;
|
mod drain;
|
||||||
|
mod events;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
|
mod pause;
|
||||||
mod photos;
|
mod photos;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod queue;
|
||||||
mod reads;
|
mod reads;
|
||||||
|
mod redate;
|
||||||
mod revisions;
|
mod revisions;
|
||||||
mod sharing;
|
mod sharing;
|
||||||
mod shutdown;
|
mod shutdown;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod supervisor;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod systemd;
|
||||||
mod takeout;
|
mod takeout;
|
||||||
mod transfers;
|
mod transfers;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod r#virtual;
|
mod r#virtual;
|
||||||
mod workers;
|
mod workers;
|
||||||
use background::{run_event_sync, run_local_index};
|
use background::{run_event_sync, run_local_index, run_photos_event_sync};
|
||||||
pub(crate) use mount::is_stale_mount;
|
pub(crate) use mount::is_stale_mount;
|
||||||
pub use mount::{MountOptions, MountOutcome, mount};
|
pub use mount::{MountOptions, MountOutcome, mount};
|
||||||
use mount::{
|
use mount::{
|
||||||
@@ -193,19 +201,42 @@ const UPLOAD_TIME_MEMORY: usize = 512;
|
|||||||
/// for the SDK's own per-request concurrency.
|
/// for the SDK's own per-request concurrency.
|
||||||
const DRAIN_WORKERS: usize = 3;
|
const DRAIN_WORKERS: usize = 3;
|
||||||
|
|
||||||
|
/// How long a staged blob that names no node is kept before it is retired.
|
||||||
|
///
|
||||||
|
/// Staging is exempt from the cache budget because it holds the only copy of
|
||||||
|
/// writes that have not been uploaded, so nothing there is ever evicted to
|
||||||
|
/// reclaim space. That is right for a blob that still describes something. It is
|
||||||
|
/// wrong for one whose sidecar is missing or names a `local~` placeholder whose
|
||||||
|
/// create is gone: no future run can address it, no UI accounts for it, and it
|
||||||
|
/// occupies disk for the life of the machine. Real instance: 95 such blobs,
|
||||||
|
/// ~445 MiB, the oldest two months old, re-announced on every mount.
|
||||||
|
///
|
||||||
|
/// A month is far longer than any transfer or any plausible "I will look at that
|
||||||
|
/// later", and the retirement is announced per blob with its size, so the one
|
||||||
|
/// case that matters — bytes a human still wants — has a month of warnings and a
|
||||||
|
/// path to go and fetch them from.
|
||||||
|
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||||
|
|
||||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||||
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
||||||
/// How long the persisted photos timeline stays good before a page request
|
/// How long the persisted photos timeline stays good before a page request
|
||||||
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
||||||
/// in the DB and every page is sliced from there; a stale one is still served
|
/// in the DB and every page is sliced from there; a stale one is still served
|
||||||
/// immediately and refreshed in the background.
|
/// immediately and refreshed in the background.
|
||||||
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
|
///
|
||||||
|
/// Short, because the cost of a stale timeline is a photo the user deleted on
|
||||||
|
/// their phone still sitting in the gallery, and the cost of the check is one
|
||||||
|
/// listing call that only runs when somebody is actually looking at the page.
|
||||||
|
const TIMELINE_TTL: Duration = Duration::from_secs(60);
|
||||||
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
|
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
|
||||||
/// call when enriching a refreshed timeline with names and media types (for the
|
/// call when enriching a refreshed timeline with names and media types (for the
|
||||||
/// Photos / Videos / Raw split). Batched so a large library is a handful of
|
/// Photos / Videos / Raw split). Batched so a large library is a handful of
|
||||||
/// round-trips rather than one request per photo, and bounded so a single call
|
/// round-trips rather than one request per photo, and bounded so a single call
|
||||||
/// never asks the server to decrypt the whole library at once.
|
/// never asks the server to decrypt the whole library at once.
|
||||||
const TIMELINE_ENRICH_CHUNK: usize = 200;
|
///
|
||||||
|
/// 150 because that is the SDK's own `MAX_BATCH_COUNT`: a larger chunk is split
|
||||||
|
/// there anyway, into one full request and a ragged tail.
|
||||||
|
const TIMELINE_ENRICH_CHUNK: usize = 150;
|
||||||
/// The same, for the persisted trash listing. Shorter, because the trash is the
|
/// The same, for the persisted trash listing. Shorter, because the trash is the
|
||||||
/// one listing a user changes and then immediately looks at — though our own
|
/// one listing a user changes and then immediately looks at — though our own
|
||||||
/// mutations also invalidate it outright, so this only covers other clients.
|
/// mutations also invalidate it outright, so this only covers other clients.
|
||||||
@@ -241,6 +272,12 @@ const PHOTOS_AVAILABLE: &str = "photos_available";
|
|||||||
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
const ALBUMS_SYNCED_MS: &str = "albums_synced_ms";
|
||||||
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
const ALBUM_SYNCED_PREFIX: &str = "album_synced_ms:";
|
||||||
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
const TRASH_SYNCED_MS: &str = "trash_synced_ms";
|
||||||
|
/// Set when a trash refresh finishes and cleared when one starts, so it is only
|
||||||
|
/// present while the stored listing is the whole trash as of some moment. A node
|
||||||
|
/// that a refresh finds and that listing lacks went to the trash since then; one
|
||||||
|
/// found after an interrupted refresh may have been there all along. Unlike
|
||||||
|
/// [`TRASH_SYNCED_MS`], our own mutations leave it alone.
|
||||||
|
const TRASH_COMPLETE_MS: &str = "trash_complete_ms";
|
||||||
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
const SHARED_WITH_ME_NAME: &str = "shared_with_me_name";
|
||||||
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
const SHARED_WITH_ME_SYNCED_MS: &str = "shared_with_me_synced_ms";
|
||||||
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
const SHARED_FOLDER_SYNCED_PREFIX: &str = "shared_folder_synced_ms:";
|
||||||
@@ -374,6 +411,45 @@ impl SearchRoots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negative thumbnail knowledge has two different authorities. A remote miss
|
||||||
|
/// only says Proton has no thumbnail to download; it must never suppress local
|
||||||
|
/// generation. A local miss says this exact file revision was successfully
|
||||||
|
/// inspected and could not be decoded. Both maps are bounded independently.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ThumbnailMissCaches {
|
||||||
|
remote: HashMap<(NodeUid, i32), i64>,
|
||||||
|
local: HashMap<(NodeUid, i32), i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailMissCaches {
|
||||||
|
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.remote.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.local.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.remote, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.local, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_local(&mut self, key: &(NodeUid, i32)) {
|
||||||
|
self.local.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
|
||||||
|
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
map.insert(key, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
||||||
/// root itself resolves to the empty path; an empty root is the mount itself
|
/// root itself resolves to the empty path; an empty root is the mount itself
|
||||||
/// and covers everything.
|
/// and covers everything.
|
||||||
@@ -442,6 +518,10 @@ struct Core {
|
|||||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||||
/// infer it from a wall of EIO.
|
/// infer it from a wall of EIO.
|
||||||
online: Arc<AtomicBool>,
|
online: Arc<AtomicBool>,
|
||||||
|
/// When the user paused syncing, the Unix second it resumes by itself;
|
||||||
|
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||||
|
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||||
|
sync_paused_until: Arc<AtomicI64>,
|
||||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||||
/// which it is rebuilt on mount.
|
/// which it is rebuilt on mount.
|
||||||
@@ -477,17 +557,25 @@ struct Core {
|
|||||||
/// already running, so a burst of page requests against a stale listing kicks
|
/// already running, so a burst of page requests against a stale listing kicks
|
||||||
/// off one refresh rather than one per request.
|
/// off one refresh rather than one per request.
|
||||||
timeline_refreshing: Arc<AtomicBool>,
|
timeline_refreshing: Arc<AtomicBool>,
|
||||||
|
/// How far the running timeline refresh has got resolving photo metadata,
|
||||||
|
/// as `(done, total)` photos; `None` when no refresh is resolving any. The
|
||||||
|
/// Places view shows it while a library's locations are first read.
|
||||||
|
timeline_progress: Arc<Mutex<Option<(usize, usize)>>>,
|
||||||
/// The same, for the album listing.
|
/// The same, for the album listing.
|
||||||
albums_refreshing: Arc<AtomicBool>,
|
albums_refreshing: Arc<AtomicBool>,
|
||||||
trash_refreshing: Arc<AtomicBool>,
|
trash_refreshing: Arc<AtomicBool>,
|
||||||
|
/// True while the similar-photo pass hashes thumbnails (see
|
||||||
|
/// [`Core::photo_similar`]), so asking again doesn't start a second one.
|
||||||
|
similar_hashing: Arc<AtomicBool>,
|
||||||
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
/// Fires whenever a trash refresh persists a batch or finishes, so a
|
||||||
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
|
||||||
/// instead of polling or blocking for the whole run. See
|
/// instead of polling or blocking for the whole run. See
|
||||||
/// [`Core::await_trash_refresh`].
|
/// [`Core::await_trash_refresh`].
|
||||||
trash_progress: Arc<tokio::sync::Notify>,
|
trash_progress: Arc<tokio::sync::Notify>,
|
||||||
/// Conflict copies the sweep has already flagged as needing attention this
|
/// Conflict copies the sweep has already checked against the activity log
|
||||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
/// this run, so each pass does not ask the database again. The log itself
|
||||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
/// is what keeps a copy from being flagged again after a restart. See
|
||||||
|
/// [`Core::run_conflict_sweep_loop`] and `docs/BUGS.md` B99.
|
||||||
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
conflict_notified: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||||
/// only report them. Resolved once at mount from config + environment
|
/// only report them. Resolved once at mount from config + environment
|
||||||
@@ -511,8 +599,22 @@ struct Core {
|
|||||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||||
/// twice.
|
/// twice.
|
||||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
|
/// Global permit pool for locally generated thumbnails. The old per-request
|
||||||
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
|
/// limit multiplied when the GUI sent several batches, allowing dozens of
|
||||||
|
/// full-size images to download at once.
|
||||||
|
thumb_gen_budget: Arc<tokio::sync::Semaphore>,
|
||||||
|
/// Current ordinary-file listing generation and a wake-up for tasks waiting
|
||||||
|
/// on a permit or a download when that listing is abandoned.
|
||||||
|
file_thumb_generation: Arc<AtomicU64>,
|
||||||
|
file_thumb_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Progress of the explicit recursive build started from the Files toolbar.
|
||||||
|
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
|
||||||
|
/// Cancellation for the explicit recursive build. Separate from ordinary
|
||||||
|
/// listing cancellation because the two jobs have independent lifetimes.
|
||||||
|
thumbnail_build_cancelled: Arc<AtomicBool>,
|
||||||
|
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Negative thumbnail knowledge, separated by whether the remote lacked a
|
||||||
|
/// thumbnail or local decoding proved this revision unsupported.
|
||||||
///
|
///
|
||||||
/// Absence has to be cached or it costs a round trip every time it is asked
|
/// Absence has to be cached or it costs a round trip every time it is asked
|
||||||
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
||||||
@@ -520,7 +622,7 @@ struct Core {
|
|||||||
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
||||||
/// is the validity tag — a new revision may well have a thumbnail — matching
|
/// is the validity tag — a new revision may well have a thumbnail — matching
|
||||||
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
||||||
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>,
|
thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
|
||||||
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
||||||
///
|
///
|
||||||
/// `df` and the free-space preflight of every file manager and installer ask
|
/// `df` and the free-space preflight of every file manager and installer ask
|
||||||
@@ -564,6 +666,9 @@ struct Core {
|
|||||||
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
/// In-flight upload/download progress, served to `GetQueueStatus`. Shared
|
||||||
/// across the FUSE session and the control-socket task.
|
/// across the FUSE session and the control-socket task.
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
|
/// Open [`Request::Subscribe`](pdfs_core::control::Request::Subscribe)
|
||||||
|
/// connections, which every change a front-end shows is published to.
|
||||||
|
events: Arc<events::EventHub>,
|
||||||
/// True while the background scanner is rebuilding the local-file index, so
|
/// True while the background scanner is rebuilding the local-file index, so
|
||||||
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
/// `SearchLocal` can tell a front-end "still indexing" apart from "no match".
|
||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
@@ -1330,6 +1435,15 @@ fn release_must_retain_queued_trash(db: &Db, uid: &NodeUid) -> pdfs_core::Result
|
|||||||
db.has_pending_op(&uid.to_string(), OP_TRASH)
|
db.has_pending_op(&uid.to_string(), OP_TRASH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size of a staged blob that has outlived [`STAGING_ORPHAN_RETAIN`], or
|
||||||
|
/// `None` while it is younger than that — or while its age cannot be read at
|
||||||
|
/// all, which is never grounds for deleting bytes.
|
||||||
|
fn orphan_past_retention(blob: &Path) -> Option<u64> {
|
||||||
|
let meta = std::fs::metadata(blob).ok()?;
|
||||||
|
let age = meta.modified().ok()?.elapsed().ok()?;
|
||||||
|
(age >= STAGING_ORPHAN_RETAIN).then_some(meta.len())
|
||||||
|
}
|
||||||
|
|
||||||
fn release_can_discard_unlinked(db: &Db, uid: &NodeUid) -> bool {
|
fn release_can_discard_unlinked(db: &Db, uid: &NodeUid) -> bool {
|
||||||
match release_must_retain_queued_trash(db, uid) {
|
match release_must_retain_queued_trash(db, uid) {
|
||||||
Ok(retain) => !retain,
|
Ok(retain) => !retain,
|
||||||
@@ -1654,6 +1768,9 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let mut queued = 0usize;
|
let mut queued = 0usize;
|
||||||
let mut stranded = 0usize;
|
let mut stranded = 0usize;
|
||||||
|
let mut retired = 0usize;
|
||||||
|
let mut retired_bytes = 0u64;
|
||||||
|
let mut example: Option<PathBuf> = None;
|
||||||
for (blob, meta) in staged {
|
for (blob, meta) in staged {
|
||||||
if claimed.contains(&blob.to_string_lossy().into_owned()) {
|
if claimed.contains(&blob.to_string_lossy().into_owned()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1661,9 +1778,17 @@ impl Core {
|
|||||||
let addressable = meta.as_ref().and_then(|m| parse_node_uid(&m.uid));
|
let addressable = meta.as_ref().and_then(|m| parse_node_uid(&m.uid));
|
||||||
let Some((meta, uid)) = meta.zip(addressable).filter(|(_, uid)| !is_local_uid(uid))
|
let Some((meta, uid)) = meta.zip(addressable).filter(|(_, uid)| !is_local_uid(uid))
|
||||||
else {
|
else {
|
||||||
|
if let Some(bytes) = orphan_past_retention(&blob) {
|
||||||
|
retired += 1;
|
||||||
|
retired_bytes += bytes;
|
||||||
|
warn!(blob = %blob.display(), bytes,
|
||||||
|
age_days = STAGING_ORPHAN_RETAIN.as_secs() / 86_400,
|
||||||
|
"retiring staged bytes that name no node and have outlived retention");
|
||||||
|
self.cache.discard_staged(&blob);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
stranded += 1;
|
stranded += 1;
|
||||||
warn!(blob = %blob.display(),
|
example.get_or_insert_with(|| blob.clone());
|
||||||
"staged bytes belong to no queued upload and name no node; kept for recovery");
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self.pending.lock().contains_key(&uid) {
|
if self.pending.lock().contains_key(&uid) {
|
||||||
@@ -1709,8 +1834,21 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if queued > 0 || stranded > 0 {
|
if stranded > 0 {
|
||||||
info!(queued, stranded, "reconciled orphaned staged writes");
|
// One line, not one per blob. A tree of them says exactly as much as
|
||||||
|
// a single count and an example does, and said it on every mount:
|
||||||
|
// 86 identical warnings for the same 86 files, boot after boot.
|
||||||
|
warn!(
|
||||||
|
count = stranded,
|
||||||
|
example = %example.map(|b| b.display().to_string()).unwrap_or_default(),
|
||||||
|
"staged bytes belong to no queued upload and name no node; kept for recovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if queued > 0 || stranded > 0 || retired > 0 {
|
||||||
|
info!(
|
||||||
|
queued,
|
||||||
|
stranded, retired, retired_bytes, "reconciled orphaned staged writes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3580,7 +3718,7 @@ impl Core {
|
|||||||
// "This file has no thumbnail" is an answer worth remembering: without
|
// "This file has no thumbnail" is an answer worth remembering: without
|
||||||
// it every listing pays a round trip per file to be told nothing (B5).
|
// it every listing pays a round trip per file to be told nothing (B5).
|
||||||
let key = (uid.clone(), ttype.as_i32());
|
let key = (uid.clone(), ttype.as_i32());
|
||||||
if self.no_thumbnail.lock().get(&key) == Some(&mtime) {
|
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let bytes = self
|
let bytes = self
|
||||||
@@ -3597,15 +3735,7 @@ impl Core {
|
|||||||
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut misses = self.no_thumbnail.lock();
|
self.thumbnail_misses.lock().remember_remote(key, mtime);
|
||||||
// Bounded by clearing rather than by LRU: the entries are two
|
|
||||||
// words each, the cap is far above any plausible working set,
|
|
||||||
// and re-learning a miss costs one round trip. Not worth a
|
|
||||||
// second data structure to order them.
|
|
||||||
if misses.len() >= MAX_THUMBNAIL_MISSES {
|
|
||||||
misses.clear();
|
|
||||||
}
|
|
||||||
misses.insert(key, mtime);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
@@ -3692,6 +3822,11 @@ impl Core {
|
|||||||
// My own tree: a role only means something under a share, and
|
// My own tree: a role only means something under a share, and
|
||||||
// this listing never crosses into one.
|
// this listing never crosses into one.
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
|
trashed_at: 0,
|
||||||
|
trashed_from: None,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -3742,10 +3877,25 @@ impl Core {
|
|||||||
/// Full-text search node names against the local SQLite index, mapping each
|
/// Full-text search node names against the local SQLite index, mapping each
|
||||||
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
||||||
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
||||||
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
|
/// Every pin, with `cached` saying whether a pinned file's content is on
|
||||||
|
/// disk, so a front-end listing pins can badge them without a round-trip
|
||||||
|
/// per file.
|
||||||
|
fn list_pins(&self) -> Vec<pdfs_core::cache::Pin> {
|
||||||
|
let mut pins = self.cache.list_pins();
|
||||||
|
for pin in pins.iter_mut().filter(|pin| pin.is_dir == Some(false)) {
|
||||||
|
if let Ok(Some(node)) = self.db.node_by_uid(&pin.uid) {
|
||||||
|
pin.cached =
|
||||||
|
self.cache
|
||||||
|
.is_cached(&node.uid, node.modification_time, node_size(&node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pins
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(&self, query: &str, limit: usize, scope: Option<&str>) -> CoreResult<Vec<SearchHit>> {
|
||||||
let hits = self
|
let hits = self
|
||||||
.db
|
.db
|
||||||
.search(query, limit)
|
.search_in(query, limit, scope)
|
||||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||||
let roots = self.search_roots();
|
let roots = self.search_roots();
|
||||||
let pinned = self.pinned_set();
|
let pinned = self.pinned_set();
|
||||||
@@ -3757,6 +3907,12 @@ impl Core {
|
|||||||
size: node_size(&h.node),
|
size: node_size(&h.node),
|
||||||
modified: h.node.modification_time,
|
modified: h.node.modification_time,
|
||||||
pinned: pinned.contains(&h.node.uid.to_string()),
|
pinned: pinned.contains(&h.node.uid.to_string()),
|
||||||
|
cached: !h.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&h.node.uid,
|
||||||
|
h.node.modification_time,
|
||||||
|
node_size(&h.node),
|
||||||
|
),
|
||||||
uid: h.node.uid.to_string(),
|
uid: h.node.uid.to_string(),
|
||||||
mounted_path: roots.resolve(&h.path),
|
mounted_path: roots.resolve(&h.path),
|
||||||
path: h.path,
|
path: h.path,
|
||||||
@@ -3824,6 +3980,12 @@ impl Core {
|
|||||||
size: node_size(&hit.node),
|
size: node_size(&hit.node),
|
||||||
modified: hit.node.modification_time,
|
modified: hit.node.modification_time,
|
||||||
pinned: is_pinned,
|
pinned: is_pinned,
|
||||||
|
cached: !hit.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&hit.node.uid,
|
||||||
|
hit.node.modification_time,
|
||||||
|
node_size(&hit.node),
|
||||||
|
),
|
||||||
uid,
|
uid,
|
||||||
mounted_path: roots.resolve(&hit.path),
|
mounted_path: roots.resolve(&hit.path),
|
||||||
path: hit.path,
|
path: hit.path,
|
||||||
@@ -4256,12 +4418,21 @@ impl Core {
|
|||||||
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
self.await_trash_refresh(TRASH_FIRST_WAIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Most of a trash comes from a handful of folders.
|
||||||
|
let mut origins: HashMap<String, Option<String>> = HashMap::new();
|
||||||
Ok(self
|
Ok(self
|
||||||
.db
|
.db
|
||||||
.trash_list()
|
.trash_list()
|
||||||
.map_err(CoreError::from)?
|
.map_err(CoreError::from)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|item| DirEntry {
|
.map(|item| DirEntry {
|
||||||
|
trashed_from: item.parent_uid.as_ref().and_then(|parent| {
|
||||||
|
origins
|
||||||
|
.entry(parent.clone())
|
||||||
|
.or_insert_with(|| self.db.node_path(parent).ok().flatten())
|
||||||
|
.clone()
|
||||||
|
}),
|
||||||
|
trashed_at: item.trashed_at.unwrap_or(0),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: item.size.max(0) as u64,
|
size: item.size.max(0) as u64,
|
||||||
@@ -4273,6 +4444,9 @@ impl Core {
|
|||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4297,6 +4471,18 @@ impl Core {
|
|||||||
"trash refresh: enumerated"
|
"trash refresh: enumerated"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// What the listing held before, and whether it was the whole trash: the
|
||||||
|
// chunks below overwrite it, so both are read up front.
|
||||||
|
let seen = self.db.trash_seen().unwrap_or_default();
|
||||||
|
let complete = self
|
||||||
|
.db
|
||||||
|
.state_i64(TRASH_COMPLETE_MS)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let _ = self.db.clear_state(TRASH_COMPLETE_MS);
|
||||||
|
let now = now_secs();
|
||||||
|
|
||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
self.db.trash_replace(&[]).map_err(CoreError::from)?;
|
||||||
}
|
}
|
||||||
@@ -4314,6 +4500,10 @@ impl Core {
|
|||||||
is_dir: node.is_folder(),
|
is_dir: node.is_folder(),
|
||||||
size: node_size(&node) as i64,
|
size: node_size(&node) as i64,
|
||||||
mtime: node.modification_time,
|
mtime: node.modification_time,
|
||||||
|
// The folder the node will go back to, which is also what tells
|
||||||
|
// a restore whether that folder is itself in the trash.
|
||||||
|
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
||||||
|
trashed_at: first_seen_in_trash(&seen, &node.uid.to_string(), complete, now),
|
||||||
}));
|
}));
|
||||||
// Cumulative, so the table is always a prefix of the real trash
|
// Cumulative, so the table is always a prefix of the real trash
|
||||||
// rather than a mix of this refresh and the last one.
|
// rather than a mix of this refresh and the last one.
|
||||||
@@ -4328,6 +4518,7 @@ impl Core {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms());
|
||||||
|
let _ = self.db.set_state_i64(TRASH_COMPLETE_MS, now_ms());
|
||||||
info!(
|
info!(
|
||||||
count = items.len(),
|
count = items.len(),
|
||||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||||
@@ -4346,10 +4537,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_trash().await {
|
let refreshed = core.refresh_trash().await;
|
||||||
warn!(error = %e, "background trash refresh failed");
|
|
||||||
}
|
|
||||||
core.trash_refreshing.store(false, Ordering::SeqCst);
|
core.trash_refreshing.store(false, Ordering::SeqCst);
|
||||||
|
match refreshed {
|
||||||
|
Ok(_) => core.events.publish(&[Topic::Trash]),
|
||||||
|
Err(e) => warn!(error = %e, "background trash refresh failed"),
|
||||||
|
}
|
||||||
// After the flag, so a waiter that wakes on this sees it cleared.
|
// After the flag, so a waiter that wakes on this sees it cleared.
|
||||||
core.trash_progress.notify_waiters();
|
core.trash_progress.notify_waiters();
|
||||||
});
|
});
|
||||||
@@ -4419,15 +4612,48 @@ impl Core {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore trashed nodes to the folders they were trashed from. The parents
|
/// Restore trashed nodes to the folders they were trashed from, together
|
||||||
/// are read *before* the restore — a restored node reappears in a listing the
|
/// with the rest of the trashed tree they belong to.
|
||||||
/// daemon may already have cached, so each destination folder is invalidated
|
///
|
||||||
/// and re-enumerated on next access.
|
/// The trash is a flat list, but a user deletes a shape: a folder, and often
|
||||||
|
/// items inside it that were trashed separately beforehand. Restoring one of
|
||||||
|
/// those in isolation is not what anyone means by it — a folder would come
|
||||||
|
/// back without the contents that were deleted with it, and a file would come
|
||||||
|
/// back inside a folder that is still in the trash, which is to say nowhere
|
||||||
|
/// the user can see. So a restore takes the whole connected piece:
|
||||||
|
/// every trashed descendant of what was asked for, and every trashed ancestor
|
||||||
|
/// above it. See [`expand_restore`] for the exact rule.
|
||||||
|
///
|
||||||
|
/// The waves are restored shallowest first, because a node cannot be put back
|
||||||
|
/// under a parent that is still trashed.
|
||||||
|
///
|
||||||
|
/// The destination parents are read *before* the restore — a restored node
|
||||||
|
/// reappears in a listing the daemon may already have cached, so each one is
|
||||||
|
/// invalidated and re-enumerated on next access.
|
||||||
fn restore(&self, uids: &[String]) -> CoreResult<usize> {
|
fn restore(&self, uids: &[String]) -> CoreResult<usize> {
|
||||||
let parsed = Self::parse_uids(uids)?;
|
let requested = Self::parse_uids(uids)?;
|
||||||
|
// A listing we could not read is not a reason to refuse the restore: it
|
||||||
|
// only costs the expansion, and the requested nodes still go back.
|
||||||
|
let relations = self.db.trash_parents().unwrap_or_default();
|
||||||
|
let waves = expand_restore(uids, &relations);
|
||||||
|
let waves: Vec<Vec<NodeUid>> = if waves.is_empty() {
|
||||||
|
vec![requested.clone()]
|
||||||
|
} else {
|
||||||
|
waves
|
||||||
|
.iter()
|
||||||
|
.map(|wave| {
|
||||||
|
wave.iter()
|
||||||
|
.filter_map(|uid| parse_uid(uid))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.filter(|wave: &Vec<NodeUid>| !wave.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let all: Vec<NodeUid> = waves.iter().flatten().cloned().collect();
|
||||||
let parents: Vec<NodeUid> = self
|
let parents: Vec<NodeUid> = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&parsed))
|
.block_on(self.client.enumerate_nodes(&all))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|n| n.parent_uid)
|
.filter_map(|n| n.parent_uid)
|
||||||
@@ -4438,11 +4664,12 @@ impl Core {
|
|||||||
// half way through a large restore leaves local state describing what the
|
// half way through a large restore leaves local state describing what the
|
||||||
// server actually did. Only a batch that restored nothing is an error the
|
// server actually did. Only a batch that restored nothing is an error the
|
||||||
// caller can act on.
|
// caller can act on.
|
||||||
let mut restored: Vec<NodeUid> = Vec::with_capacity(parsed.len());
|
let mut restored: Vec<NodeUid> = Vec::with_capacity(all.len());
|
||||||
let mut first_error: Option<ProtonError> = None;
|
let mut first_error: Option<ProtonError> = None;
|
||||||
|
for wave in &waves {
|
||||||
self.rt
|
self.rt
|
||||||
.block_on(async {
|
.block_on(async {
|
||||||
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(&parsed));
|
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(wave));
|
||||||
while let Some(item) = outcomes.next().await {
|
while let Some(item) = outcomes.next().await {
|
||||||
let (uid, outcome) = item?;
|
let (uid, outcome) = item?;
|
||||||
match outcome {
|
match outcome {
|
||||||
@@ -4459,6 +4686,7 @@ impl Core {
|
|||||||
Ok::<(), ProtonError>(())
|
Ok::<(), ProtonError>(())
|
||||||
})
|
})
|
||||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||||
|
}
|
||||||
if restored.is_empty()
|
if restored.is_empty()
|
||||||
&& let Some(error) = first_error
|
&& let Some(error) = first_error
|
||||||
{
|
{
|
||||||
@@ -4605,6 +4833,14 @@ impl Core {
|
|||||||
};
|
};
|
||||||
if let Err(e) = self.db.activity_add(&entry) {
|
if let Err(e) = self.db.activity_add(&entry) {
|
||||||
warn!(error = ?e, "could not record activity");
|
warn!(error = ?e, "could not record activity");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A sync pass is how a conflict copy reaches the listing, and the sweep
|
||||||
|
// reports the ones it finds as conflicts.
|
||||||
|
if matches!(entry.kind, ActivityKind::Conflict | ActivityKind::Sync) {
|
||||||
|
self.events.publish(&[Topic::Activity, Topic::Conflicts]);
|
||||||
|
} else {
|
||||||
|
self.events.publish(&[Topic::Activity]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4806,12 +5042,13 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
role: role_to_str(link.role).to_string(),
|
role: role_to_str(link.role).to_string(),
|
||||||
expires: link.expiration_time,
|
expires: link.expiration_time,
|
||||||
has_password: link.has_custom_password,
|
has_password: link.has_custom_password,
|
||||||
|
created: link.creation_time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
||||||
/// receive uids as strings over the control socket and pass them back verbatim.
|
/// receive uids as strings over the control socket and pass them back verbatim.
|
||||||
fn parse_uid(s: &str) -> Option<NodeUid> {
|
pub(crate) fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||||
let (vol, link) = s.split_once('~')?;
|
let (vol, link) = s.split_once('~')?;
|
||||||
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
||||||
return None;
|
return None;
|
||||||
@@ -4948,6 +5185,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
|||||||
album: None,
|
album: None,
|
||||||
// Nothing signed it: it has never been near the crypto layer.
|
// Nothing signed it: it has never been near the crypto layer.
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4983,7 +5222,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
|||||||
|
|
||||||
/// Convert a stored synced folder into its wire form for the front-ends, with
|
/// Convert a stored synced folder into its wire form for the front-ends, with
|
||||||
/// the live progress of its pass when one is running.
|
/// the live progress of its pass when one is running.
|
||||||
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
|
fn sync_folder_info(
|
||||||
|
f: StoredSyncFolder,
|
||||||
|
progress: Option<SyncProgress>,
|
||||||
|
paused: bool,
|
||||||
|
) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
@@ -4993,6 +5236,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
|||||||
state: f.state,
|
state: f.state,
|
||||||
last_sync: f.last_sync,
|
last_sync: f.last_sync,
|
||||||
progress,
|
progress,
|
||||||
|
paused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5231,6 +5475,115 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// When a node a trash refresh found went to the trash, as far as the daemon
|
||||||
|
/// can tell. A node the stored listing already had keeps the time it had. A new
|
||||||
|
/// one went there since the last refresh, so now is close, but only when that
|
||||||
|
/// refresh saw the whole trash (`complete`): after a first or interrupted one,
|
||||||
|
/// a node missing from the listing may be years old, and no date beats a wrong
|
||||||
|
/// one.
|
||||||
|
fn first_seen_in_trash(
|
||||||
|
seen: &HashMap<String, Option<i64>>,
|
||||||
|
uid: &str,
|
||||||
|
complete: bool,
|
||||||
|
now: i64,
|
||||||
|
) -> Option<i64> {
|
||||||
|
match seen.get(uid) {
|
||||||
|
Some(at) => *at,
|
||||||
|
None if complete => Some(now),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand a restore request across the trashed tree, and order it so a parent is
|
||||||
|
/// always restored before its children.
|
||||||
|
///
|
||||||
|
/// `relations` is the persisted trash listing as `(uid, parent_uid)` pairs; a
|
||||||
|
/// parent that is not itself in the listing is a live folder, which ends the
|
||||||
|
/// walk upward. The rule is:
|
||||||
|
///
|
||||||
|
/// - every trashed descendant of a requested node joins the restore, so a folder
|
||||||
|
/// comes back with the items that were trashed inside it;
|
||||||
|
/// - every trashed ancestor of a requested node joins it too, so a file never
|
||||||
|
/// lands in a folder that is still in the trash;
|
||||||
|
/// - a requested uid the listing does not know about is restored on its own,
|
||||||
|
/// because a stale or half-materialised listing must not silently drop it.
|
||||||
|
///
|
||||||
|
/// The result is one wave per depth, shallowest first. Waves are disjoint and
|
||||||
|
/// together contain each uid exactly once.
|
||||||
|
fn expand_restore(
|
||||||
|
requested: &[String],
|
||||||
|
relations: &[(String, Option<String>)],
|
||||||
|
) -> Vec<Vec<String>> {
|
||||||
|
let parents: HashMap<&str, Option<&str>> = relations
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, parent)| (uid.as_str(), parent.as_deref()))
|
||||||
|
.collect();
|
||||||
|
let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
for (uid, parent) in relations {
|
||||||
|
// Only a trashed parent: a live one keeps its child as a root here.
|
||||||
|
if let Some(parent) = parent.as_deref()
|
||||||
|
&& parents.contains_key(parent)
|
||||||
|
{
|
||||||
|
children.entry(parent).or_default().push(uid.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected: HashSet<&str> = HashSet::new();
|
||||||
|
for uid in requested {
|
||||||
|
let uid = uid.as_str();
|
||||||
|
if !parents.contains_key(uid) {
|
||||||
|
selected.insert(uid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Upward: every trashed ancestor, so the destination folder exists again.
|
||||||
|
let mut cursor = Some(uid);
|
||||||
|
while let Some(current) = cursor {
|
||||||
|
// A parent outside the listing is live: the walk stops under it.
|
||||||
|
if !parents.contains_key(current) || !selected.insert(current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = parents.get(current).copied().flatten();
|
||||||
|
}
|
||||||
|
// Downward: the whole trashed subtree, so the folder comes back full.
|
||||||
|
let mut stack = vec![uid];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
for child in children.get(current).into_iter().flatten() {
|
||||||
|
if selected.insert(child) {
|
||||||
|
stack.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth within the *trash*, not within the drive: a node whose parent is live
|
||||||
|
// (or unknown) is at depth 0 and can be restored straight away.
|
||||||
|
let mut waves: Vec<Vec<String>> = Vec::new();
|
||||||
|
for uid in &selected {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut cursor = parents.get(uid).copied().flatten();
|
||||||
|
// Only trashed ancestors count, and the walk is bounded by the listing's
|
||||||
|
// size: server data that somehow describes a cycle must not spin here.
|
||||||
|
while let Some(current) = cursor
|
||||||
|
&& parents.contains_key(current)
|
||||||
|
&& depth < parents.len()
|
||||||
|
{
|
||||||
|
depth += 1;
|
||||||
|
cursor = parents.get(current).copied().flatten();
|
||||||
|
}
|
||||||
|
if waves.len() <= depth {
|
||||||
|
waves.resize(depth + 1, Vec::new());
|
||||||
|
}
|
||||||
|
waves[depth].push((*uid).to_string());
|
||||||
|
}
|
||||||
|
// Stable output, so a restore of the same trash is the same sequence of calls
|
||||||
|
// every time — the batches are what shows up in the log and in the tests.
|
||||||
|
for wave in &mut waves {
|
||||||
|
wave.sort();
|
||||||
|
}
|
||||||
|
waves.retain(|wave| !wave.is_empty());
|
||||||
|
waves
|
||||||
|
}
|
||||||
|
|
||||||
/// The server revision id of a node's active revision, if it is a file that has
|
/// The server revision id of a node's active revision, if it is a file that has
|
||||||
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
||||||
fn node_revision_id(node: &Node) -> Option<String> {
|
fn node_revision_id(node: &Node) -> Option<String> {
|
||||||
@@ -5872,6 +6225,8 @@ mod pending_size_tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5929,15 +6284,171 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, fuse_name,
|
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||||
is_stale_mount, node_visible, note_self_change, parse_node_uid, prepare_shared_roots,
|
first_seen_in_trash, fuse_name, is_stale_mount, node_visible, note_self_change,
|
||||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
orphan_past_retention, parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
|
||||||
|
virtual_node,
|
||||||
};
|
};
|
||||||
use super::{Db, WriteAuthority};
|
use super::{Db, WriteAuthority};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Staged bytes that name no node are kept for a month, because the only
|
||||||
|
/// copy of a write must outlive the bug that stranded it. Past that, one
|
||||||
|
/// boot retires them instead of re-announcing them every boot forever.
|
||||||
|
#[test]
|
||||||
|
fn a_stranded_blob_is_retired_only_once_it_outlives_retention() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-orphan-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let blob = dir.join("blob");
|
||||||
|
std::fs::write(&blob, b"twelve bytes").unwrap();
|
||||||
|
|
||||||
|
// Written just now: still recoverable, still kept.
|
||||||
|
assert_eq!(orphan_past_retention(&blob), None);
|
||||||
|
|
||||||
|
set_mtime_ago(
|
||||||
|
&blob,
|
||||||
|
super::STAGING_ORPHAN_RETAIN + Duration::from_secs(60),
|
||||||
|
);
|
||||||
|
assert_eq!(orphan_past_retention(&blob), Some(12));
|
||||||
|
|
||||||
|
// A blob that is not there is not a blob to retire.
|
||||||
|
std::fs::remove_file(&blob).unwrap();
|
||||||
|
assert_eq!(orphan_past_retention(&blob), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backdate a file's mtime, which is what the retention check reads.
|
||||||
|
fn set_mtime_ago(path: &std::path::Path, ago: Duration) {
|
||||||
|
let when = std::time::SystemTime::now() - ago;
|
||||||
|
let secs = when
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as libc::time_t;
|
||||||
|
let times = [
|
||||||
|
libc::timeval {
|
||||||
|
tv_sec: secs,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
|
libc::timeval {
|
||||||
|
tv_sec: secs,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
|
||||||
|
// SAFETY: both pointers are valid for the call and nothing keeps them.
|
||||||
|
let rc = unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) };
|
||||||
|
assert_eq!(rc, 0, "utimes: {}", std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(uid, parent)` pairs in the shape [`Db::trash_parents`] returns.
|
||||||
|
fn relations(pairs: &[(&str, Option<&str>)]) -> Vec<(String, Option<String>)> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, parent)| (uid.to_string(), parent.map(str::to_string)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|v| v.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restoring a folder brings back what was trashed inside it, and the folder
|
||||||
|
/// goes first: the server has nowhere to put a child whose parent is trashed.
|
||||||
|
#[test]
|
||||||
|
fn restoring_a_folder_restores_its_trashed_contents_parents_first() {
|
||||||
|
let trash = relations(&[
|
||||||
|
("dir", Some("live")),
|
||||||
|
("file", Some("dir")),
|
||||||
|
("deep", Some("sub")),
|
||||||
|
("sub", Some("dir")),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["dir"]), &trash),
|
||||||
|
vec![
|
||||||
|
vec!["dir".to_string()],
|
||||||
|
vec!["file".to_string(), "sub".to_string()],
|
||||||
|
vec!["deep".to_string()],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restoring a file inside a trashed folder restores the folder too —
|
||||||
|
/// otherwise the file comes back somewhere the user cannot reach.
|
||||||
|
#[test]
|
||||||
|
fn restoring_content_restores_the_trashed_parents_above_it() {
|
||||||
|
let trash = relations(&[
|
||||||
|
("dir", Some("live")),
|
||||||
|
("sub", Some("dir")),
|
||||||
|
("file", Some("sub")),
|
||||||
|
("other", Some("dir")),
|
||||||
|
]);
|
||||||
|
// `other` is a sibling of `sub`, not an ancestor of `file`: it stays put.
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["file"]), &trash),
|
||||||
|
vec![
|
||||||
|
vec!["dir".to_string()],
|
||||||
|
vec!["sub".to_string()],
|
||||||
|
vec!["file".to_string()],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node whose parent is not in the trash is already at the top: one wave,
|
||||||
|
/// no ancestors invented.
|
||||||
|
#[test]
|
||||||
|
fn a_node_under_a_live_parent_restores_on_its_own() {
|
||||||
|
let trash = relations(&[("file", Some("live")), ("elsewhere", None)]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["file"]), &trash),
|
||||||
|
vec![vec!["file".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing is materialised in chunks and can be stale, so a uid it does
|
||||||
|
/// not know about must still be restored rather than dropped.
|
||||||
|
#[test]
|
||||||
|
fn an_unlisted_uid_is_still_restored() {
|
||||||
|
let trash = relations(&[("dir", None)]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["missing"]), &trash),
|
||||||
|
vec![vec!["missing".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asking for both a folder and something inside it restores each node once.
|
||||||
|
#[test]
|
||||||
|
fn overlapping_requests_do_not_duplicate_nodes() {
|
||||||
|
let trash = relations(&[("dir", None), ("file", Some("dir"))]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["dir", "file"]), &trash),
|
||||||
|
vec![vec!["dir".to_string()], vec!["file".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node keeps the time it was first seen in the trash; a new one is dated
|
||||||
|
/// now only when the previous refresh saw the whole trash.
|
||||||
|
#[test]
|
||||||
|
fn trash_dates_are_kept_and_only_guessed_after_a_complete_refresh() {
|
||||||
|
let seen = HashMap::from([("old".to_string(), Some(5)), ("unknown".to_string(), None)]);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", true, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "old", false, 9), Some(5));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "unknown", true, 9), None);
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", true, 9), Some(9));
|
||||||
|
assert_eq!(first_seen_in_trash(&seen, "new", false, 9), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent chain that loops back on itself must terminate.
|
||||||
|
#[test]
|
||||||
|
fn a_cyclic_listing_terminates() {
|
||||||
|
let trash = relations(&[("a", Some("b")), ("b", Some("a"))]);
|
||||||
|
let waves = expand_restore(&uids(&["a"]), &trash);
|
||||||
|
let total: usize = waves.iter().map(Vec::len).sum();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
/// The collapse [`Core::require_uid_writable`] performs, without a `Core`:
|
/// The collapse [`Core::require_uid_writable`] performs, without a `Core`:
|
||||||
/// every non-writable authority is one `EACCES` on the syscall path.
|
/// every non-writable authority is one `EACCES` on the syscall path.
|
||||||
@@ -7082,6 +7593,9 @@ mod tests {
|
|||||||
share_id,
|
share_id,
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
});
|
});
|
||||||
node
|
node
|
||||||
};
|
};
|
||||||
@@ -7542,10 +8056,44 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod thumbnail_miss_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_misses_never_suppress_local_generation() {
|
||||||
|
let uid = parse_uid("volume~node").expect("uid");
|
||||||
|
let key = (uid, ThumbnailType::Thumbnail.as_i32());
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
|
||||||
|
misses.remember_remote(key.clone(), 42);
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
|
||||||
|
misses.remember_local(key.clone(), 42);
|
||||||
|
assert!(misses.local_contains(&key, 42));
|
||||||
|
misses.forget_local(&key);
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_miss_cache_is_bounded_on_gui_only_paths() {
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
for index in 0..=MAX_THUMBNAIL_MISSES {
|
||||||
|
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
|
||||||
|
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
|
||||||
|
}
|
||||||
|
assert_eq!(misses.local.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod search_root_tests {
|
mod search_root_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -340,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)
|
||||||
@@ -354,6 +362,7 @@ 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(),
|
||||||
@@ -374,6 +383,7 @@ pub fn mount(
|
|||||||
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())),
|
||||||
@@ -381,6 +391,8 @@ pub fn mount(
|
|||||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
upload_cancel: 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()),
|
||||||
@@ -389,13 +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)),
|
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()),
|
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,
|
||||||
@@ -406,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.
|
||||||
@@ -440,6 +462,10 @@ pub fn mount(
|
|||||||
// 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.
|
||||||
workers.extend(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.
|
||||||
@@ -543,6 +569,42 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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.
|
||||||
@@ -560,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();
|
||||||
@@ -655,6 +720,11 @@ pub fn mount(
|
|||||||
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.
|
/// 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
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
@@ -669,6 +739,9 @@ pub fn mount(
|
|||||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
/// than abandoning the user's bytes mid-flight.
|
/// than abandoning the user's bytes mid-flight.
|
||||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
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.shutdown.stop();
|
||||||
core.wake_drain();
|
core.wake_drain();
|
||||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
@@ -683,6 +756,12 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
|||||||
warn!("a background worker panicked before shutdown");
|
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");
|
debug!("background workers stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::QUOTA_TTL;
|
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};
|
||||||
|
|
||||||
@@ -47,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,
|
||||||
@@ -57,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,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.
|
||||||
@@ -609,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,
|
||||||
@@ -640,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.
|
||||||
@@ -654,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1120,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,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() {
|
||||||
@@ -132,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,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 {
|
||||||
@@ -273,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);
|
||||||
}
|
}
|
||||||
@@ -368,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1220,6 +1226,9 @@ impl Core {
|
|||||||
// Taken from the descriptor being streamed, so it names the content that
|
// 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.
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
let streamed = LocalSig::from(&meta);
|
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.
|
||||||
@@ -1240,7 +1249,7 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -1268,6 +1277,9 @@ impl Core {
|
|||||||
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 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(
|
||||||
@@ -1278,7 +1290,7 @@ 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}"))?;
|
||||||
warn_if_torn(rel, &path, streamed);
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
|||||||
@@ -115,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);
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,9 @@ fn poll_only_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);
|
||||||
}
|
}
|
||||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
|||||||
true
|
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) => {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -357,9 +357,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favourites are a separate call: the upload seal takes classification
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
// tags, but `Favorite` is not one of them — it goes through the photos
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
// volume's dedicated favourite endpoint.
|
// volume's dedicated favorite endpoint.
|
||||||
if !favorites.is_empty() {
|
if !favorites.is_empty() {
|
||||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -370,7 +370,7 @@ impl Core {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
warn!(error = %e, "marking imported favourites failed");
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
//! 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;
|
||||||
@@ -20,7 +24,7 @@ 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::{AtomicBool, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,14 +389,61 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
|||||||
&& cancel.load(Ordering::Relaxed)
|
&& cancel.load(Ordering::Relaxed)
|
||||||
{
|
{
|
||||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
// and the SDK would seal a truncated revision from it.
|
// and the SDK would seal a truncated revision from it. Not
|
||||||
return Err(std::io::Error::new(
|
// `Interrupted` either: every reader loop, the SDK's included,
|
||||||
std::io::ErrorKind::Interrupted,
|
// retries that kind, and this flag never clears, so the drain
|
||||||
"upload superseded by a newer write",
|
// 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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Syncing: two arrows chasing each other round a circle. Bundled because
|
||||||
|
Adwaita 50 no longer ships emblem-synchronizing-symbolic. The lower arrow
|
||||||
|
is the upper one turned half a circle, written out in plain coordinates:
|
||||||
|
GTK's symbolic icon renderer drops a transformed group. -->
|
||||||
|
<path fill="#222222" d="M 2,8 A 6,6 0 0 1 12.91,4.56 L 11.48,5.56 A 4.25,4.25 0 0 0 3.75,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 14.23,3.64 L 14.03,7.68 L 10.17,6.48 Z"/>
|
||||||
|
<path fill="#222222" d="M 14,8 A 6,6 0 0 1 3.09,11.44 L 4.52,10.44 A 4.25,4.25 0 0 0 12.25,8 Z"/>
|
||||||
|
<path fill="#222222" d="M 1.77,12.36 L 1.97,8.32 L 5.83,9.52 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 733 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Up to date: a check knocked out of a filled circle. -->
|
||||||
|
<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 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: 305 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Sharing stopped: the share glyph with its last node crossed out. Not
|
||||||
|
a no-entry sign, which reads as an error. -->
|
||||||
|
<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 3.63,8.65 L 8.23,11.25 L 8.97,9.95 L 4.37,7.35 Z"/>
|
||||||
|
<path fill="#222222" d="M 9.28,11.02 L 13.48,15.22 L 14.72,13.98 L 10.52,9.78 Z"/>
|
||||||
|
<path fill="#222222" d="M 13.48,9.78 L 9.28,13.98 L 10.52,15.22 L 14.72,11.02 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 706 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Upload-files action: an up arrow over a tray, the pair to
|
||||||
|
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
|
||||||
|
which reads as "send by mail". -->
|
||||||
|
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<path fill="#6d4aff" d="M4 12a4 4 0 0 1 4-4h11.2a4 4 0 0 1 2.9 1.2L25 12h15a4 4 0 0 1 4 4v22a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#8a6eff" d="M4 19a3 3 0 0 1 3-3h34a3 3 0 0 1 3 3v19a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4z"/>
|
||||||
|
<path fill="#ffffff" fill-opacity="0.18" d="M7 16h34a3 3 0 0 1 3 3v1a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v-1a3 3 0 0 1 3-3z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 442 B |
@@ -4,6 +4,34 @@
|
|||||||
icon-theme resource path at startup, so they resolve by name (and recolour
|
icon-theme resource path at startup, so they resolve by name (and recolour
|
||||||
with the theme) exactly like a stock symbolic icon. -->
|
with the theme) exactly like a stock symbolic icon. -->
|
||||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-filter-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-cached-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-synced-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-unshare-symbolic.svg</file>
|
||||||
|
<!-- The Proton theme's folder: a full-colour icon from the icon theme
|
||||||
|
can't be recoloured by CSS. -->
|
||||||
|
<file preprocess="xml-stripblanks">scalable/places/pdfs-proton-folder.svg</file>
|
||||||
|
<!-- The app icon, from the one file packaging installs, so the in-app brand
|
||||||
|
matches the launcher and resolves even when nothing is installed. -->
|
||||||
|
<file alias="scalable/apps/io.narl.proton-drive-linux.svg" preprocess="xml-stripblanks">../../../packaging/io.narl.proton-drive-linux.svg</file>
|
||||||
|
</gresource>
|
||||||
|
<!-- The app stylesheet, loaded once at startup. Colours come from the
|
||||||
|
libadwaita palette so the app follows the system accent, light/dark and
|
||||||
|
high contrast. The Proton theme is a separate, opt-in provider. -->
|
||||||
|
<gresource prefix="/de/nils/protondrivelinux">
|
||||||
|
<file>style.css</file>
|
||||||
|
<file>proton-theme.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* Proton theme — the Proton web apps' Snow (light) and Carbon (dark) palettes.
|
||||||
|
*
|
||||||
|
* Loaded by main.rs after libadwaita's own stylesheet, which it re-applies on
|
||||||
|
* top of the user's GTK theme so that theme's hard-coded colours can't show
|
||||||
|
* through. Only libadwaita's colour variables are set here; every widget
|
||||||
|
* derives its look from them. Values from ProtonMail/WebClients,
|
||||||
|
* packages/colors/themes/src/{snow,carbon}/standard-base.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-bg-color: #6d4aff;
|
||||||
|
--accent-fg-color: #ffffff;
|
||||||
|
--destructive-bg-color: #dc3251;
|
||||||
|
--destructive-fg-color: #ffffff;
|
||||||
|
--error-bg-color: #dc3251;
|
||||||
|
--error-fg-color: #ffffff;
|
||||||
|
--success-bg-color: #1ea885;
|
||||||
|
--success-fg-color: #ffffff;
|
||||||
|
--warning-bg-color: #ff9900;
|
||||||
|
--warning-fg-color: rgb(0 0 0 / 80%);
|
||||||
|
|
||||||
|
--window-bg-color: #ffffff;
|
||||||
|
--window-fg-color: #0c0c14;
|
||||||
|
--view-bg-color: #ffffff;
|
||||||
|
--view-fg-color: #0c0c14;
|
||||||
|
--headerbar-bg-color: #ffffff;
|
||||||
|
--headerbar-fg-color: #0c0c14;
|
||||||
|
--headerbar-backdrop-color: #ffffff;
|
||||||
|
--sidebar-bg-color: #f5f4f2;
|
||||||
|
--sidebar-fg-color: #0c0c14;
|
||||||
|
--sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-bg-color: #f8f8f6;
|
||||||
|
--secondary-sidebar-fg-color: #0c0c14;
|
||||||
|
--secondary-sidebar-backdrop-color: #f8f8f6;
|
||||||
|
--card-bg-color: #f5f4f2;
|
||||||
|
--card-fg-color: #0c0c14;
|
||||||
|
--dialog-bg-color: #ffffff;
|
||||||
|
--dialog-fg-color: #0c0c14;
|
||||||
|
--popover-bg-color: #ffffff;
|
||||||
|
--popover-fg-color: #0c0c14;
|
||||||
|
--thumbnail-bg-color: #f5f4f2;
|
||||||
|
--thumbnail-fg-color: #0c0c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--destructive-bg-color: #f5385a;
|
||||||
|
--error-bg-color: #f5385a;
|
||||||
|
|
||||||
|
--window-bg-color: #1e1c26;
|
||||||
|
--window-fg-color: #ffffff;
|
||||||
|
--view-bg-color: #1e1c26;
|
||||||
|
--view-fg-color: #ffffff;
|
||||||
|
--headerbar-bg-color: #1e1c26;
|
||||||
|
--headerbar-fg-color: #ffffff;
|
||||||
|
--headerbar-backdrop-color: #1e1c26;
|
||||||
|
--sidebar-bg-color: #16141c;
|
||||||
|
--sidebar-fg-color: #ffffff;
|
||||||
|
--sidebar-backdrop-color: #16141c;
|
||||||
|
--secondary-sidebar-bg-color: #1a1821;
|
||||||
|
--secondary-sidebar-fg-color: #ffffff;
|
||||||
|
--secondary-sidebar-backdrop-color: #1a1821;
|
||||||
|
--card-bg-color: #292733;
|
||||||
|
--card-fg-color: #ffffff;
|
||||||
|
--dialog-bg-color: #292733;
|
||||||
|
--dialog-fg-color: #ffffff;
|
||||||
|
--popover-bg-color: #292733;
|
||||||
|
--popover-fg-color: #ffffff;
|
||||||
|
--thumbnail-bg-color: #292733;
|
||||||
|
--thumbnail-fg-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/* Proton Drive for Linux — app stylesheet.
|
||||||
|
*
|
||||||
|
* Colours come from the libadwaita palette (var(--accent-bg-color),
|
||||||
|
* var(--success-color), ...) so the app follows the system accent, light/dark
|
||||||
|
* and high contrast. The one exception is the photo viewer, which always sits
|
||||||
|
* on a near-black backdrop and so uses fixed light-on-dark colours. The Proton
|
||||||
|
* theme is opt-in and lives in its own provider (see main.rs and
|
||||||
|
* proton-theme.css).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
.brand-title { font-size: 1.6rem; font-weight: 800; }
|
||||||
|
|
||||||
|
/* Sidebar footer */
|
||||||
|
.sidebar-footer { padding: 6px 12px 12px 12px; }
|
||||||
|
.sidebar-status { padding: 6px 8px; border-radius: 8px; }
|
||||||
|
.sidebar-status image.success { color: var(--success-color); }
|
||||||
|
.sidebar-status image.warning { color: var(--warning-color); }
|
||||||
|
.sidebar-status image.error { color: var(--error-color); }
|
||||||
|
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
|
||||||
|
.sidebar-account { padding: 4px 4px 4px 8px; }
|
||||||
|
/* A count of what waits on a page, at the end of its sidebar row */
|
||||||
|
.sidebar-badge {
|
||||||
|
min-width: 10px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: alpha(currentColor, 0.15);
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sync page status card: the state is the headline, the detail explains it */
|
||||||
|
.sync-card { padding: 18px 24px; }
|
||||||
|
.sync-card-icon { padding: 6px; }
|
||||||
|
.sync-card-icon.success { color: var(--success-color); }
|
||||||
|
.sync-card-icon.warning { color: var(--warning-color); }
|
||||||
|
.sync-card-icon.error { color: var(--error-color); }
|
||||||
|
|
||||||
|
/* File browser */
|
||||||
|
.file-grid { padding: 6px; }
|
||||||
|
.file-tile { padding: 8px; border-radius: 10px; }
|
||||||
|
.file-tile:hover { background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
|
.file-tile:selected, .file-tile:hover:selected { background: color-mix(in srgb, var(--accent-bg-color) 20%, transparent); }
|
||||||
|
.file-thumbnail { border-radius: 7px; }
|
||||||
|
.file-badge { -gtk-icon-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||||
|
.badge-pinned { color: var(--success-color); }
|
||||||
|
.badge-cached { color: var(--success-color); }
|
||||||
|
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
|
||||||
|
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
|
||||||
|
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||||
|
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
|
||||||
|
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
|
||||||
|
.dropzone-active { border-color: var(--accent-bg-color); background: color-mix(in srgb, var(--accent-bg-color) 10%, transparent); }
|
||||||
|
.files-drop-active { outline: 2px dashed var(--accent-bg-color); outline-offset: -4px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 6%, transparent); }
|
||||||
|
|
||||||
|
/* Photo viewer: always light-on-dark, whatever the system style */
|
||||||
|
.photo-viewer-window { background-color: #111014; }
|
||||||
|
.viewer-top-bar { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }
|
||||||
|
.viewer-title { font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }
|
||||||
|
.viewer-counter { font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }
|
||||||
|
.viewer-action-btn { color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }
|
||||||
|
.viewer-action-btn:hover { background-color: rgba(255, 255, 255, 0.28); color: white; }
|
||||||
|
.viewer-action-btn:checked { background-color: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
.viewer-close-btn { background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }
|
||||||
|
.viewer-close-btn:hover { background-color: var(--destructive-bg-color); color: var(--destructive-fg-color); }
|
||||||
|
.viewer-nav-btn { background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }
|
||||||
|
.viewer-nav-btn:hover { background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }
|
||||||
|
.viewer-nav-btn:disabled { opacity: 0; }
|
||||||
|
.photo-scrubber { background: linear-gradient(to left, alpha(var(--window-bg-color), 0.92) 40%, alpha(var(--window-bg-color), 0)); }
|
||||||
|
.photo-scrubber-knob { background-color: var(--accent-bg-color); border-radius: 2px; }
|
||||||
|
.photo-scrubber-bubble { font-weight: 600; background-color: var(--popover-bg-color); color: var(--popover-fg-color); border-radius: 999px; padding: 4px 12px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); }
|
||||||
|
.viewer-spinner { color: white; }
|
||||||
|
.viewer-status { color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }
|
||||||
|
.viewer-info-panel { background-color: var(--window-bg-color); border-left: 1px solid alpha(currentColor, 0.12); }
|
||||||
|
|
||||||
|
/* Photos grid */
|
||||||
|
.gallery-day { font-size: 0.82rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: alpha(currentColor, 0.65); padding: 2px 2px 6px 2px; }
|
||||||
|
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border: none; border-radius: 4px; background: none; box-shadow: none; }
|
||||||
|
.photo-tile:focus { outline: 2px solid var(--accent-color); outline-offset: -2px; }
|
||||||
|
.photo-thumb { transition: transform 220ms ease; }
|
||||||
|
.photo-tile:hover .photo-thumb { transform: scale(1.02); }
|
||||||
|
.photo-check { color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }
|
||||||
|
.photo-check-on { background: var(--accent-bg-color); color: var(--accent-fg-color); }
|
||||||
|
.photo-tile-selected { outline: 3px solid var(--accent-bg-color); outline-offset: -3px; }
|
||||||
|
.photo-placeholder { color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }
|
||||||
|
.photo-caption { font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }
|
||||||
|
.photo-tile:hover .photo-caption { opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }
|
||||||
|
.photo-video-badge { color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }
|
||||||
|
.photo-video-badge-small { padding: 3px; min-width: 0; min-height: 0; }
|
||||||
|
.photo-tile:hover .photo-video-badge { background: color-mix(in srgb, var(--accent-bg-color) 85%, transparent); }
|
||||||
|
.photo-group-badge { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }
|
||||||
|
.memory-card { padding: 0; border-radius: 12px; }
|
||||||
|
.memory-cover { border-radius: 12px; background: alpha(currentColor, 0.06); }
|
||||||
|
.memory-label { font-weight: 700; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0)); }
|
||||||
|
.album-card { padding: 0; border-radius: 16px; transition: background 180ms ease; }
|
||||||
|
.album-card:hover { background: alpha(currentColor, 0.07); }
|
||||||
|
.album-cover { border-radius: 16px; background: alpha(currentColor, 0.06); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); transition: box-shadow 180ms ease; }
|
||||||
|
.album-card:hover .album-cover { box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); }
|
||||||
|
.album-card:hover .photo-thumb { transform: scale(1.05); }
|
||||||
|
.view-switch button, .view-switch toggle { padding-left: 18px; padding-right: 18px; }
|
||||||
|
|
||||||
|
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||||
|
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||||
|
.role-pill.warning-pill { color: var(--warning-color); background-color: color-mix(in srgb, var(--warning-bg-color) 20%, transparent); }
|
||||||
|
|
||||||
|
/* Activity: the feed sits on the window background as one card per row, under
|
||||||
|
* day headings, like a boxed list with its rows set apart. */
|
||||||
|
listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; }
|
||||||
|
listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; }
|
||||||
|
listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; }
|
||||||
|
listview.activity-feed row.card { border-radius: 12px; }
|
||||||
|
|
||||||
|
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||||
|
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||||
|
/* The Places map: sea and land come in the map's own colours; markers (drawn
|
||||||
|
* in the area's own colour) in the accent. */
|
||||||
|
.places-map-frame { border-radius: 12px; }
|
||||||
|
.places-map { color: var(--accent-bg-color); }
|
||||||
|
/* Credit on the Places street map, in the corner as on every web map. */
|
||||||
|
.places-spot-photo { padding: 0; }
|
||||||
|
.places-spot-photo picture { border-radius: 6px; overflow: hidden; }
|
||||||
|
.places-map-credit { padding: 2px 8px; border-top-left-radius: 8px; color: var(--window-fg-color); background: color-mix(in srgb, var(--window-bg-color) 80%, transparent); }
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Dates and times as the app shows them, so every page words a moment the
|
||||||
|
//! same way.
|
||||||
|
//!
|
||||||
|
//! - [`relative`] for "when did this happen" in lists: "Just now", "5 min
|
||||||
|
//! ago", "Yesterday, 14:05", "Tuesday", then a date.
|
||||||
|
//! - [`short_date`] for date columns: "Sep 23", with the year only when it is
|
||||||
|
//! not this year.
|
||||||
|
//! - [`full`] for tooltips: the weekday, date, year and time.
|
||||||
|
//!
|
||||||
|
//! The formats are `strftime` patterns that go through gettext, so they follow
|
||||||
|
//! the language picked in Preferences, not only the system locale.
|
||||||
|
|
||||||
|
use gtk4::glib;
|
||||||
|
|
||||||
|
use crate::{gettext, gettext_f, ngettext_f};
|
||||||
|
|
||||||
|
/// Seconds in a day, for the "within the last week" checks.
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
/// A past moment in epoch seconds, relative to now. Empty when the time can't
|
||||||
|
/// be read.
|
||||||
|
pub(crate) fn relative(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => relative_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`relative`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn relative_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let ago = now.to_unix() - at.to_unix();
|
||||||
|
// A clock a little ahead of ours still reads as "just now".
|
||||||
|
if (-60..60).contains(&ago) {
|
||||||
|
return gettext("Just now");
|
||||||
|
}
|
||||||
|
if (60..3600).contains(&ago) {
|
||||||
|
let minutes = (ago / 60) as u64;
|
||||||
|
return ngettext_f("{n} min ago", "{n} min ago", minutes, &[]);
|
||||||
|
}
|
||||||
|
// Translators: strftime format for a time of day, such as "14:05".
|
||||||
|
let time = || format(at, &gettext("%H:%M"));
|
||||||
|
if at.ymd() == now.ymd() {
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||||
|
// Translators: {time} is a time of day, such as "14:05".
|
||||||
|
return gettext_f("Yesterday, {time}", &[("time", &time())]);
|
||||||
|
}
|
||||||
|
if (0..6 * DAY).contains(&ago) {
|
||||||
|
return format(at, "%A");
|
||||||
|
}
|
||||||
|
short_date_to(at, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A date in epoch seconds, without the time. Empty when the time can't be
|
||||||
|
/// read.
|
||||||
|
pub(crate) fn short_date(secs: i64) -> String {
|
||||||
|
match (
|
||||||
|
glib::DateTime::from_unix_local(secs),
|
||||||
|
glib::DateTime::now_local(),
|
||||||
|
) {
|
||||||
|
(Ok(at), Ok(now)) => short_date_to(&at, &now),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`short_date`] for a fixed `now`, so it can be tested.
|
||||||
|
pub(crate) fn short_date_to(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let pattern = if at.year() == now.year() {
|
||||||
|
// Translators: strftime format for a date in this year, such as "Sep 23".
|
||||||
|
gettext("%b %-d")
|
||||||
|
} else {
|
||||||
|
// Translators: strftime format for a date in another year, such as "Sep 23, 2025".
|
||||||
|
gettext("%b %-d, %Y")
|
||||||
|
};
|
||||||
|
format(at, &pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole moment in epoch seconds, for a tooltip. Empty when the time
|
||||||
|
/// can't be read.
|
||||||
|
pub(crate) fn full(secs: i64) -> String {
|
||||||
|
glib::DateTime::from_unix_local(secs)
|
||||||
|
.map(|at| {
|
||||||
|
// Translators: strftime format for a full date and time, such as
|
||||||
|
// "Wednesday, September 23, 2026, 14:05".
|
||||||
|
format(&at, &gettext("%A, %B %-d, %Y, %H:%M"))
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format(at: &glib::DateTime, pattern: &str) -> String {
|
||||||
|
at.format(pattern)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn at(y: i32, m: i32, d: i32, h: i32, min: i32) -> glib::DateTime {
|
||||||
|
glib::DateTime::from_local(y, m, d, h, min, 0.0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relative_times_get_coarser_with_age() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 59), &now), "1 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 14, 55), &now), "5 min ago");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 23, 9, 5), &now), "09:05");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 22, 14, 5), &now),
|
||||||
|
"Yesterday, 14:05"
|
||||||
|
);
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 20, 10, 0), &now), "Sunday");
|
||||||
|
assert_eq!(relative_to(&at(2026, 9, 1, 10, 0), &now), "Sep 1");
|
||||||
|
assert_eq!(relative_to(&at(2024, 3, 5, 10, 0), &now), "Mar 5, 2024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_moment_ago_or_slightly_ahead_is_just_now() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(relative_to(&now, &now), "Just now");
|
||||||
|
assert_eq!(
|
||||||
|
relative_to(&at(2026, 9, 23, 15, 0), &now.add_seconds(30.0).unwrap()),
|
||||||
|
"Just now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_dates_show_the_year_only_when_it_differs() {
|
||||||
|
let now = at(2026, 9, 23, 15, 0);
|
||||||
|
assert_eq!(short_date_to(&at(2026, 1, 2, 8, 0), &now), "Jan 2");
|
||||||
|
assert_eq!(short_date_to(&at(2025, 12, 31, 8, 0), &now), "Dec 31, 2025");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! The window's side of the daemon's event feed ([`Request::Subscribe`]).
|
||||||
|
//!
|
||||||
|
//! The window used to ask the daemon for its status every two seconds and let
|
||||||
|
//! pages re-read themselves on timers. Now one thread follows the feed and hands
|
||||||
|
//! each event to the main loop: a status or transfers snapshot is painted as it
|
||||||
|
//! comes, and a change reloads the page on screen if the page shows it. A
|
||||||
|
//! window nobody can see keeps its changes for later and catches up when it is
|
||||||
|
//! focused again.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use pdfs_core::control::{Event, Feed, follow};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
/// How long the conflict list waits after a change before it is read again.
|
||||||
|
/// Listing walks every node the daemon knows, and a sync pass can report
|
||||||
|
/// changes many times a second.
|
||||||
|
const CONFLICTS_DEBOUNCE: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// How long the import's state waits after its progress moved before it is
|
||||||
|
/// read again.
|
||||||
|
const TAKEOUT_DEBOUNCE: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// How old the invitations may be when the window is focused. Another person
|
||||||
|
/// inviting this account sends the daemon no event, so focus is when they are
|
||||||
|
/// looked for.
|
||||||
|
const INVITATIONS_ON_FOCUS: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// What the feed keeps between events.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FeedState {
|
||||||
|
/// What changed while the window was out of sight.
|
||||||
|
deferred: RefCell<BTreeSet<Topic>>,
|
||||||
|
/// Whether a conflict read is already waiting out [`CONFLICTS_DEBOUNCE`].
|
||||||
|
conflicts_pending: Rc<Cell<bool>>,
|
||||||
|
/// Whether an import read is already waiting out [`TAKEOUT_DEBOUNCE`].
|
||||||
|
takeout_pending: Rc<Cell<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow the daemon for as long as `window` is open.
|
||||||
|
pub(crate) fn follow_daemon(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
||||||
|
let (tx, rx) = async_channel::bounded(64);
|
||||||
|
let socket = ui.dirs.control_socket();
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-feed".into())
|
||||||
|
.spawn(move || follow(&socket, |feed| tx.send_blocking(feed).is_ok()));
|
||||||
|
if let Err(e) = spawned {
|
||||||
|
tracing::error!("cannot start the event feed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = Rc::new(FeedState::default());
|
||||||
|
let ui_feed = ui.clone();
|
||||||
|
let state_feed = state.clone();
|
||||||
|
let window_feed = window.downgrade();
|
||||||
|
let rx_feed = rx.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
while let Ok(feed) = rx_feed.recv().await {
|
||||||
|
let hidden = window_feed.upgrade().is_none_or(|w| window_hidden(&w));
|
||||||
|
on_feed(&ui_feed, &state_feed, feed, hidden);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch up as soon as the user comes back to the window.
|
||||||
|
let ui_focus = ui.clone();
|
||||||
|
window.connect_is_active_notify(move |window| {
|
||||||
|
if !window.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topics: Vec<Topic> = state.deferred.take().into_iter().collect();
|
||||||
|
if !topics.is_empty() {
|
||||||
|
follow_changes(&ui_focus, &state, &topics);
|
||||||
|
}
|
||||||
|
refresh_quota(&ui_focus);
|
||||||
|
refresh_invitations_badge(&ui_focus, INVITATIONS_ON_FOCUS);
|
||||||
|
});
|
||||||
|
// Closing the channel ends the loop above, and the thread with the next
|
||||||
|
// event it tries to hand over.
|
||||||
|
window.connect_close_request(move |_| {
|
||||||
|
rx.close();
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_feed(ui: &Rc<Ui>, state: &Rc<FeedState>, feed: Feed, hidden: bool) {
|
||||||
|
match feed {
|
||||||
|
// The daemon follows up with its whole state, so there is nothing to
|
||||||
|
// ask for here.
|
||||||
|
Feed::Connected | Feed::Event(Event::Heartbeat) => {}
|
||||||
|
Feed::Event(Event::Status(status)) => {
|
||||||
|
paint_status(ui, *status);
|
||||||
|
// Uploads are what move the quota, and they move the status too.
|
||||||
|
refresh_quota(ui);
|
||||||
|
}
|
||||||
|
// Painted even out of sight: the end of a batch is what sends the
|
||||||
|
// "Sync complete" notification.
|
||||||
|
Feed::Event(Event::Transfers { items, jobs }) => {
|
||||||
|
repaint_transfers(ui, &items, &jobs);
|
||||||
|
follow_takeout(ui, state, &jobs);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) if hidden => {
|
||||||
|
state.deferred.borrow_mut().extend(topics);
|
||||||
|
}
|
||||||
|
Feed::Event(Event::Changed { topics }) => follow_changes(ui, state, &topics),
|
||||||
|
// Ask once, which paints "not connected" or "not responding", and clear
|
||||||
|
// progress that can no longer move.
|
||||||
|
Feed::Disconnected => {
|
||||||
|
refresh_status(ui);
|
||||||
|
repaint_transfers(ui, &[], &[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reload what `topics` says changed: the sidebar badges wherever the user
|
||||||
|
/// is, and the page on screen if it shows one of them.
|
||||||
|
fn follow_changes(ui: &Rc<Ui>, state: &FeedState, topics: &[Topic]) {
|
||||||
|
let changed = |topic| topics.contains(&topic);
|
||||||
|
if changed(Topic::Conflicts) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.conflicts_pending, CONFLICTS_DEBOUNCE, move || {
|
||||||
|
refresh_conflicts(&ui);
|
||||||
|
if activity_visible(&ui) {
|
||||||
|
refresh_activity_conflicts(&ui, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if changed(Topic::Shares) {
|
||||||
|
refresh_invitations_badge(ui, Duration::ZERO);
|
||||||
|
}
|
||||||
|
match ui.stack.visible_child_name().as_deref() {
|
||||||
|
Some("browser") if changed(Topic::Files) => load_browser(ui),
|
||||||
|
// The timeline is left alone: reloading it would throw away the place
|
||||||
|
// the user scrolled to. F5 reloads it on request.
|
||||||
|
Some("gallery")
|
||||||
|
if changed(Topic::Photos)
|
||||||
|
&& ui.gallery.content.visible_child_name().as_deref() == Some("albums") =>
|
||||||
|
{
|
||||||
|
load_albums(ui)
|
||||||
|
}
|
||||||
|
Some("trash") if changed(Topic::Trash) => load_trash(ui),
|
||||||
|
Some("shared") if changed(Topic::Shares) => load_shared(ui),
|
||||||
|
Some("sharedbyme") if changed(Topic::Shares) || changed(Topic::Files) => {
|
||||||
|
load_shared_by_me(ui)
|
||||||
|
}
|
||||||
|
Some("devices") if changed(Topic::Devices) => load_devices(ui),
|
||||||
|
Some("locations") => follow_sync_view(ui, topics),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the import's state again while its progress moves, and once more after
|
||||||
|
/// it stops, which is how a finished import is noticed. Nothing is read while
|
||||||
|
/// no import runs and the page is elsewhere.
|
||||||
|
fn follow_takeout(ui: &Rc<Ui>, state: &FeedState, jobs: &[JobItem]) {
|
||||||
|
let importing = jobs.iter().any(|job| job.title == IMPORT_JOB);
|
||||||
|
if importing
|
||||||
|
|| ui.takeout.running.get()
|
||||||
|
|| ui.stack.visible_child_name().as_deref() == Some("takeout")
|
||||||
|
{
|
||||||
|
let ui = ui.clone();
|
||||||
|
debounce(&state.takeout_pending, TAKEOUT_DEBOUNCE, move || {
|
||||||
|
refresh_takeout(&ui)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` once `delay` has passed, unless a run is already waiting. The run
|
||||||
|
/// sees everything that happened up to it, so nothing is missed by skipping.
|
||||||
|
fn debounce(pending: &Rc<Cell<bool>>, delay: Duration, f: impl FnOnce() + 'static) {
|
||||||
|
if pending.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pending = pending.clone();
|
||||||
|
glib::timeout_add_local_once(delay, move || {
|
||||||
|
pending.set(false);
|
||||||
|
f();
|
||||||
|
});
|
||||||
|
}
|
||||||