mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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@v4
|
||||
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
|
||||
@@ -31,6 +31,7 @@ jobs:
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev \
|
||||
desktop-file-utils \
|
||||
gettext \
|
||||
rpm
|
||||
|
||||
- name: Set up Rust
|
||||
@@ -84,6 +85,7 @@ jobs:
|
||||
cp target/release/pdfs-tray dist/binaries/
|
||||
cp target/release/pdfs-app dist/binaries/
|
||||
cp target/release/pdfs-prompt dist/binaries/
|
||||
po/build.sh dist/binaries/locale
|
||||
|
||||
# Create tarball
|
||||
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/proton-drive.service deb/usr/lib/systemd/user/
|
||||
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
|
||||
INSTALLED_SIZE=$(du -sk deb | cut -f1)
|
||||
@@ -122,13 +125,14 @@ jobs:
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
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}
|
||||
Description: Proton Drive client for Linux
|
||||
Proton Drive client for Linux featuring:
|
||||
- FUSE files-on-demand mount
|
||||
- Command-line interface (CLI)
|
||||
- GTK4 system tray, desktop application, and search launcher
|
||||
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||
Description: Unofficial Proton Drive client for Linux
|
||||
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||
- FUSE virtual mount with block-level caching
|
||||
- Scriptable command-line interface (pdfs)
|
||||
- GTK4 desktop application, system tray, and search launcher
|
||||
EOF
|
||||
|
||||
# Build Debian package
|
||||
@@ -198,3 +202,13 @@ jobs:
|
||||
prerelease: false
|
||||
env:
|
||||
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).
|
||||
Generated
+609
-792
File diff suppressed because it is too large.
Load diff
+19
-9
@@ -3,15 +3,20 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.9.0"
|
||||
version = "2.3.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
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]
|
||||
proton-sdk = { version = "0.6.1" }
|
||||
proton-drive-rs = { version = "0.6.1" }
|
||||
proton-sdk = { version = "0.7.0" }
|
||||
proton-drive-rs = { version = "0.7.0" }
|
||||
|
||||
# Internal crates.
|
||||
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"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.23"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||
@@ -29,24 +35,28 @@ anyhow = "1"
|
||||
parking_lot = "0.12"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
directories = "6"
|
||||
ignore = "0.4"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11"
|
||||
# Google Takeout exports are zip sets; the importer reads their central
|
||||
# directories and streams entries out without extracting to disk first.
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||
# import has to speak SHA-1 whatever its age.
|
||||
sha1 = "0.10"
|
||||
sha1 = "0.11"
|
||||
# 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
|
||||
# would be dead weight in the daemon.
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
# 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"] }
|
||||
rpassword = "7"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
fuser = "0.17"
|
||||
fuser = "0.18"
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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 (public domain), so no map tiles are fetched; 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.
|
||||
- **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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -270,19 +378,21 @@ sudo apt-get install -y \
|
||||
libadwaita-1-dev \
|
||||
libwebkitgtk-6.0-dev \
|
||||
libsecret-1-dev \
|
||||
libdbus-1-dev
|
||||
libdbus-1-dev \
|
||||
libimage-exiftool-perl
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```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+)
|
||||
```bash
|
||||
sudo dnf install -y \
|
||||
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):
|
||||
@@ -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:
|
||||
```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
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### 2. Arch Linux
|
||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
||||
### 2. Arch Linux (AUR)
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
+34
@@ -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
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "CLI for the Proton Drive Linux client"
|
||||
|
||||
[[bin]]
|
||||
|
||||
+907
-35
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||
|
||||
[dependencies]
|
||||
@@ -21,7 +23,8 @@ sha2.workspace = true
|
||||
sha1.workspace = true
|
||||
zip.workspace = true
|
||||
rusqlite.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
dbus-secret-service-keyring-store.workspace = true
|
||||
parking_lot.workspace = true
|
||||
async-trait.workspace = true
|
||||
libc.workspace = true
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
//! 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_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
use proton_sdk::cache::EncryptedCacheRepository;
|
||||
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
}
|
||||
|
||||
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)?)
|
||||
}
|
||||
|
||||
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
|
||||
let entry = keyring_entry()?;
|
||||
match entry.get_password() {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
|
||||
}
|
||||
}
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -297,6 +298,10 @@ pub struct Pin {
|
||||
/// which is why it is defaulted.
|
||||
#[serde(default)]
|
||||
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
|
||||
@@ -415,6 +420,10 @@ pub struct ContentCache {
|
||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||
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>` +
|
||||
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||
/// sees them; blocks carry their own LRU budget.
|
||||
@@ -507,6 +516,19 @@ impl ContentCache {
|
||||
std::fs::create_dir_all(&content_dir)?;
|
||||
let thumb_dir = content_dir.join("thumbs");
|
||||
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");
|
||||
std::fs::create_dir_all(&block_dir)?;
|
||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||
@@ -530,6 +552,7 @@ impl ContentCache {
|
||||
let cache = Self {
|
||||
content_dir,
|
||||
thumb_dir,
|
||||
raw_thumb_dir,
|
||||
block_dir,
|
||||
scratch_dir,
|
||||
staging_dir,
|
||||
@@ -1666,6 +1689,12 @@ impl ContentCache {
|
||||
.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.
|
||||
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
||||
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
||||
@@ -1829,6 +1858,7 @@ impl ContentCache {
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
is_dir: row.is_dir,
|
||||
cached: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ pub struct AppConfig {
|
||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||
/// path here. Defaulted for configs predating the field.
|
||||
@@ -119,6 +128,78 @@ pub struct AppConfig {
|
||||
/// field.
|
||||
#[serde(default)]
|
||||
pub prompt: Option<PromptConfig>,
|
||||
/// Paint the desktop app in Proton purple instead of the system accent
|
||||
/// colour. `None` means "follow the system". Defaulted for configs
|
||||
/// predating the field.
|
||||
#[serde(default)]
|
||||
pub proton_accent: 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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -127,12 +208,18 @@ impl Default for AppConfig {
|
||||
app_version: APP_VERSION.to_string(),
|
||||
user_agent: USER_AGENT.to_string(),
|
||||
cache_budget: None,
|
||||
upload_limit: None,
|
||||
download_limit: None,
|
||||
mountpoint: None,
|
||||
ignore_patterns: None,
|
||||
device_uid: None,
|
||||
conflict_sweep: None,
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_accent: None,
|
||||
files_view: FilesView::default(),
|
||||
tray_hidden: false,
|
||||
language: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,17 +558,44 @@ mod tests {
|
||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||
user_agent: "test-agent/1.0".to_string(),
|
||||
cache_budget: Some(1234),
|
||||
upload_limit: Some(500_000),
|
||||
download_limit: None,
|
||||
mountpoint: Some("/tmp/x".to_string()),
|
||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||
device_uid: Some("dev-uid".to_string()),
|
||||
conflict_sweep: Some(SweepMode::Enforce),
|
||||
open_with: None,
|
||||
prompt: None,
|
||||
proton_accent: Some(true),
|
||||
files_view: FilesView {
|
||||
list: true,
|
||||
sort: FileSort::Modified,
|
||||
descending: true,
|
||||
folders_first: false,
|
||||
},
|
||||
tray_hidden: true,
|
||||
language: Some("de".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&config).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.user_agent, "test-agent/1.0");
|
||||
assert_eq!(decoded.proton_accent, Some(true));
|
||||
assert!(decoded.tray_hidden);
|
||||
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]
|
||||
@@ -554,6 +668,11 @@ mod tests {
|
||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||
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!(
|
||||
decoded.resolved_ignore_patterns().len(),
|
||||
DEFAULT_IGNORE_PATTERNS.len(),
|
||||
|
||||
+815
-12
File diff suppressed because it is too large.
Load diff
@@ -29,6 +29,17 @@ impl Db {
|
||||
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
|
||||
/// whose stored `kind` no longer parses (written by an older build) are
|
||||
/// skipped rather than failing the whole read.
|
||||
|
||||
@@ -178,16 +178,33 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
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
|
||||
// favouriting one is not supported yet anyway.
|
||||
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<_>>()?;
|
||||
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.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.read();
|
||||
@@ -222,10 +239,14 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
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
|
||||
// favouriting one is not supported yet anyway.
|
||||
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<_>>()?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 28;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 33;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -218,6 +218,88 @@ impl Db {
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -632,7 +714,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
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
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
@@ -846,3 +928,81 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
||||
const MIGRATION_V28: &str = "
|
||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||
";
|
||||
|
||||
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||
/// restore can reason about the shape of what it is putting back.
|
||||
///
|
||||
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||
/// one of those in isolation is what the parent link fixes — a child restored
|
||||
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||
/// without its separately-trashed contents comes back empty.
|
||||
///
|
||||
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||
/// did before.
|
||||
const MIGRATION_V29: &str = "
|
||||
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||
";
|
||||
|
||||
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||
/// who took it, so the timeline records what ties them together.
|
||||
///
|
||||
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||
/// its own uid.
|
||||
///
|
||||
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||
/// the gallery behaved before this column existed.
|
||||
const MIGRATION_V30: &str = "
|
||||
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||
";
|
||||
|
||||
/// Schema v31: which photos a refresh still has to read from the server.
|
||||
///
|
||||
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||
/// records that the work was already done, so a refresh only reads the photos it
|
||||
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||
///
|
||||
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||
/// ever learned about.
|
||||
const MIGRATION_V31: &str = "
|
||||
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||
";
|
||||
|
||||
/// V32: give videos written off as un-thumbnailable another chance.
|
||||
///
|
||||
/// Before local video thumbnails, the fallback fed a video's bytes to the image
|
||||
/// decoder, which could only fail, and that failure was recorded as the
|
||||
/// permanent "no thumbnail" verdict. Run once per table in `{table}`.
|
||||
const MIGRATION_V32: &str = "UPDATE {table} SET thumb_state = 0 WHERE kind = 1 AND thumb_state = 2";
|
||||
|
||||
/// Schema v33: where each photo was taken.
|
||||
///
|
||||
/// `latitude` and `longitude` come from the location other clients write into
|
||||
/// a photo's extended attributes; `place_id` is the GeoNames id of the nearest
|
||||
/// town (see `crate::places`), so the Places view is a plain `GROUP BY`.
|
||||
///
|
||||
/// Every photo is marked unresolved, so the next refresh reads the location of
|
||||
/// the whole library once. That is the backfill: the metadata is on the node
|
||||
/// the refresh reads anyway, and nothing has to be downloaded.
|
||||
const MIGRATION_V33: &str = "
|
||||
ALTER TABLE photos ADD COLUMN latitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN longitude REAL;
|
||||
ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||
UPDATE photos SET resolved_at = NULL;
|
||||
";
|
||||
@@ -56,10 +56,10 @@ pub use local::LocalFileHit;
|
||||
pub use maintenance::{DbStats, VacuumOutcome};
|
||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||
pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||
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 sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -171,6 +171,7 @@ impl Db {
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
|
||||
@@ -355,6 +355,22 @@ impl Db {
|
||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||
/// nothing below 3 chars.
|
||||
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();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -365,9 +381,10 @@ impl Db {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
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",
|
||||
)?;
|
||||
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 {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// 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
|
||||
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
|
||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||
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());
|
||||
@@ -392,6 +410,13 @@ impl Db {
|
||||
Some(path) => path,
|
||||
None => walk_path_of(&conn, &uid)?,
|
||||
};
|
||||
if let Some(scope) = scope
|
||||
&& !path
|
||||
.strip_prefix(scope)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
hits.push(SearchHit { node, path });
|
||||
}
|
||||
Ok(hits)
|
||||
|
||||
@@ -84,6 +84,25 @@ pub const LOCAL_VOLUME: &str = "local";
|
||||
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||
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
|
||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||
///
|
||||
@@ -661,6 +680,40 @@ impl Db {
|
||||
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`.
|
||||
///
|
||||
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
||||
@@ -719,6 +772,31 @@ impl Db {
|
||||
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 —
|
||||
/// the staged bytes are still the only copy of the user's write.
|
||||
///
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -34,67 +34,372 @@ pub struct StoredPhoto {
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
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,
|
||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||
/// `None` keeps whatever is already stored rather than clearing it.
|
||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||
/// A town photos were taken in: its GeoNames id (see [`crate::places`]), how
|
||||
/// many photos it holds, and the newest of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
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)>,
|
||||
/// 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 {
|
||||
/// Replace the timeline wholesale. Each item is
|
||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||
/// when the refresh could not resolve that photo's node, in which case the
|
||||
/// flag already stored is kept rather than silently cleared.
|
||||
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||
/// read from the server in this pass, so its fields are taken as they are —
|
||||
/// a `None` there means the server no longer reports that value. A row whose
|
||||
/// `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<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||
// daemon may not know them when it replaces the timeline — because the
|
||||
// refresh skipped an already-resolved photo, or because resolving it
|
||||
// failed. Keep any previously learned value so the tab split and the
|
||||
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||
// both are derived from the name. The photo relation is kept for the same
|
||||
// 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 FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
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)?),
|
||||
},
|
||||
))
|
||||
})?;
|
||||
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);
|
||||
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", [])?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||
place_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||
?16)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||
.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());
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
{
|
||||
stmt.execute(params![
|
||||
uid,
|
||||
capture_time,
|
||||
name,
|
||||
ratio,
|
||||
thumb_state,
|
||||
row.uid,
|
||||
row.capture_time,
|
||||
carried.name,
|
||||
carried.ratio,
|
||||
carried.thumb_state,
|
||||
seq as i64,
|
||||
media_type,
|
||||
carried.media_type,
|
||||
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),
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -102,11 +407,93 @@ impl Db {
|
||||
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,
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// 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(
|
||||
&self,
|
||||
offset: usize,
|
||||
@@ -114,27 +501,38 @@ impl Db {
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
not_in_album: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.read();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql = String::from(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||
);
|
||||
let mut sql = String::from(PHOTO_SELECT);
|
||||
let mut binds: Vec<i64> = 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 {
|
||||
binds.push(k.as_i64());
|
||||
conds.push(format!("kind = ?{}", binds.len()));
|
||||
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||
}
|
||||
if let Some((from, to)) = range {
|
||||
binds.push(from);
|
||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
||||
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||
}
|
||||
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() {
|
||||
sql.push_str(" WHERE ");
|
||||
@@ -145,22 +543,41 @@ impl Db {
|
||||
binds.push(offset as i64);
|
||||
let offset_pos = binds.len();
|
||||
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 rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(binds), |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,
|
||||
})
|
||||
})?
|
||||
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||
/// switches between these, and deleting a tile trashes all of them.
|
||||
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<_>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -175,9 +592,15 @@ impl Db {
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
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 {
|
||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||
None => ("", Vec::new()),
|
||||
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", 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!(
|
||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||
@@ -198,11 +621,144 @@ impl Db {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
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 (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||
for row in rows {
|
||||
@@ -225,21 +781,9 @@ impl Db {
|
||||
}
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
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 sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||
let mut photos = Vec::new();
|
||||
for row in rows {
|
||||
photos.push(row?);
|
||||
@@ -247,7 +791,7 @@ impl Db {
|
||||
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
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,6 +11,10 @@ pub struct StoredTrash {
|
||||
pub is_dir: bool,
|
||||
pub size: 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>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
@@ -20,7 +24,8 @@ impl Db {
|
||||
tx.execute("DELETE FROM trash", [])?;
|
||||
{
|
||||
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) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
for item in items {
|
||||
stmt.execute(params![
|
||||
@@ -28,7 +33,8 @@ impl Db {
|
||||
item.name,
|
||||
item.is_dir as i64,
|
||||
item.size,
|
||||
item.mtime
|
||||
item.mtime,
|
||||
item.parent_uid
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -41,7 +47,7 @@ impl Db {
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
@@ -51,6 +57,7 @@ impl Db {
|
||||
is_dir: r.get::<_, i64>(2)? != 0,
|
||||
size: r.get(3)?,
|
||||
mtime: r.get(4)?,
|
||||
parent_uid: r.get(5)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
@@ -60,5 +67,19 @@ impl Db {
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- device sync (devices.md) -----------------------------------------
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub enum Error {
|
||||
Proton(#[from] proton_sdk::ProtonError),
|
||||
|
||||
#[error("keyring: {0}")]
|
||||
Keyring(#[from] keyring::Error),
|
||||
Keyring(#[from] keyring_core::Error),
|
||||
|
||||
#[error("serialization: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod localindex;
|
||||
pub mod menu;
|
||||
pub mod mounts;
|
||||
pub mod opener;
|
||||
pub mod places;
|
||||
pub mod profile;
|
||||
pub mod sdkcache;
|
||||
pub mod search;
|
||||
@@ -21,6 +22,7 @@ pub mod service;
|
||||
pub mod shell;
|
||||
pub mod syncignore;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
|
||||
pub use access::{Access, access_for, perm_bits};
|
||||
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
|
||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||
/// 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> {
|
||||
vec![
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -268,4 +331,42 @@ mod tests {
|
||||
|
||||
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)]
|
||||
enum PromptFlag {
|
||||
/// `--prompt=TEXT`
|
||||
@@ -105,6 +124,13 @@ pub struct PromptConfig {
|
||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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
|
||||
/// [`DEFAULT_MENU_LIMIT`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -124,6 +150,8 @@ pub enum PromptMode {
|
||||
Gtk,
|
||||
/// The user's own launcher, driven over stdin/stdout.
|
||||
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
|
||||
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||
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.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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]
|
||||
fn a_configured_launcher_is_used_verbatim() {
|
||||
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.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
//! ```
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
||||
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) {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return;
|
||||
};
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
||||
/// successful login.
|
||||
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||
/// 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 {
|
||||
systemctl(&["enable", "--now"])
|
||||
let enabled = systemctl(&["enable"]);
|
||||
systemctl(&["restart"]) && enabled
|
||||
}
|
||||
|
||||
/// Stop the running mount without disabling it. Called by the tray's
|
||||
|
||||
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
||||
"Thumbs.db",
|
||||
];
|
||||
|
||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
||||
/// revision — the app renames the finished file to its real name, and only that
|
||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
||||
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
||||
/// writing it gives it this name, only while the write is in flight, and the
|
||||
/// finished article always arrives under a different one. On an on-demand mount
|
||||
/// these should never seal a remote revision — the app renames the finished file
|
||||
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
|
||||
/// and docs/BUGS.md B70.
|
||||
///
|
||||
/// `.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] = &[
|
||||
".crdownload", // Chromium/Brave/Edge
|
||||
".part", // Firefox, wget, aria2c
|
||||
".partial", // some downloaders / older IE
|
||||
".download", // Safari / macOS
|
||||
".tmp", // generic scratch
|
||||
".temp", // generic scratch
|
||||
".swp", // vim swap
|
||||
".swx", // vim swap (secondary)
|
||||
];
|
||||
@@ -319,8 +331,6 @@ mod tests {
|
||||
"movie.mkv.part",
|
||||
"big.iso.partial",
|
||||
"photo.jpg.download",
|
||||
"export.xml.tmp",
|
||||
"notes.TEMP",
|
||||
".notes.txt.swp",
|
||||
"doc.txt~",
|
||||
".~lock.report.odt#",
|
||||
@@ -340,6 +350,10 @@ mod tests {
|
||||
"archive.partly.zip", // ".partly" is not ".part"
|
||||
"template.dotx",
|
||||
"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");
|
||||
}
|
||||
|
||||
+319
-30
@@ -393,22 +393,27 @@ fn assemble(entries: Vec<RawEntry>, sidecars: &HashMap<String, Vec<u8>>) -> Take
|
||||
.unwrap_or(&file_name)
|
||||
.to_string();
|
||||
|
||||
// Sidecar first, then the date stamped into the name.
|
||||
let capture_time = sidecar
|
||||
.photo_taken_time
|
||||
.as_ref()
|
||||
.and_then(SidecarTime::epoch_seconds)
|
||||
.or_else(|| {
|
||||
sidecar
|
||||
.creation_time
|
||||
.as_ref()
|
||||
.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: sidecar
|
||||
.photo_taken_time
|
||||
.as_ref()
|
||||
.and_then(SidecarTime::epoch_seconds)
|
||||
.or_else(|| {
|
||||
sidecar
|
||||
.creation_time
|
||||
.as_ref()
|
||||
.and_then(SidecarTime::epoch_seconds)
|
||||
}),
|
||||
capture_time,
|
||||
favorite: sidecar.favorited,
|
||||
bucket,
|
||||
});
|
||||
@@ -507,34 +512,218 @@ fn unswap_duplicate_marker(stem: &str) -> Option<String> {
|
||||
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.
|
||||
///
|
||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, then a
|
||||
/// prefix match for the case where truncation cut into the photo name itself.
|
||||
/// Tried in order of confidence: the exact name, the `(N)`-swapped name, an
|
||||
/// unknown trailing suffix, then a prefix match for the case where truncation
|
||||
/// cut into the photo name itself. The best-ranked candidate in the folder wins.
|
||||
fn find_sidecar<'a>(
|
||||
file_name: &str,
|
||||
in_folder: &BTreeMap<String, &'a Vec<u8>>,
|
||||
) -> Option<&'a Vec<u8>> {
|
||||
let mut prefix_match: Option<&'a Vec<u8>> = None;
|
||||
let mut prefix_len = 0usize;
|
||||
|
||||
let mut best: Option<((u8, usize), &'a Vec<u8>)> = None;
|
||||
for (sidecar_name, body) in in_folder {
|
||||
let stem = strip_sidecar_suffix(sidecar_name);
|
||||
if stem == file_name {
|
||||
return Some(body);
|
||||
}
|
||||
if unswap_duplicate_marker(&stem).as_deref() == Some(file_name) {
|
||||
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);
|
||||
let Some(rank) = sidecar_rank(strip_json_suffix(sidecar_name), file_name) else {
|
||||
continue;
|
||||
};
|
||||
if best.as_ref().is_none_or(|(current, _)| rank < *current) {
|
||||
best = Some((rank, 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)]
|
||||
@@ -623,7 +812,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidecar_supplies_capture_time_and_favourite() {
|
||||
fn sidecar_supplies_capture_time_and_favorite() {
|
||||
let mut sidecars = HashMap::new();
|
||||
sidecar(
|
||||
&mut sidecars,
|
||||
@@ -663,6 +852,106 @@ mod tests {
|
||||
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]
|
||||
fn truncated_supplemental_suffixes_still_match() {
|
||||
// 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
|
||||
authors.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
description = "FUSE on-demand filesystem for Proton Drive"
|
||||
|
||||
[dependencies]
|
||||
@@ -18,6 +20,7 @@ tracing.workspace = true
|
||||
fuser.workspace = true
|
||||
libc.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
image.workspace = true
|
||||
notify = "8"
|
||||
parking_lot.workspace = true
|
||||
@@ -175,6 +175,40 @@ impl Core {
|
||||
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.
|
||||
pub(crate) async fn refresh_album(&self, album: &NodeUid) -> CoreResult<()> {
|
||||
let photos = self.photos();
|
||||
@@ -221,6 +255,84 @@ impl Core {
|
||||
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
|
||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||
/// to the timeline can just as easily be a change to an album.
|
||||
@@ -230,6 +342,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.
|
||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||
AlbumInfo {
|
||||
|
||||
@@ -425,6 +425,165 @@ pub(super) async fn run_event_sync(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
||||
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
||||
/// then sleeps; runs on its own thread for the life of the daemon.
|
||||
@@ -487,7 +646,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);
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -541,6 +705,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,4 +953,96 @@ mod tests {
|
||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||
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");
|
||||
}
|
||||
}
|
||||
+539
-42
@@ -11,24 +11,44 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write as _};
|
||||
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::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
TransferDirection,
|
||||
TransferDirection, TrashFailure,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::transfers::CountingReader;
|
||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
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
|
||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||
/// [`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 {
|
||||
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);
|
||||
|
||||
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>> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
@@ -129,22 +185,107 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
||||
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
|
||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||
/// the mount root.
|
||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
if p.is_absolute() {
|
||||
let relative = if p.is_absolute() {
|
||||
p.strip_prefix(mountpoint)
|
||||
.map(Path::to_path_buf)
|
||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||
} else {
|
||||
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,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set request timeout");
|
||||
@@ -169,6 +310,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 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 serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||
Ok(CtlRequest::Status) => {
|
||||
let pins = core.cache.list_pins();
|
||||
@@ -180,6 +324,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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(),
|
||||
@@ -216,7 +362,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
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((core, rel)) => match core.list_dir(&rel) {
|
||||
@@ -225,36 +371,84 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::Refresh { scope }) => {
|
||||
let result = match &scope {
|
||||
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||
RefreshScope::Trash => {
|
||||
core.invalidate_trash();
|
||||
Ok(())
|
||||
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(),
|
||||
}
|
||||
RefreshScope::Photos => {
|
||||
core.invalidate_photos();
|
||||
// Whatever changed the timeline can just as easily have
|
||||
// changed an album; one scope covers the whole photos view.
|
||||
core.invalidate_albums();
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "refreshed".to_string(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Some(_refreshing) => {
|
||||
let result = match &scope {
|
||||
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||
RefreshScope::Trash => {
|
||||
core.invalidate_trash();
|
||||
Ok(())
|
||||
}
|
||||
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();
|
||||
// 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
|
||||
// changed an album; one scope covers the whole photos view.
|
||||
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(())
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: match scope {
|
||||
RefreshScope::Photos { .. } => "refreshing".to_string(),
|
||||
_ => "refreshed".to_string(),
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(CtlRequest::PhotosTimeline {
|
||||
offset,
|
||||
limit,
|
||||
kind,
|
||||
range,
|
||||
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 {
|
||||
available: true,
|
||||
items,
|
||||
@@ -267,13 +461,52 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||
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::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) {
|
||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: if favorite {
|
||||
"added to favourites".to_string()
|
||||
"added to favorites".to_string()
|
||||
} else {
|
||||
"removed from favourites".to_string()
|
||||
"removed from favorites".to_string()
|
||||
},
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -305,6 +538,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
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(months) => CtlResponse::PhotoMonths {
|
||||
months: months
|
||||
@@ -320,6 +585,50 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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) {
|
||||
Some(u) => match core.open_photo(&u) {
|
||||
Ok(p) => CtlResponse::FilePath {
|
||||
@@ -374,6 +683,36 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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 {
|
||||
name,
|
||||
media_type,
|
||||
@@ -441,7 +780,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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 },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
@@ -590,6 +933,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Diagnostics) => CtlResponse::Diagnostics {
|
||||
diagnostics: core.diagnostics(),
|
||||
},
|
||||
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
||||
Ok(entries) => CtlResponse::Entries { entries },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -619,9 +965,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||
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 {
|
||||
message: format!("emptied trash ({n} item(s))"),
|
||||
message: format!("emptied trash ({items})"),
|
||||
}
|
||||
}
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -701,6 +1052,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(items) => CtlResponse::Devices { items },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
@@ -784,6 +1158,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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 }) => {
|
||||
core.sync_now(id);
|
||||
CtlResponse::Ok {
|
||||
@@ -793,22 +1178,77 @@ 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 },
|
||||
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,
|
||||
// which outlasts the socket timeout. Ack, then work.
|
||||
let core = core.clone();
|
||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
||||
Ok(message) => {
|
||||
core.touch_profile();
|
||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "restore failed");
|
||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||
std::thread::spawn(move || {
|
||||
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||
Ok(message) => {
|
||||
core.touch_profile();
|
||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(error = %e, "restore failed");
|
||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
CtlResponse::Ok {
|
||||
@@ -979,6 +1419,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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(rel) => match core.list_revisions(&rel) {
|
||||
Ok(items) => CtlResponse::Revisions { items },
|
||||
@@ -1221,4 +1668,54 @@ mod request_limit_tests {
|
||||
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));
|
||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||
location.progress = progress.get(sync_folder_id).cloned();
|
||||
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||
}
|
||||
}
|
||||
Ok(locations)
|
||||
@@ -226,6 +227,39 @@ impl Core {
|
||||
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) -------------------------
|
||||
|
||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||
@@ -448,7 +482,8 @@ impl Core {
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
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())
|
||||
}
|
||||
@@ -561,6 +596,7 @@ impl Core {
|
||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||
}
|
||||
drop(_guard);
|
||||
self.clear_sync_folder_paused(id);
|
||||
// Stop watching the folder we just dropped.
|
||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||
if delete_remote
|
||||
@@ -625,6 +661,13 @@ impl Core {
|
||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||
.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 {
|
||||
let mode_str = target_mode.as_str();
|
||||
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);
|
||||
}
|
||||
}
|
||||
+248
-47
@@ -29,12 +29,16 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use futures::StreamExt;
|
||||
use pdfs_core::batch;
|
||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
||||
use proton_drive_rs::Node;
|
||||
use pdfs_core::db::{
|
||||
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::{Node, NodeMoveItem};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::state::Intervals;
|
||||
@@ -281,8 +285,9 @@ impl Core {
|
||||
}
|
||||
};
|
||||
let online = self.online.load(Ordering::Relaxed);
|
||||
let paused = self.sync_paused();
|
||||
let op = match due {
|
||||
Some(op) if online => op,
|
||||
Some(op) if online && !paused => op,
|
||||
idle => {
|
||||
// Claimed but offline: hand it straight back, or it stays
|
||||
// invisible to every worker until this one happens to be
|
||||
@@ -297,6 +302,18 @@ impl Core {
|
||||
// Cheap and a no-op when empty.
|
||||
self.cache.flush_touches();
|
||||
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
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
@@ -642,61 +659,46 @@ impl Core {
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// The name half goes first, so that a collision on the move half below is
|
||||
// about the name the node will actually land under rather than the one it
|
||||
// is about to lose.
|
||||
let mut landed = node.name.clone();
|
||||
if landed != name {
|
||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
||||
Ok(()) => landed = name.clone(),
|
||||
// Someone took the name while we were offline. Renaming to a
|
||||
let landed = if node.parent_uid.as_ref() == Some(&parent) {
|
||||
self.drain_rename_in_place(&uid, &node.name, &name)?
|
||||
} else {
|
||||
// Move and rename land as one request (`move-multiple` takes a
|
||||
// target name), so there is no half-applied state between them and
|
||||
// no window for the requirements to go stale between two calls —
|
||||
// the race B46 queued this op to get away from.
|
||||
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
|
||||
// clobbers their file nor drops ours, and it is visible.
|
||||
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))?;
|
||||
landed = alt.clone();
|
||||
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||
self.adopt_drained_name(&uid, &alt);
|
||||
self.log_activity(
|
||||
ActivityKind::Rename,
|
||||
&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}"),
|
||||
false,
|
||||
);
|
||||
alt
|
||||
}
|
||||
// The destination folder is gone. Leaving the node in its current
|
||||
// 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
|
||||
// rename half above still applied. Retrying could only fail again
|
||||
// — the folder is not coming back — and would wedge the queue.
|
||||
// it, but it exists and it is where it has always been. The name
|
||||
// the user chose still applies there. Retrying the move could
|
||||
// only fail again — the folder is not coming back — and would
|
||||
// wedge the queue.
|
||||
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(
|
||||
ActivityKind::Rename,
|
||||
&landed,
|
||||
@@ -704,16 +706,73 @@ impl Core {
|
||||
.to_string(),
|
||||
false,
|
||||
);
|
||||
landed
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
self.db.delete_op(op.id)?;
|
||||
self.note_self_change(&uid);
|
||||
info!(%uid, name = %landed, "pending rename landed");
|
||||
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.
|
||||
///
|
||||
/// A node that is already gone is a success, not a failure: the outcome the
|
||||
@@ -886,6 +945,7 @@ impl Core {
|
||||
if !meta.complete {
|
||||
return Err("queued create holds an incomplete blob".into());
|
||||
}
|
||||
let thumbnails = self.upload_thumbnails(Path::new(blob), name);
|
||||
let guard = self
|
||||
.transfers
|
||||
.begin(name, op.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -896,7 +956,7 @@ impl Core {
|
||||
media_type_for(name),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1086,6 +1146,7 @@ impl Core {
|
||||
}
|
||||
|
||||
let alt = conflict_name(&name, now_secs());
|
||||
let thumbnails = self.upload_thumbnails(blob, &alt);
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&alt, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -1096,7 +1157,7 @@ impl Core {
|
||||
media_type_for(&alt),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1157,6 +1218,101 @@ impl Core {
|
||||
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`.
|
||||
///
|
||||
/// A supersede can replace the entry while the op that owned it is in
|
||||
@@ -1284,6 +1440,7 @@ impl Core {
|
||||
}
|
||||
|
||||
let name = self.node_name(&uid);
|
||||
let thumbnails = self.upload_thumbnails(&blob, &name);
|
||||
let guard =
|
||||
self.transfers
|
||||
.begin(&name, meta.uid.clone(), TransferDirection::Upload, meta.len);
|
||||
@@ -1297,7 +1454,7 @@ impl Core {
|
||||
&uid,
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
));
|
||||
let took = started.elapsed();
|
||||
@@ -1529,6 +1686,24 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1554,6 +1729,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]
|
||||
fn drain_authorities_match_each_operation_kind() {
|
||||
for kind in [OP_REVISION, OP_TRASH] {
|
||||
@@ -1587,6 +1784,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1879,6 +2078,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ impl Filesystem for ProtonFs {
|
||||
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
||||
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
||||
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 bsize = u64::from(REPORTED_BLKSIZE);
|
||||
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
||||
@@ -89,7 +89,7 @@ impl Filesystem for ProtonFs {
|
||||
return;
|
||||
}
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -183,7 +183,7 @@ impl Filesystem for ProtonFs {
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Meta, move || fs.serve_opendir(ino, reply));
|
||||
.run(Lane::Meta, "opendir", move || fs.serve_opendir(ino, reply));
|
||||
}
|
||||
|
||||
fn readdir(
|
||||
@@ -210,9 +210,9 @@ impl Filesystem for ProtonFs {
|
||||
return;
|
||||
}
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
||||
self.core.workers.run(Lane::Meta, "readdir", move || {
|
||||
fs.serve_readdir(ino, offset, reply)
|
||||
});
|
||||
}
|
||||
|
||||
fn releasedir(
|
||||
@@ -237,9 +237,9 @@ impl Filesystem for ProtonFs {
|
||||
return;
|
||||
}
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
|
||||
self.core.workers.run(Lane::Transfer, "open", move || {
|
||||
fs.serve_open(ino, flags, reply)
|
||||
});
|
||||
}
|
||||
|
||||
fn read(
|
||||
@@ -329,7 +329,7 @@ impl Filesystem for ProtonFs {
|
||||
// even read off the FUSE device. It only reads `state`, so moving it
|
||||
// races with nothing. FUSE does not require replies in request order.
|
||||
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) {
|
||||
Ok(bytes) => reply.data(&bytes),
|
||||
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
|
||||
// long as the burst lasted. See [`Workers`].
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Meta, move || fs.serve_create(parent, &name, reply));
|
||||
self.core.workers.run(Lane::Meta, "create", move || {
|
||||
fs.serve_create(parent, &name, reply)
|
||||
});
|
||||
}
|
||||
|
||||
fn write(
|
||||
@@ -506,7 +506,7 @@ impl Filesystem for ProtonFs {
|
||||
// the scratch file).
|
||||
let ino = ino.0;
|
||||
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) {
|
||||
reply.error(e);
|
||||
return;
|
||||
@@ -620,7 +620,7 @@ impl Filesystem for ProtonFs {
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
|
||||
.run(Lane::Transfer, "fsync", move || fs.serve_fsync(fh, reply));
|
||||
}
|
||||
|
||||
fn release(
|
||||
@@ -680,7 +680,7 @@ impl Filesystem for ProtonFs {
|
||||
};
|
||||
// `trash_child` is a remote call; see `serve_create`.
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -701,9 +701,9 @@ impl Filesystem for ProtonFs {
|
||||
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
|
||||
// it: two remote calls. See `serve_create`.
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Meta, move || fs.serve_rmdir(parent, &name_str, reply));
|
||||
self.core.workers.run(Lane::Meta, "rmdir", move || {
|
||||
fs.serve_rmdir(parent, &name_str, reply)
|
||||
});
|
||||
}
|
||||
|
||||
fn mkdir(
|
||||
@@ -731,9 +731,9 @@ impl Filesystem for ProtonFs {
|
||||
// remote round trip, and inline it blocks every other request on the
|
||||
// mount until it answers.
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Meta, move || fs.serve_mkdir(parent, &name, reply));
|
||||
self.core.workers.run(Lane::Meta, "mkdir", move || {
|
||||
fs.serve_mkdir(parent, &name, reply)
|
||||
});
|
||||
}
|
||||
|
||||
fn rename(
|
||||
@@ -774,7 +774,7 @@ impl Filesystem for ProtonFs {
|
||||
// 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.
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -799,7 +799,7 @@ impl Filesystem for ProtonFs {
|
||||
// network per call (B5).
|
||||
let fs = self.clone();
|
||||
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)
|
||||
});
|
||||
}
|
||||
@@ -1099,7 +1099,7 @@ impl ProtonFs {
|
||||
if !off_loop {
|
||||
let fs = self.clone();
|
||||
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)
|
||||
});
|
||||
return;
|
||||
|
||||
+528
-60
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
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 fuser::ReplyXattr;
|
||||
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||
TransferDirection,
|
||||
ThumbnailBuildStatus, TransferDirection,
|
||||
};
|
||||
use pdfs_core::db::{
|
||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||
@@ -80,28 +80,35 @@ use proton_drive_rs::{
|
||||
|
||||
mod albums;
|
||||
mod background;
|
||||
mod conflicts;
|
||||
mod control;
|
||||
use control::run_control_socket;
|
||||
mod devices;
|
||||
mod diagnostics;
|
||||
mod drain;
|
||||
mod filesystem;
|
||||
pub use filesystem::ProtonFs;
|
||||
mod mount;
|
||||
mod pause;
|
||||
mod photos;
|
||||
mod profile;
|
||||
mod queue;
|
||||
mod reads;
|
||||
mod redate;
|
||||
mod revisions;
|
||||
mod sharing;
|
||||
mod shutdown;
|
||||
mod state;
|
||||
mod supervisor;
|
||||
mod sweep;
|
||||
mod sync;
|
||||
mod systemd;
|
||||
mod takeout;
|
||||
mod transfers;
|
||||
mod upload;
|
||||
mod r#virtual;
|
||||
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 use mount::{MountOptions, MountOutcome, mount};
|
||||
use mount::{
|
||||
@@ -193,19 +200,42 @@ const UPLOAD_TIME_MEMORY: usize = 512;
|
||||
/// for the SDK's own per-request concurrency.
|
||||
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_MAX: Duration = Duration::from_secs(300);
|
||||
/// 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
|
||||
/// in the DB and every page is sliced from there; a stale one is still served
|
||||
/// 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`]
|
||||
/// 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
|
||||
/// 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.
|
||||
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
|
||||
/// one listing a user changes and then immediately looks at — though our own
|
||||
/// mutations also invalidate it outright, so this only covers other clients.
|
||||
@@ -374,6 +404,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
|
||||
/// root itself resolves to the empty path; an empty root is the mount itself
|
||||
/// and covers everything.
|
||||
@@ -442,6 +511,10 @@ struct Core {
|
||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||
/// infer it from a wall of EIO.
|
||||
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
|
||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||
/// which it is rebuilt on mount.
|
||||
@@ -477,6 +550,10 @@ struct Core {
|
||||
/// already running, so a burst of page requests against a stale listing kicks
|
||||
/// off one refresh rather than one per request.
|
||||
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.
|
||||
albums_refreshing: Arc<AtomicBool>,
|
||||
trash_refreshing: Arc<AtomicBool>,
|
||||
@@ -485,9 +562,10 @@ struct Core {
|
||||
/// instead of polling or blocking for the whole run. See
|
||||
/// [`Core::await_trash_refresh`].
|
||||
trash_progress: Arc<tokio::sync::Notify>,
|
||||
/// Conflict copies the sweep has already flagged as needing attention this
|
||||
/// run, so a divergent `(sync-conflict …)` file is logged once rather than
|
||||
/// on every sweep pass. See [`Core::run_conflict_sweep_loop`].
|
||||
/// Conflict copies the sweep has already checked against the activity log
|
||||
/// this run, so each pass does not ask the database again. The log itself
|
||||
/// 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>>>,
|
||||
/// Whether the conflict sweep may actually trash the duplicates it finds, or
|
||||
/// only report them. Resolved once at mount from config + environment
|
||||
@@ -511,8 +589,22 @@ struct Core {
|
||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||
/// twice.
|
||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
|
||||
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
|
||||
/// Global permit pool for locally generated thumbnails. The old per-request
|
||||
/// 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
|
||||
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
||||
@@ -520,7 +612,7 @@ struct Core {
|
||||
/// 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
|
||||
/// 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)`.
|
||||
///
|
||||
/// `df` and the free-space preflight of every file manager and installer ask
|
||||
@@ -1330,6 +1422,15 @@ fn release_must_retain_queued_trash(db: &Db, uid: &NodeUid) -> pdfs_core::Result
|
||||
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 {
|
||||
match release_must_retain_queued_trash(db, uid) {
|
||||
Ok(retain) => !retain,
|
||||
@@ -1654,6 +1755,9 @@ impl Core {
|
||||
};
|
||||
let mut queued = 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 {
|
||||
if claimed.contains(&blob.to_string_lossy().into_owned()) {
|
||||
continue;
|
||||
@@ -1661,9 +1765,17 @@ impl Core {
|
||||
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))
|
||||
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;
|
||||
warn!(blob = %blob.display(),
|
||||
"staged bytes belong to no queued upload and name no node; kept for recovery");
|
||||
example.get_or_insert_with(|| blob.clone());
|
||||
continue;
|
||||
};
|
||||
if self.pending.lock().contains_key(&uid) {
|
||||
@@ -1709,8 +1821,21 @@ impl Core {
|
||||
}
|
||||
}
|
||||
}
|
||||
if queued > 0 || stranded > 0 {
|
||||
info!(queued, stranded, "reconciled orphaned staged writes");
|
||||
if stranded > 0 {
|
||||
// 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 +3705,7 @@ impl Core {
|
||||
// "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).
|
||||
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);
|
||||
}
|
||||
let bytes = self
|
||||
@@ -3597,15 +3722,7 @@ impl Core {
|
||||
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||
}
|
||||
None => {
|
||||
let mut misses = self.no_thumbnail.lock();
|
||||
// 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);
|
||||
self.thumbnail_misses.lock().remember_remote(key, mtime);
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
@@ -3692,6 +3809,9 @@ impl Core {
|
||||
// My own tree: a role only means something under a share, and
|
||||
// this listing never crosses into one.
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -3742,10 +3862,25 @@ impl Core {
|
||||
/// 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,
|
||||
/// 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
|
||||
.db
|
||||
.search(query, limit)
|
||||
.search_in(query, limit, scope)
|
||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||
let roots = self.search_roots();
|
||||
let pinned = self.pinned_set();
|
||||
@@ -3757,6 +3892,12 @@ impl Core {
|
||||
size: node_size(&h.node),
|
||||
modified: h.node.modification_time,
|
||||
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(),
|
||||
mounted_path: roots.resolve(&h.path),
|
||||
path: h.path,
|
||||
@@ -3824,6 +3965,12 @@ impl Core {
|
||||
size: node_size(&hit.node),
|
||||
modified: hit.node.modification_time,
|
||||
pinned: is_pinned,
|
||||
cached: !hit.node.is_folder()
|
||||
&& self.cache.is_cached(
|
||||
&hit.node.uid,
|
||||
hit.node.modification_time,
|
||||
node_size(&hit.node),
|
||||
),
|
||||
uid,
|
||||
mounted_path: roots.resolve(&hit.path),
|
||||
path: hit.path,
|
||||
@@ -4273,6 +4420,9 @@ impl Core {
|
||||
uid: item.uid,
|
||||
path: String::new(),
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -4314,6 +4464,9 @@ impl Core {
|
||||
is_dir: node.is_folder(),
|
||||
size: node_size(&node) as i64,
|
||||
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()),
|
||||
}));
|
||||
// Cumulative, so the table is always a prefix of the real trash
|
||||
// rather than a mix of this refresh and the last one.
|
||||
@@ -4419,15 +4572,48 @@ impl Core {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Restore trashed nodes to the folders they were trashed from. The parents
|
||||
/// are read *before* the restore — a restored node reappears in a listing the
|
||||
/// daemon may already have cached, so each destination folder is invalidated
|
||||
/// and re-enumerated on next access.
|
||||
/// Restore trashed nodes to the folders they were trashed from, together
|
||||
/// with the rest of the trashed tree they belong to.
|
||||
///
|
||||
/// 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> {
|
||||
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
|
||||
.rt
|
||||
.block_on(self.client.enumerate_nodes(&parsed))
|
||||
.block_on(self.client.enumerate_nodes(&all))
|
||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
||||
.into_iter()
|
||||
.filter_map(|n| n.parent_uid)
|
||||
@@ -4438,27 +4624,29 @@ impl Core {
|
||||
// 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
|
||||
// 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;
|
||||
self.rt
|
||||
.block_on(async {
|
||||
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(&parsed));
|
||||
while let Some(item) = outcomes.next().await {
|
||||
let (uid, outcome) = item?;
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
self.hidden.lock().remove(&uid);
|
||||
restored.push(uid);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e, "restore failed for a node");
|
||||
first_error.get_or_insert(e);
|
||||
for wave in &waves {
|
||||
self.rt
|
||||
.block_on(async {
|
||||
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(wave));
|
||||
while let Some(item) = outcomes.next().await {
|
||||
let (uid, outcome) = item?;
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
self.hidden.lock().remove(&uid);
|
||||
restored.push(uid);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(%uid, error = %e, "restore failed for a node");
|
||||
first_error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), ProtonError>(())
|
||||
})
|
||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||
Ok::<(), ProtonError>(())
|
||||
})
|
||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||
}
|
||||
if restored.is_empty()
|
||||
&& let Some(error) = first_error
|
||||
{
|
||||
@@ -4811,7 +4999,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
||||
|
||||
/// 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.
|
||||
fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||
pub(crate) fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||
let (vol, link) = s.split_once('~')?;
|
||||
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
||||
return None;
|
||||
@@ -4948,6 +5136,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
||||
album: None,
|
||||
// Nothing signed it: it has never been near the crypto layer.
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4983,7 +5173,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> {
|
||||
|
||||
/// Convert a stored synced folder into its wire form for the front-ends, with
|
||||
/// 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 {
|
||||
id: f.id,
|
||||
local_path: f.local_path,
|
||||
@@ -4993,6 +5187,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
||||
state: f.state,
|
||||
last_sync: f.last_sync,
|
||||
progress,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5231,6 +5426,96 @@ fn node_size(node: &Node) -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
||||
fn node_revision_id(node: &Node) -> Option<String> {
|
||||
@@ -5872,6 +6157,8 @@ mod pending_size_tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5929,15 +6216,159 @@ mod tests {
|
||||
use super::{
|
||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, fuse_name,
|
||||
is_stale_mount, node_visible, note_self_change, parse_node_uid, prepare_shared_roots,
|
||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
||||
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
|
||||
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||
resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
|
||||
virtual_node,
|
||||
};
|
||||
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 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`:
|
||||
/// every non-writable authority is one `EACCES` on the syscall path.
|
||||
@@ -7082,6 +7513,9 @@ mod tests {
|
||||
share_id,
|
||||
membership_id: ShareMembershipId::from("membership"),
|
||||
permissions,
|
||||
invite_time: None,
|
||||
inviter_email: None,
|
||||
inviter_verification: None,
|
||||
});
|
||||
node
|
||||
};
|
||||
@@ -7542,10 +7976,44 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
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)]
|
||||
mod search_root_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
||||
pub username: String,
|
||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||
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.
|
||||
@@ -340,7 +343,12 @@ pub fn mount(
|
||||
let MountOptions {
|
||||
username,
|
||||
sweep_mode,
|
||||
upload_limit,
|
||||
download_limit,
|
||||
} = 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 scope = root.tree_event_scope_id();
|
||||
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.
|
||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||
|
||||
let paused_until = pause::load_paused_until(&db);
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
rt: rt.clone(),
|
||||
@@ -374,6 +383,7 @@ pub fn mount(
|
||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||
online: Arc::new(AtomicBool::new(online)),
|
||||
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||
@@ -381,6 +391,7 @@ pub fn mount(
|
||||
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
timeline_progress: Arc::new(Mutex::new(None)),
|
||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||
trash_progress: Arc::new(tokio::sync::Notify::new()),
|
||||
@@ -389,7 +400,15 @@ pub fn mount(
|
||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||
super::photos::THUMB_GEN_CONCURRENCY,
|
||||
)),
|
||||
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
@@ -406,6 +425,7 @@ pub fn mount(
|
||||
// Before anything can queue work against it: the drain thread below reaches
|
||||
// every mount's inode space through this registry, not through `core.state`.
|
||||
core.register_state(mountpoint);
|
||||
core.transfers.set_limits(upload_limit, download_limit);
|
||||
|
||||
// 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.
|
||||
@@ -440,6 +460,10 @@ pub fn mount(
|
||||
// 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.
|
||||
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
|
||||
// out identical to the live file, surface the ones that genuinely diverge.
|
||||
@@ -543,6 +567,26 @@ pub fn mount(
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||
// fetch to observe that closure, and then drain a stable registry.
|
||||
@@ -560,6 +604,9 @@ pub fn mount(
|
||||
}
|
||||
};
|
||||
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 {
|
||||
let repair_core = core.clone();
|
||||
let root_uid = core.primary_root_uid.clone();
|
||||
@@ -655,6 +702,11 @@ pub fn mount(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// How long teardown waits for the FUSE worker pool before proceeding without
|
||||
/// it. Long enough for an in-flight block to finish, short enough that
|
||||
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
||||
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||
///
|
||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||
@@ -669,6 +721,9 @@ pub fn mount(
|
||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||
/// than abandoning the user's bytes mid-flight.
|
||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||
// Say so before the joins: a teardown that takes a while must not be read as
|
||||
// a missed watchdog ping and turned into a kill.
|
||||
systemd::stopping();
|
||||
core.shutdown.stop();
|
||||
core.wake_drain();
|
||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||
@@ -683,6 +738,12 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+1826
-68
File diff suppressed because it is too large.
Load diff
@@ -248,15 +248,19 @@ impl Core {
|
||||
|
||||
// ---- restore (features.md 5.2) ----------------------------------------
|
||||
|
||||
/// The folders under this machine's device that could be synced here, each
|
||||
/// carrying a proposed local path.
|
||||
/// The folders under a device that could be synced here, each carrying a
|
||||
/// 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
|
||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||
/// does not exist, and silently creating it would be worse.
|
||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.ensure_device()?;
|
||||
pub(crate) fn list_restorable_folders(
|
||||
&self,
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<Vec<RestorableFolder>> {
|
||||
let device = self.restore_source(device)?;
|
||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||
})?;
|
||||
@@ -334,11 +338,12 @@ impl Core {
|
||||
pub(crate) fn restore_sync_folders(
|
||||
&self,
|
||||
items: &[pdfs_core::control::RestoreItem],
|
||||
device: Option<&str>,
|
||||
) -> CoreResult<String> {
|
||||
if items.is_empty() {
|
||||
return Err(CoreError::invalid("nothing to restore"));
|
||||
}
|
||||
let device = self.ensure_device()?;
|
||||
let device = self.restore_source(device)?;
|
||||
let existing = self
|
||||
.db
|
||||
.sync_folder_list()
|
||||
@@ -499,6 +504,19 @@ mod tests {
|
||||
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 {
|
||||
let start = source.find(signature).expect("function signature exists");
|
||||
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 crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
.map(role_to_str)
|
||||
.unwrap_or_default()
|
||||
.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 {
|
||||
name: n.name,
|
||||
is_dir,
|
||||
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||
uid: n.uid.to_string(),
|
||||
role,
|
||||
shared_by,
|
||||
shared_at,
|
||||
shared_by_unverified,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +388,66 @@ impl Core {
|
||||
.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 ---------------------------------------------------
|
||||
|
||||
/// List nodes shared with me that I have accepted.
|
||||
@@ -609,6 +685,7 @@ impl Core {
|
||||
items.push(SharedItem {
|
||||
uid: uid.to_string(),
|
||||
is_dir: n.is_folder(),
|
||||
modified: n.modification_time,
|
||||
name: n.name,
|
||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||
member_count: members,
|
||||
@@ -640,6 +717,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
|
||||
/// that mount has never interned the node, or if the chain breaks partway —
|
||||
/// a broken chain is a missing path, never a partial one.
|
||||
@@ -654,3 +772,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
||||
parts.reverse();
|
||||
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,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,6 +1130,9 @@ mod tests {
|
||||
share_id: ShareId::from("share"),
|
||||
membership_id: ShareMembershipId::from("membership"),
|
||||
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);
|
||||
}
|
||||
|
||||
let roots = self.search_roots();
|
||||
let online = self.online.load(std::sync::atomic::Ordering::Relaxed);
|
||||
for node in &nodes {
|
||||
if node.trashed || node.is_folder() {
|
||||
@@ -132,7 +133,16 @@ impl Core {
|
||||
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
|
||||
/// answered "idle" for every such file and let the sweep delete one with a
|
||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||
let mut busy = false;
|
||||
self.for_each_state(|st| {
|
||||
let Some(&ino) = st.by_uid.get(uid) else {
|
||||
@@ -273,12 +283,21 @@ impl Core {
|
||||
busy
|
||||
}
|
||||
|
||||
/// Surface a conflict copy that needs the user's attention, at most once per
|
||||
/// daemon run so the activity feed is not spammed each pass.
|
||||
/// Surface a conflict copy that needs the user's attention, once. The
|
||||
/// 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) {
|
||||
if !self.conflict_notified.lock().insert(uid.clone()) {
|
||||
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");
|
||||
self.log_activity(ActivityKind::Conflict, name, detail, false);
|
||||
}
|
||||
@@ -368,6 +387,8 @@ mod tests {
|
||||
photo: None,
|
||||
album: None,
|
||||
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
|
||||
/// pass that may run for minutes.
|
||||
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
|
||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||
// 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
|
||||
// actually goes up rather than whatever is at the path when we finish.
|
||||
let streamed = LocalSig::from(&meta);
|
||||
let thumbnails = self
|
||||
.upload_thumbnails_async(path.clone(), name.to_string())
|
||||
.await;
|
||||
// Count the bytes as they are read, so this upload shows up in
|
||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||
// draft is sealed, so the transfer registers without one.
|
||||
@@ -1240,7 +1249,7 @@ impl Core {
|
||||
crate::media_type_for(name),
|
||||
reader,
|
||||
meta.len() as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
Some(mtime),
|
||||
false,
|
||||
)
|
||||
@@ -1268,6 +1277,9 @@ impl Core {
|
||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||
let mtime = system_mtime(&meta);
|
||||
let streamed = LocalSig::from(&meta);
|
||||
let thumbnails = self
|
||||
.upload_thumbnails_async(path.clone(), base_name(rel).to_string())
|
||||
.await;
|
||||
let reader = OwnedCountingReader::new(
|
||||
file,
|
||||
self.transfers.begin(
|
||||
@@ -1278,7 +1290,7 @@ impl Core {
|
||||
),
|
||||
);
|
||||
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
|
||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||
warn_if_torn(rel, &path, streamed);
|
||||
|
||||
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(&core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||
reconcile_all(core);
|
||||
} else {
|
||||
for id in ids {
|
||||
if core.sync_paused() {
|
||||
break;
|
||||
}
|
||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||
core.reconcile_folder(&folder);
|
||||
}
|
||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
||||
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) {
|
||||
if core.sync_paused() {
|
||||
debug!("sync: paused, skipping reconcile");
|
||||
return;
|
||||
}
|
||||
let folders = match core.db.sync_folder_list() {
|
||||
Ok(f) => f,
|
||||
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
|
||||
// volume's dedicated favourite endpoint.
|
||||
// volume's dedicated favorite endpoint.
|
||||
if !favorites.is_empty() {
|
||||
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||
.into_iter()
|
||||
@@ -370,7 +370,7 @@ impl Core {
|
||||
})
|
||||
.collect();
|
||||
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(())
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
//! always reflects exactly what is in flight — even if the work fails partway
|
||||
//! 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
|
||||
|
||||
use parking_lot::Mutex;
|
||||
@@ -20,7 +24,7 @@ use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||
|
||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||
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 {
|
||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
||||
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
|
||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||
pub fn begin(
|
||||
@@ -169,9 +240,17 @@ pub struct 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) {
|
||||
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)
|
||||
{
|
||||
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||
// and the SDK would seal a truncated revision from it.
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"upload superseded by a newer write",
|
||||
));
|
||||
// and the SDK would seal a truncated revision from it. Not
|
||||
// `Interrupted` either: every reader loop, the SDK's included,
|
||||
// retries that kind, and this flag never clears, so the drain
|
||||
// thread spun forever with the transfer stuck at 0 bytes (B98).
|
||||
return Err(std::io::Error::other("upload superseded by a newer write"));
|
||||
}
|
||||
let n = self.inner.read(buf)?;
|
||||
self.guard.add(n as u64);
|
||||
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()),
|
||||
));
|
||||
}
|
||||
let thumbnails = core
|
||||
.upload_thumbnails_async(t.path.clone(), t.name.clone())
|
||||
.await;
|
||||
match core
|
||||
.client
|
||||
.upload_file_from(
|
||||
@@ -77,7 +80,7 @@ async fn run_uploads(
|
||||
media_type_for(&t.name),
|
||||
reader,
|
||||
t.size as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
mtime,
|
||||
false,
|
||||
)
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
||||
photo: None,
|
||||
album: None,
|
||||
verification: Default::default(),
|
||||
direct_role: None,
|
||||
share_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+333
-33
@@ -12,6 +12,8 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use parking_lot::{Condvar, Mutex};
|
||||
use tracing::warn;
|
||||
@@ -51,8 +53,25 @@ pub(crate) const FUSE_WORKERS: usize = 11;
|
||||
/// exactly the blocking this split exists to prevent.
|
||||
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>;
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Lane {
|
||||
@@ -65,12 +84,63 @@ pub(crate) enum Lane {
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queues {
|
||||
meta: VecDeque<Job>,
|
||||
transfer: VecDeque<Job>,
|
||||
meta: VecDeque<Queued>,
|
||||
transfer: VecDeque<Queued>,
|
||||
/// Set when the pool is dropped, to wake and retire every worker.
|
||||
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.
|
||||
///
|
||||
/// Two condvars, not one, because the workers are not interchangeable: a
|
||||
@@ -86,6 +156,22 @@ struct Pool {
|
||||
meta_cv: Condvar,
|
||||
/// Waited on by the general workers.
|
||||
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.
|
||||
@@ -94,25 +180,46 @@ struct Pool {
|
||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||
pub(crate) struct Workers {
|
||||
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 {
|
||||
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 {
|
||||
queues: Mutex::new(Queues::default()),
|
||||
meta_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 meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
||||
for i in 0..n {
|
||||
let mut handles = Vec::with_capacity(n);
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
let inner = inner.clone();
|
||||
let meta_only = i < meta_workers;
|
||||
std::thread::Builder::new()
|
||||
.name(format!(
|
||||
"pdfs-fuse-{}{i}",
|
||||
if meta_only { "meta-" } else { "" }
|
||||
))
|
||||
let handle = std::thread::Builder::new()
|
||||
.name(name.clone())
|
||||
.spawn(move || {
|
||||
let cv = if meta_only {
|
||||
&inner.meta_cv
|
||||
@@ -121,17 +228,20 @@ impl Workers {
|
||||
};
|
||||
loop {
|
||||
let mut q = inner.queues.lock();
|
||||
let job = loop {
|
||||
let picked = loop {
|
||||
// A general worker takes transfers first: metadata
|
||||
// already has threads of its own, so draining the
|
||||
// bulk queue is the useful thing for it to do.
|
||||
let picked = if meta_only {
|
||||
q.meta.pop_front()
|
||||
let taken = if meta_only {
|
||||
q.meta.pop_front().map(|job| (Lane::Meta, job))
|
||||
} 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 {
|
||||
break Some(job);
|
||||
if let Some(taken) = taken {
|
||||
break Some(taken);
|
||||
}
|
||||
if q.closed {
|
||||
break None;
|
||||
@@ -139,7 +249,15 @@ impl Workers {
|
||||
cv.wait(&mut 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
|
||||
// for the rest of the run. The dropped `Reply` answers
|
||||
// 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
|
||||
// the same check — an SDK-side `.lock().unwrap()`
|
||||
// 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`.
|
||||
@@ -179,7 +356,15 @@ impl Workers {
|
||||
/// 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
|
||||
/// 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();
|
||||
if q.closed {
|
||||
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
||||
@@ -189,11 +374,31 @@ impl Workers {
|
||||
job();
|
||||
return;
|
||||
}
|
||||
match lane {
|
||||
Lane::Meta => q.meta.push_back(Box::new(job)),
|
||||
Lane::Transfer => q.transfer.push_back(Box::new(job)),
|
||||
}
|
||||
let queued = Queued {
|
||||
label,
|
||||
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);
|
||||
if crosses_warn_step(depth) {
|
||||
warn!(
|
||||
lane = if matches!(lane, Lane::Meta) {
|
||||
"meta"
|
||||
} else {
|
||||
"transfer"
|
||||
},
|
||||
depth, label, "fuse worker queue is deep"
|
||||
);
|
||||
}
|
||||
match lane {
|
||||
// Either class can serve metadata, and only one of them needs to:
|
||||
// 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 {
|
||||
/// 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) {
|
||||
self.inner.queues.lock().closed = true;
|
||||
self.inner.meta_cv.notify_all();
|
||||
self.inner.general_cv.notify_all();
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +467,64 @@ mod tests {
|
||||
use std::sync::mpsc;
|
||||
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
|
||||
/// metadata job: that is what the reserved threads are for.
|
||||
#[test]
|
||||
@@ -240,7 +540,7 @@ mod tests {
|
||||
for _ in 0..general {
|
||||
let release_rx = release_rx.clone();
|
||||
let started_tx = started_tx.clone();
|
||||
pool.run(Lane::Transfer, move || {
|
||||
pool.run(Lane::Transfer, "test", move || {
|
||||
started_tx.send(()).unwrap();
|
||||
let _ = release_rx.lock().recv();
|
||||
});
|
||||
@@ -255,13 +555,13 @@ mod tests {
|
||||
// in the way that used to starve metadata.
|
||||
for _ in 0..16 {
|
||||
let release_rx = release_rx.clone();
|
||||
pool.run(Lane::Transfer, move || {
|
||||
pool.run(Lane::Transfer, "test", move || {
|
||||
let _ = release_rx.lock().recv();
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.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.
|
||||
let pool = Workers::new(1).unwrap();
|
||||
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))
|
||||
.expect("a general worker takes metadata when no transfer is waiting");
|
||||
}
|
||||
@@ -288,9 +588,9 @@ mod tests {
|
||||
#[test]
|
||||
fn a_panicking_job_does_not_retire_its_worker() {
|
||||
let pool = Workers::new(1).unwrap();
|
||||
pool.run(Lane::Meta, || panic!("boom"));
|
||||
pool.run(Lane::Meta, "test", || panic!("boom"));
|
||||
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))
|
||||
.expect("the pool still serves work after a panic");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.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]]
|
||||
name = "pdfs-tray"
|
||||
@@ -24,10 +27,13 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
ksni = "0.2"
|
||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
||||
ksni = { version = "0.3", features = ["blocking"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
webkit6 = "0.4"
|
||||
webkit6 = "0.6"
|
||||
# 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),
|
||||
"--sourcedir",
|
||||
"resources/icons",
|
||||
"--sourcedir",
|
||||
"resources",
|
||||
"resources/pdfs.gresource.xml",
|
||||
])
|
||||
.status()
|
||||
@@ -22,7 +24,7 @@ fn main() {
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||
println!(
|
||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
||||
);
|
||||
println!("cargo:rerun-if-changed=resources/icons");
|
||||
println!("cargo:rerun-if-changed=resources/style.css");
|
||||
println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg");
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,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,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,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 |
@@ -4,6 +4,20 @@
|
||||
icon-theme resource path at startup, so they resolve by name (and recolour
|
||||
with the theme) exactly like a stock symbolic icon. -->
|
||||
<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-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>
|
||||
<!-- 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 purple accent is a separate, opt-in provider. -->
|
||||
<gresource prefix="/de/nils/protondrivelinux">
|
||||
<file>style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -0,0 +1,95 @@
|
||||
/* 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
|
||||
* purple accent is opt-in and lives in its own provider (see main.rs).
|
||||
*/
|
||||
|
||||
/* 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; }
|
||||
|
||||
/* 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(--yellow-3); }
|
||||
.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, progressbar.browser-status-meter trough { min-width: 104px; }
|
||||
scale.browser-status-meter trough { min-height: 6px; }
|
||||
progressbar.browser-status-meter trough, progressbar.browser-status-meter progress { 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 { min-width: 20px; padding: 0 2px; }
|
||||
.photo-scrubber value { font-weight: 600; background-color: var(--popover-bg-color); border-radius: 6px; padding: 2px 8px; }
|
||||
.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; transition: box-shadow 180ms ease; }
|
||||
.photo-tile:hover { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45); }
|
||||
.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.06); }
|
||||
.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 { 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); }
|
||||
|
||||
/* 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: water a faint wash of the text colour, bubbles (drawn in
|
||||
* the area's own colour) in the accent. */
|
||||
.places-map-frame { border-radius: 12px; background: color-mix(in srgb, currentColor 5%, transparent); }
|
||||
.places-map { color: var(--accent-bg-color); }
|
||||
+914
-276
File diff suppressed because it is too large.
Load diff
@@ -1,53 +1,137 @@
|
||||
use crate::*;
|
||||
|
||||
pub(crate) struct ActivityState {
|
||||
// Activity page: one section, newest-first, rebuilt wholesale on each load.
|
||||
// Activity page: a "Needs attention" section for live conflicts, then the
|
||||
// feed grouped by day, rebuilt wholesale on each change.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
|
||||
pub(crate) days: gtk4::Box,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
|
||||
/// page polls every couple of seconds and usually gets back exactly what it
|
||||
/// is already showing; rebuilding all 200 rows for that would throw away the
|
||||
/// user's scroll position several times a minute.
|
||||
/// Fingerprint of what is on screen (see [`activity_key`]). The page polls
|
||||
/// every couple of seconds and usually gets back exactly what it is already
|
||||
/// showing; rebuilding every row for that would throw away the user's
|
||||
/// scroll position several times a minute.
|
||||
pub(crate) key: RefCell<Option<String>>,
|
||||
/// The last feed the daemon sent, so a filter change repaints without a
|
||||
/// round-trip.
|
||||
pub(crate) items: RefCell<Vec<ActivityEntry>>,
|
||||
pub(crate) filter: Cell<ActivityFilter>,
|
||||
/// Conflict copies still waiting for a decision, or `None` before the first
|
||||
/// listing. Lets a logged conflict read as resolved once it is gone.
|
||||
pub(crate) conflicts: RefCell<Option<Vec<ConflictInfo>>>,
|
||||
pub(crate) conflicts_inflight: Cell<bool>,
|
||||
pub(crate) conflicts_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// Widgets the Activity page's load/repaint touch.
|
||||
pub(crate) struct ActivityWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) days: gtk4::Box,
|
||||
pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||
/// daemon performed this session (uploads, deletes, shares, …).
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Activity")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
/// Which slice of the feed the filter bar shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ActivityFilter {
|
||||
All,
|
||||
Transfers,
|
||||
Changes,
|
||||
Sharing,
|
||||
Problems,
|
||||
}
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
impl ActivityFilter {
|
||||
const ALL: [ActivityFilter; 5] = [
|
||||
ActivityFilter::All,
|
||||
ActivityFilter::Transfers,
|
||||
ActivityFilter::Changes,
|
||||
ActivityFilter::Sharing,
|
||||
ActivityFilter::Problems,
|
||||
];
|
||||
|
||||
fn label(self) -> String {
|
||||
match self {
|
||||
ActivityFilter::All => pgettext("activity filter", "All"),
|
||||
ActivityFilter::Transfers => pgettext("activity filter", "Transfers"),
|
||||
ActivityFilter::Changes => pgettext("activity filter", "Changes"),
|
||||
ActivityFilter::Sharing => pgettext("activity filter", "Sharing"),
|
||||
ActivityFilter::Problems => pgettext("activity filter", "Problems"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an entry belongs in this slice. A failed action is a problem
|
||||
/// whatever it was, and also stays under its own kind.
|
||||
pub(crate) fn matches(self, entry: &ActivityEntry) -> bool {
|
||||
use ActivityKind::*;
|
||||
match self {
|
||||
ActivityFilter::All => true,
|
||||
ActivityFilter::Transfers => matches!(entry.kind, Upload | Download | Sync),
|
||||
ActivityFilter::Changes => matches!(
|
||||
entry.kind,
|
||||
Rename | Move | CreateFolder | Trash | Restore | DeleteForever | EmptyTrash
|
||||
),
|
||||
ActivityFilter::Sharing => matches!(entry.kind, Share | PublicLink | Unshare),
|
||||
ActivityFilter::Problems => !entry.ok || entry.kind == Conflict,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||
/// daemon performed (uploads, deletes, shares, …), grouped by day, with the
|
||||
/// conflicts that still need a decision on top.
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
// Filter chips: a linked row of radio toggles.
|
||||
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bar.add_css_class("linked");
|
||||
bar.set_halign(gtk4::Align::Start);
|
||||
let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new();
|
||||
for filter in ActivityFilter::ALL {
|
||||
let button = gtk4::ToggleButton::with_label(&filter.label());
|
||||
if let Some((_, first)) = filters.first() {
|
||||
button.set_group(Some(first));
|
||||
} else {
|
||||
button.set_active(true);
|
||||
}
|
||||
bar.append(&button);
|
||||
filters.push((filter, button));
|
||||
}
|
||||
|
||||
let attention = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Needs attention"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
column.set_margin_top(18);
|
||||
column.set_margin_bottom(18);
|
||||
column.set_margin_start(18);
|
||||
column.set_margin_end(18);
|
||||
column.append(&bar);
|
||||
column.append(&attention);
|
||||
column.append(&days);
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.tightening_threshold(600)
|
||||
.child(&column)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -66,20 +150,17 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
content.add_named(&scroll, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame(&gettext("Activity"), &content);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
ActivityWidgets {
|
||||
content,
|
||||
status,
|
||||
group,
|
||||
attention,
|
||||
days,
|
||||
filters,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
@@ -87,12 +168,26 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
}
|
||||
|
||||
/// Install the Activity page's retry button.
|
||||
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
pub(crate) fn wire_activity(
|
||||
ui: &Rc<Ui>,
|
||||
retry: >k4::Button,
|
||||
filters: &[(ActivityFilter, gtk4::ToggleButton)],
|
||||
) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_activity(&ui_retry);
|
||||
});
|
||||
for (filter, button) in filters {
|
||||
let ui = ui.clone();
|
||||
let filter = *filter;
|
||||
button.connect_toggled(move |button| {
|
||||
if button.is_active() {
|
||||
ui.activity.filter.set(filter);
|
||||
paint_activity(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Activity list.
|
||||
@@ -118,6 +213,7 @@ pub(crate) fn activity_status(
|
||||
/// pass fills the feed as it works rather than only once it is done. Anything
|
||||
/// other than a good answer leaves the rows alone until the next tick.
|
||||
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||
refresh_activity_conflicts(ui, false);
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
@@ -141,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||
}
|
||||
|
||||
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
refresh_activity_conflicts(ui, true);
|
||||
if ui.activity.inflight.get() {
|
||||
return;
|
||||
}
|
||||
@@ -148,8 +245,8 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Loading…",
|
||||
"Reading recent activity.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading recent activity."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -167,15 +264,15 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Error { message, .. })) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
&gettext("Couldn't read activity"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => activity_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read activity",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read activity"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => activity_unreachable(&ui),
|
||||
@@ -189,8 +286,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -198,8 +295,8 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
activity_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -210,93 +307,350 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
|
||||
/// The log is append-only and newest-first, so a feed with the same length and
|
||||
/// the same newest entry is the same feed.
|
||||
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
|
||||
match items.first() {
|
||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the Activity section from a fresh log, unless it is already showing
|
||||
/// exactly this feed.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
let key = activity_key(items);
|
||||
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
/// Poll the conflicts still waiting for a decision while the Activity page is
|
||||
/// on screen. `force` skips the TTL, for navigation and right after a
|
||||
/// resolution. Listing walks every node the daemon knows, so the tick asks far
|
||||
/// less often than it does for the feed.
|
||||
pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||
let state = &ui.activity;
|
||||
if state.conflicts_inflight.get()
|
||||
|| (!force
|
||||
&& state
|
||||
.conflicts_at
|
||||
.get()
|
||||
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||
{
|
||||
return;
|
||||
}
|
||||
*ui.activity.key.borrow_mut() = Some(key);
|
||||
for row in ui.activity.rows.borrow_mut().drain(..) {
|
||||
ui.activity.group.remove(&row);
|
||||
state.conflicts_inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
let state = &ui.activity;
|
||||
state.conflicts_inflight.set(false);
|
||||
state.conflicts_at.set(Some(Instant::now()));
|
||||
// An older daemon, or none, has no list: leave the logged conflicts as
|
||||
// they are rather than calling them resolved.
|
||||
let items = match result {
|
||||
Ok(Ok(Response::Conflicts { items })) => Some(items),
|
||||
_ => None,
|
||||
};
|
||||
*state.conflicts.borrow_mut() = items;
|
||||
if ui.stack.visible_child_name().as_deref() == Some("activity") {
|
||||
paint_activity(&ui);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A cheap fingerprint of what the page shows: the feed's length and newest
|
||||
/// entry (the log is append-only and newest-first, so that pins the feed), the
|
||||
/// filter, and which conflicts are still open.
|
||||
pub(crate) fn activity_key(
|
||||
items: &[ActivityEntry],
|
||||
filter: ActivityFilter,
|
||||
conflicts: Option<&[ConflictInfo]>,
|
||||
) -> String {
|
||||
let newest = match items.first() {
|
||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
||||
None => String::new(),
|
||||
};
|
||||
let open = conflicts.map(|c| {
|
||||
c.iter()
|
||||
.map(|c| c.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
format!("{newest}|{filter:?}|{open:?}")
|
||||
}
|
||||
|
||||
/// Keep a fresh log from the daemon and repaint with it.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
*ui.activity.items.borrow_mut() = items.to_vec();
|
||||
paint_activity(ui);
|
||||
}
|
||||
|
||||
/// One feed row: an entry and how many times it was logged in a row (or, for
|
||||
/// a conflict, at all).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FeedRow<'a> {
|
||||
pub(crate) entry: &'a ActivityEntry,
|
||||
pub(crate) count: usize,
|
||||
/// When the oldest collapsed repeat happened.
|
||||
pub(crate) first: i64,
|
||||
}
|
||||
|
||||
/// Collapse repeats so the feed reads as news. The daemon flags a divergent
|
||||
/// conflict once per run, so the same copy comes back every day it stays
|
||||
/// unresolved: every conflict entry folds into its newest mention. Any other
|
||||
/// entry folds only into an identical one right before it (a retry loop
|
||||
/// failing the same way).
|
||||
pub(crate) fn collapse_feed(items: &[ActivityEntry]) -> Vec<FeedRow<'_>> {
|
||||
let same = |a: &ActivityEntry, b: &ActivityEntry| {
|
||||
a.kind == b.kind && a.ok == b.ok && a.target == b.target && a.detail == b.detail
|
||||
};
|
||||
let mut rows: Vec<FeedRow> = Vec::new();
|
||||
for entry in items {
|
||||
let prior = if entry.kind == ActivityKind::Conflict {
|
||||
rows.iter_mut().find(|row| same(row.entry, entry))
|
||||
} else {
|
||||
rows.last_mut().filter(|row| same(row.entry, entry))
|
||||
};
|
||||
match prior {
|
||||
Some(row) => {
|
||||
row.count += 1;
|
||||
row.first = row.first.min(entry.time);
|
||||
}
|
||||
None => rows.push(FeedRow {
|
||||
entry,
|
||||
count: 1,
|
||||
first: entry.time,
|
||||
}),
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
rows
|
||||
}
|
||||
|
||||
/// A section title for the day `at` falls on, seen from `now`: "Today",
|
||||
/// "Yesterday", a weekday within the week, then a date.
|
||||
pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||
let today = now.ymd();
|
||||
if at.ymd() == today {
|
||||
return gettext("Today");
|
||||
}
|
||||
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||
return gettext("Yesterday");
|
||||
}
|
||||
let format = if now.difference(at).as_seconds() < 6 * 86_400 {
|
||||
"%A".to_string()
|
||||
} else if at.year() == now.year() {
|
||||
// Translators: strftime format for a day heading in this year, such as "Wednesday, September 23".
|
||||
gettext("%A, %B %-d")
|
||||
} else {
|
||||
// Translators: strftime format for a day heading in an earlier year, such as "December 31, 2025".
|
||||
gettext("%B %-d, %Y")
|
||||
};
|
||||
at.format(&format)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebuild the page from the kept feed and conflict list, unless it already
|
||||
/// shows exactly this.
|
||||
pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
let state = &ui.activity;
|
||||
let items = state.items.borrow();
|
||||
let conflicts = state.conflicts.borrow();
|
||||
let filter = state.filter.get();
|
||||
let key = activity_key(&items, filter, conflicts.as_deref());
|
||||
if state.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
let open: &[ConflictInfo] = conflicts.as_deref().unwrap_or_default();
|
||||
if items.is_empty() && open.is_empty() {
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
"Nothing yet",
|
||||
"Uploads, moves, shares and other changes appear here as they happen.",
|
||||
&gettext("Nothing yet"),
|
||||
&gettext("Uploads, moves, shares and other changes appear here as they happen."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.activity.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for a in items {
|
||||
let mut subtitle = activity_time(a.time);
|
||||
if !a.detail.is_empty() {
|
||||
subtitle = format!("{subtitle} · {}", a.detail);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(format!("{} {}", activity_verb(a.kind), a.target))
|
||||
.subtitle(subtitle)
|
||||
*state.key.borrow_mut() = Some(key);
|
||||
state.content.set_visible_child_name("list");
|
||||
|
||||
// Needs attention: the live conflicts, each opening the resolve dialog.
|
||||
for row in state.attention_rows.borrow_mut().drain(..) {
|
||||
state.attention.remove(&row);
|
||||
}
|
||||
state.attention.set_visible(!open.is_empty());
|
||||
state.attention.set_description(Some(&ngettext_f(
|
||||
"A file was changed in two places at once. Choose which version to keep.",
|
||||
"{n} files were changed in two places at once. Choose which versions to keep.",
|
||||
open.len() as u64,
|
||||
&[],
|
||||
)));
|
||||
let mut attention_rows = state.attention_rows.borrow_mut();
|
||||
for conflict in open {
|
||||
let row = conflict_row(ui, conflict);
|
||||
state.attention.add(&row);
|
||||
attention_rows.push(row);
|
||||
}
|
||||
drop(attention_rows);
|
||||
|
||||
while let Some(child) = state.days.first_child() {
|
||||
state.days.remove(&child);
|
||||
}
|
||||
let rows: Vec<FeedRow> = collapse_feed(&items)
|
||||
.into_iter()
|
||||
.filter(|row| filter.matches(row.entry))
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("edit-find-symbolic")
|
||||
.title(gettext("No matching activity"))
|
||||
.description(gettext("Nothing in the recent log fits this filter."))
|
||||
.build();
|
||||
let icon = if a.ok {
|
||||
activity_icon(a.kind)
|
||||
} else {
|
||||
"dialog-warning-symbolic"
|
||||
empty.add_css_class("compact");
|
||||
state.days.append(&empty);
|
||||
return;
|
||||
}
|
||||
let now = glib::DateTime::now_local().ok();
|
||||
let mut group: Option<(String, adw::PreferencesGroup)> = None;
|
||||
for row in rows {
|
||||
let at = glib::DateTime::from_unix_local(row.entry.time).ok();
|
||||
let label = match (&at, &now) {
|
||||
(Some(at), Some(now)) => day_label(at, now),
|
||||
_ => String::new(),
|
||||
};
|
||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
||||
row.set_activatable(false);
|
||||
ui.activity.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
if group.as_ref().is_none_or(|(day, _)| *day != label) {
|
||||
let section = adw::PreferencesGroup::builder().title(&label).build();
|
||||
state.days.append(§ion);
|
||||
group = Some((label, section));
|
||||
}
|
||||
if let Some((_, section)) = &group {
|
||||
section.add(&feed_row(ui, &row, at.as_ref(), open));
|
||||
}
|
||||
}
|
||||
*ui.activity.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// A human verb for an activity kind, used as the row title's lead word.
|
||||
pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
||||
/// One row of the feed.
|
||||
fn feed_row(
|
||||
ui: &Rc<Ui>,
|
||||
row: &FeedRow,
|
||||
at: Option<&glib::DateTime>,
|
||||
open: &[ConflictInfo],
|
||||
) -> adw::ActionRow {
|
||||
let a = row.entry;
|
||||
let title = activity_title(a.kind, &a.target);
|
||||
// A logged conflict names the copy; it is still open while the daemon
|
||||
// still lists a copy by that name.
|
||||
let pending = (a.kind == ActivityKind::Conflict)
|
||||
.then(|| open.iter().find(|c| file_name(&c.path) == a.target))
|
||||
.flatten();
|
||||
let resolved = a.kind == ActivityKind::Conflict
|
||||
&& pending.is_none()
|
||||
&& ui.activity.conflicts.borrow().is_some();
|
||||
|
||||
let mut subtitle: Vec<String> = Vec::new();
|
||||
if !a.detail.is_empty() {
|
||||
subtitle.push(capitalize(&a.detail));
|
||||
}
|
||||
if row.count > 1 {
|
||||
let since = activity_time(row.first);
|
||||
// Translators: {time} is a date and time such as "Sep 21, 14:05".
|
||||
subtitle.push(ngettext_f(
|
||||
"{n} time since {time}",
|
||||
"{n} times since {time}",
|
||||
row.count as u64,
|
||||
&[("time", &since)],
|
||||
));
|
||||
}
|
||||
if resolved {
|
||||
subtitle.push(pgettext("state", "Resolved"));
|
||||
}
|
||||
let widget = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle.join(" · "))
|
||||
.use_markup(false)
|
||||
.title_lines(1)
|
||||
.subtitle_lines(2)
|
||||
.build();
|
||||
|
||||
// A failure and a conflict are different news: a conflict kept both
|
||||
// copies, a failure lost the action. Each gets its own icon and colour.
|
||||
let icon = gtk4::Image::from_icon_name(if resolved {
|
||||
"object-select-symbolic"
|
||||
} else if a.ok || a.kind == ActivityKind::Conflict {
|
||||
activity_icon(a.kind)
|
||||
} else {
|
||||
"dialog-error-symbolic"
|
||||
});
|
||||
if resolved {
|
||||
icon.add_css_class("success");
|
||||
} else if a.kind == ActivityKind::Conflict {
|
||||
icon.add_css_class("warning");
|
||||
} else if !a.ok {
|
||||
icon.add_css_class("error");
|
||||
}
|
||||
widget.add_prefix(&icon);
|
||||
|
||||
// Translators: strftime format for the time of day in the activity feed, such as "14:05".
|
||||
let time_format = gettext("%H:%M");
|
||||
let time = gtk4::Label::new(Some(
|
||||
&at.and_then(|at| at.format(&time_format).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
time.add_css_class("dim-label");
|
||||
time.add_css_class("numeric");
|
||||
if let Some(at) = at.and_then(|at| at.format("%c").ok()) {
|
||||
time.set_tooltip_text(Some(&at));
|
||||
}
|
||||
widget.add_suffix(&time);
|
||||
|
||||
if let Some(conflict) = pending {
|
||||
let resolve = gtk4::Button::builder()
|
||||
.label(gettext("Resolve…"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
let conflict = conflict.clone();
|
||||
resolve.connect_clicked(move |_| prompt_resolve_conflict(&ui, &conflict));
|
||||
widget.add_suffix(&resolve);
|
||||
}
|
||||
widget
|
||||
}
|
||||
|
||||
/// The row title for an activity: what happened to `target`, as one sentence
|
||||
/// per kind so translators can place the name where their language needs it.
|
||||
pub(crate) fn activity_title(kind: ActivityKind, target: &str) -> String {
|
||||
let args = [("name", target)];
|
||||
match kind {
|
||||
ActivityKind::Upload => "Uploaded",
|
||||
ActivityKind::Download => "Downloaded",
|
||||
ActivityKind::Sync => "Synced",
|
||||
ActivityKind::Rename => "Renamed",
|
||||
ActivityKind::Move => "Moved",
|
||||
ActivityKind::CreateFolder => "Created folder",
|
||||
ActivityKind::Trash => "Trashed",
|
||||
ActivityKind::Restore => "Restored",
|
||||
ActivityKind::DeleteForever => "Deleted",
|
||||
ActivityKind::EmptyTrash => "Emptied trash —",
|
||||
ActivityKind::Share => "Shared",
|
||||
ActivityKind::PublicLink => "Linked",
|
||||
ActivityKind::Unshare => "Unshared",
|
||||
ActivityKind::Conflict => "Conflict",
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Upload => gettext_f("Uploaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Download => gettext_f("Downloaded {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Sync => gettext_f("Synced {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Rename => gettext_f("Renamed {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Move => gettext_f("Moved {name}", &args),
|
||||
// Translators: {name} is a folder name.
|
||||
ActivityKind::CreateFolder => gettext_f("Created folder {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Trash => gettext_f("Trashed {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Restore => gettext_f("Restored {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::DeleteForever => gettext_f("Deleted {name}", &args),
|
||||
// The target of an empty is a count, not a name to lead with a verb.
|
||||
// Translators: {count} is the number of items removed from the Trash.
|
||||
ActivityKind::EmptyTrash => gettext_f("Emptied Trash ({count})", &[("count", target)]),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Share => gettext_f("Shared {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::PublicLink => gettext_f("Created a link to {name}", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
ActivityKind::Unshare => gettext_f("Unshared {name}", &args),
|
||||
// Translators: {name} is the name of the conflict copy.
|
||||
ActivityKind::Conflict => gettext_f("Conflict {name}", &args),
|
||||
}
|
||||
}
|
||||
|
||||
/// A themed icon for an activity kind.
|
||||
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
match kind {
|
||||
ActivityKind::Upload => "document-send-symbolic",
|
||||
ActivityKind::Upload => "pdfs-upload-symbolic",
|
||||
ActivityKind::Download => "document-save-symbolic",
|
||||
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
||||
ActivityKind::Rename => "document-edit-symbolic",
|
||||
ActivityKind::Move => "go-jump-symbolic",
|
||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
|
||||
"user-trash-symbolic"
|
||||
}
|
||||
ActivityKind::Trash => "user-trash-symbolic",
|
||||
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||
ActivityKind::Restore => "edit-undo-symbolic",
|
||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||
@@ -307,7 +661,104 @@ pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||
/// Format an epoch-seconds timestamp for the Activity feed, in local time.
|
||||
pub(crate) fn activity_time(secs: i64) -> String {
|
||||
glib::DateTime::from_unix_local(secs)
|
||||
.and_then(|dt| dt.format("%b %-d, %H:%M"))
|
||||
// Translators: strftime format for a date and time in the activity feed, such as "Sep 23, 14:05".
|
||||
.and_then(|dt| dt.format(&gettext("%b %-d, %H:%M")))
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(time: i64, kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||
ActivityEntry {
|
||||
time,
|
||||
kind,
|
||||
target: target.into(),
|
||||
detail: detail.into(),
|
||||
ok,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_logged_every_day_shows_once_with_a_count() {
|
||||
let items = vec![
|
||||
entry(
|
||||
300,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
entry(250, ActivityKind::Upload, "b.txt", "", true),
|
||||
entry(
|
||||
200,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
100,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"differs",
|
||||
false,
|
||||
),
|
||||
];
|
||||
let rows = collapse_feed(&items);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].entry.time, 300);
|
||||
assert_eq!(rows[0].count, 3);
|
||||
assert_eq!(rows[0].first, 100);
|
||||
assert_eq!(rows[1].count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_entries_collapse_only_when_back_to_back() {
|
||||
let items = vec![
|
||||
entry(300, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
entry(200, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
entry(150, ActivityKind::Trash, "c.txt", "", true),
|
||||
entry(100, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||
];
|
||||
let counts: Vec<usize> = collapse_feed(&items).iter().map(|r| r.count).collect();
|
||||
assert_eq!(counts, vec![2, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_problems_filter_holds_failures_and_conflicts() {
|
||||
let failed = entry(1, ActivityKind::Upload, "a", "", false);
|
||||
let conflict = entry(1, ActivityKind::Conflict, "a", "", false);
|
||||
let fine = entry(1, ActivityKind::Upload, "a", "", true);
|
||||
assert!(ActivityFilter::Problems.matches(&failed));
|
||||
assert!(ActivityFilter::Problems.matches(&conflict));
|
||||
assert!(!ActivityFilter::Problems.matches(&fine));
|
||||
assert!(ActivityFilter::Transfers.matches(&failed));
|
||||
assert!(!ActivityFilter::Sharing.matches(&fine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_read_today_yesterday_weekday_then_date() {
|
||||
let tz = glib::TimeZone::utc();
|
||||
let at = |y, m, d, h| glib::DateTime::new(&tz, y, m, d, h, 0, 0.0).unwrap();
|
||||
let now = at(2026, 9, 23, 12);
|
||||
assert_eq!(day_label(&at(2026, 9, 23, 1), &now), "Today");
|
||||
assert_eq!(day_label(&at(2026, 9, 22, 23), &now), "Yesterday");
|
||||
// Weekday and month names follow the locale, so compare against it.
|
||||
let named = |dt: glib::DateTime, format: &str| dt.format(format).unwrap().to_string();
|
||||
assert_eq!(
|
||||
day_label(&at(2026, 9, 20, 10), &now),
|
||||
named(at(2026, 9, 20, 10), "%A")
|
||||
);
|
||||
assert_eq!(
|
||||
day_label(&at(2026, 9, 1, 10), &now),
|
||||
named(at(2026, 9, 1, 10), "%A, %B %-d")
|
||||
);
|
||||
assert_eq!(
|
||||
day_label(&at(2025, 12, 31, 10), &now),
|
||||
named(at(2025, 12, 31, 10), "%B %-d, %Y")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,35 @@ const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
show_album_grid(ui);
|
||||
load_albums(ui);
|
||||
}
|
||||
|
||||
/// Switch to the album grid as it was last filled, keeping its scroll
|
||||
/// position. The timeline's filters do not apply to albums, so they go.
|
||||
fn show_album_grid(ui: &Rc<Ui>) {
|
||||
close_album(ui);
|
||||
close_place(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("albums");
|
||||
}
|
||||
|
||||
/// Ask the daemon for the album listing and rebuild the grid.
|
||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
if ui.gallery.albums_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.albums_loading.set(true);
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
"Loading albums…",
|
||||
"Reading your Proton Drive albums.",
|
||||
);
|
||||
// A grid already on screen stays there while it refreshes, rather than
|
||||
// blinking to a loading page and losing its scroll position.
|
||||
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||
albums_status(
|
||||
ui,
|
||||
"view-grid-symbolic",
|
||||
&gettext("Loading albums…"),
|
||||
&gettext("Reading your Proton Drive albums."),
|
||||
);
|
||||
}
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
@@ -45,33 +57,33 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
})) => albums_status(
|
||||
&ui,
|
||||
"image-missing-symbolic",
|
||||
"No photo library",
|
||||
"This Proton account doesn't have Photos enabled.",
|
||||
&gettext("No photo library"),
|
||||
&gettext("This Proton account doesn't have Photos enabled."),
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) if items.is_empty() => albums_status(
|
||||
&ui,
|
||||
"view-grid-symbolic",
|
||||
"No albums",
|
||||
"Albums you create in Proton Photos appear here.",
|
||||
&gettext("No albums"),
|
||||
&gettext("Create one with New Album, or in Proton Photos."),
|
||||
),
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
fill_albums(&ui, &items);
|
||||
ui.gallery.albums_stack.set_visible_child_name("grid");
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't load albums", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
|
||||
albums_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't load albums",
|
||||
&gettext("Couldn't load albums"),
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => albums_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service didn't respond.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -80,15 +92,34 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||
/// (newest activity first).
|
||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
||||
let scroll = ui
|
||||
.gallery
|
||||
.albums
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_downcast::<gtk4::ScrolledWindow>();
|
||||
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||
while let Some(child) = ui.gallery.albums.first_child() {
|
||||
ui.gallery.albums.remove(&child);
|
||||
}
|
||||
*ui.gallery.album_list.borrow_mut() = albums.to_vec();
|
||||
if ui.gallery.album.borrow().is_none() {
|
||||
ui.gallery.title.set_subtitle(&ngettext_f(
|
||||
"{n} album",
|
||||
"{n} albums",
|
||||
albums.len() as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
for album in albums {
|
||||
ui.gallery.albums.append(&album_card(ui, album));
|
||||
}
|
||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||
// batch that fills them is the gallery's own.
|
||||
schedule_thumbs(ui);
|
||||
// A refresh rebuilds the cards; put the view back where the user was.
|
||||
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||
}
|
||||
}
|
||||
|
||||
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||
@@ -150,28 +181,409 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
||||
want_cover(ui, uid, &picture);
|
||||
}
|
||||
|
||||
let context = gtk4::GestureClick::builder().button(3).build();
|
||||
let ui_context = ui.clone();
|
||||
let menu_album = album.clone();
|
||||
context.connect_pressed(move |gesture, _, x, y| {
|
||||
gesture.set_state(gtk4::EventSequenceState::Claimed);
|
||||
if let Some(anchor) = gesture.widget() {
|
||||
show_album_menu(&ui_context, &menu_album, &anchor, x, y);
|
||||
}
|
||||
});
|
||||
button.add_controller(context);
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let album = album.clone();
|
||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// The album card's menu. Albums shared with us belong to someone else, so
|
||||
/// they only open.
|
||||
fn show_album_menu(ui: &Rc<Ui>, album: &AlbumInfo, anchor: >k4::Widget, x: f64, y: f64) {
|
||||
let mut menu = ActionMenu::new();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item(&pgettext("verb", "Open"), move || {
|
||||
open_album(&ui_c, album_c.clone())
|
||||
});
|
||||
if !album.shared {
|
||||
menu.section();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item(&gettext("Rename…"), move || {
|
||||
prompt_rename_album(&ui_c, &album_c)
|
||||
});
|
||||
menu.section();
|
||||
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||
menu.item(&gettext("Delete Album…"), move || {
|
||||
confirm_delete_album(&ui_c, &album_c)
|
||||
});
|
||||
}
|
||||
menu.popup_at(anchor, x, y);
|
||||
}
|
||||
|
||||
/// Ask for an album name. `on_name` gets the trimmed name; Create stays off
|
||||
/// while the entry is blank.
|
||||
fn prompt_album_name(
|
||||
ui: &Rc<Ui>,
|
||||
heading: &str,
|
||||
action: &str,
|
||||
current: &str,
|
||||
on_name: impl Fn(String) + 'static,
|
||||
) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder().heading(heading).build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title(gettext("Album name"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("ok", action);
|
||||
dialog.set_response_appearance("ok", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_response_enabled("ok", !current.trim().is_empty());
|
||||
dialog.set_default_response(Some("ok"));
|
||||
dialog.set_close_response("cancel");
|
||||
let dialog_typed = dialog.clone();
|
||||
row.connect_changed(move |row| {
|
||||
dialog_typed.set_response_enabled("ok", !row.text().trim().is_empty());
|
||||
});
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
let name = row.text().trim().to_string();
|
||||
if resp == "ok" && !name.is_empty() {
|
||||
on_name(name);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Ask for a name and create an album, then add `photos` to it, if any.
|
||||
pub(crate) fn prompt_new_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
let ui_c = ui.clone();
|
||||
let (heading, action) = (gettext("New Album"), gettext("Create"));
|
||||
prompt_album_name(ui, &heading, &action, "", move |name| {
|
||||
let rx = spawn_request(ui_c.dirs.control_socket(), Request::CreateAlbum { name });
|
||||
let ui = ui_c.clone();
|
||||
let photos = photos.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumCreated { uid })) => {
|
||||
if photos.is_empty() {
|
||||
toast(&ui, &gettext("Album created"));
|
||||
reload_album_grid(&ui);
|
||||
} else {
|
||||
add_to_album(&ui, uid, photos);
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't create the album"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't create the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
let (heading, action) = (gettext("Rename Album"), gettext("Rename"));
|
||||
prompt_album_name(ui, &heading, &action, &album.name, move |name| {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
Request::RenameAlbum {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
gettext("Album renamed"),
|
||||
gettext("Couldn't rename the album"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm_delete_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||
let Some(win) = ui_window(ui) else { return };
|
||||
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||
confirm_destructive(
|
||||
&win,
|
||||
&gettext("Delete Album?"),
|
||||
// Translators: {name} is the album's name.
|
||||
&gettext_f(
|
||||
"“{name}” is deleted. Its photos stay in your timeline.",
|
||||
&[("name", &album.name)],
|
||||
),
|
||||
&gettext("Delete"),
|
||||
move || {
|
||||
run_album_request(
|
||||
&ui_c,
|
||||
Request::DeleteAlbum {
|
||||
uid: uid.clone(),
|
||||
delete_photos: false,
|
||||
},
|
||||
gettext("Album deleted"),
|
||||
gettext("Couldn't delete the album"),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Send an album rename or delete and refresh the grid once it lands.
|
||||
fn run_album_request(ui: &Rc<Ui>, request: Request, done: String, failed: String) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, &done);
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refill the album grid if it is on screen; otherwise it reloads when shown.
|
||||
fn reload_album_grid(ui: &Rc<Ui>) {
|
||||
if ui.gallery.content.visible_child_name().as_deref() == Some("albums") {
|
||||
load_albums(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer our own albums to add `photos` to, plus a new one.
|
||||
pub(crate) fn prompt_add_to_album(ui: &Rc<Ui>, photos: Vec<String>) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let albums: Vec<AlbumInfo> = match rx.recv().await {
|
||||
Ok(Ok(Response::Albums { items, .. })) => {
|
||||
items.into_iter().filter(|album| !album.shared).collect()
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't load albums"), &message, kind);
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't load albums"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if albums.is_empty() {
|
||||
prompt_new_album(&ui, photos);
|
||||
return;
|
||||
}
|
||||
|
||||
let list = gtk4::ListBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::None)
|
||||
.build();
|
||||
list.add_css_class("boxed-list");
|
||||
for album in &albums {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&album.name))
|
||||
.subtitle(album_subtitle(album))
|
||||
.activatable(true)
|
||||
.build();
|
||||
list.append(&row);
|
||||
}
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.max_content_height(360)
|
||||
.child(&list)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Add to Album"))
|
||||
.body(match photos.len() {
|
||||
1 => gettext("Choose an album for this photo."),
|
||||
n => ngettext_f(
|
||||
"Choose an album for {n} photo.",
|
||||
"Choose an album for {n} photos.",
|
||||
n as u64,
|
||||
&[],
|
||||
),
|
||||
})
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("new", &gettext("New Album…"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let (ui_c, dialog_c, photos_c) = (ui.clone(), dialog.clone(), photos.clone());
|
||||
list.connect_row_activated(move |_, row| {
|
||||
if let Some(album) = albums.get(row.index().max(0) as usize) {
|
||||
dialog_c.close();
|
||||
add_to_album(&ui_c, album.uid.clone(), photos_c.clone());
|
||||
}
|
||||
});
|
||||
let ui_c = ui.clone();
|
||||
dialog.connect_response(Some("new"), move |_, _| {
|
||||
prompt_new_album(&ui_c, photos.clone())
|
||||
});
|
||||
dialog.present(ui_window(&ui).as_ref());
|
||||
});
|
||||
}
|
||||
|
||||
fn add_to_album(ui: &Rc<Ui>, album: String, photos: Vec<String>) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::AddToAlbum { uid: album, photos },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||
if let Some(failure) = failed.first() {
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Some photos couldn't be added"),
|
||||
&failure.message,
|
||||
);
|
||||
} else {
|
||||
toast(
|
||||
&ui,
|
||||
&ngettext_f(
|
||||
"Added {n} photo to the album",
|
||||
"Added {n} photos to the album",
|
||||
changed.len() as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Filed photos no longer belong in a timeline of unfiled ones.
|
||||
// The daemon's filter drops them too, so the paging offset stays
|
||||
// in step with what is left.
|
||||
if ui.gallery.not_in_album.get() && ui.gallery.album.borrow().is_none() {
|
||||
ui.gallery
|
||||
.selected
|
||||
.borrow_mut()
|
||||
.retain(|uid| !changed.contains(uid));
|
||||
remove_photos(&ui, &changed);
|
||||
}
|
||||
reload_album_grid(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't add to the album"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't add to the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Take photos out of the open album. They leave the view at once and come
|
||||
/// back if the server refuses; the timeline keeps them either way.
|
||||
pub(crate) fn remove_from_album(ui: &Rc<Ui>, album: &AlbumInfo, photos: Vec<String>) {
|
||||
let removed = remove_photos(ui, &photos);
|
||||
set_selection_mode(ui, false);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::RemoveFromAlbum {
|
||||
uid: album.uid.clone(),
|
||||
photos,
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
ui.busy_begin();
|
||||
glib::spawn_future_local(async move {
|
||||
let reply = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match reply {
|
||||
Ok(Ok(Response::AlbumChanged { changed, failed })) => {
|
||||
if !failed.is_empty() {
|
||||
let kept: Vec<PhotoItem> = removed
|
||||
.iter()
|
||||
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
|
||||
.cloned()
|
||||
.collect();
|
||||
restore_photos(&ui, kept);
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Some photos couldn't be removed from the album"),
|
||||
&failed[0].message,
|
||||
);
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The open album's count, as the header shows it.
|
||||
let mut open = ui.gallery.album.borrow_mut();
|
||||
if let Some(album) = open.as_mut() {
|
||||
album.photo_count = album.photo_count.saturating_sub(changed.len());
|
||||
ui.gallery.title.set_subtitle(&album_subtitle(album));
|
||||
}
|
||||
drop(open);
|
||||
toast(
|
||||
&ui,
|
||||
&ngettext_f(
|
||||
"Removed {n} photo from the album",
|
||||
"Removed {n} photos from the album",
|
||||
changed.len() as u64,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
restore_photos(&ui, removed);
|
||||
toast_failure(
|
||||
&ui,
|
||||
&gettext("Couldn't remove from the album"),
|
||||
&message,
|
||||
kind,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
restore_photos(&ui, removed);
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't remove from the album"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// "12 photos", plus where the album came from when it isn't ours.
|
||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||
let photos = match album.photo_count {
|
||||
1 => "1 photo".to_string(),
|
||||
n => format!("{n} photos"),
|
||||
};
|
||||
let count = album.photo_count as u64;
|
||||
if album.shared {
|
||||
format!("{photos} · shared with me")
|
||||
ngettext_f(
|
||||
"{n} photo · shared with me",
|
||||
"{n} photos · shared with me",
|
||||
count,
|
||||
&[],
|
||||
)
|
||||
} else {
|
||||
photos
|
||||
ngettext_f("{n} photo", "{n} photos", count, &[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue an album cover for the gallery's own thumbnail machinery: same batching,
|
||||
/// same texture cache, same decode pacing as a timeline tile.
|
||||
fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
pub(crate) fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
if let Some(texture) = ui.gallery.photo_tex.borrow().get(&uid) {
|
||||
picture.set_paintable(Some(texture));
|
||||
return;
|
||||
@@ -192,10 +604,10 @@ fn want_cover(ui: &Rc<Ui>, uid: String, picture: >k4::Picture) {
|
||||
/// Open one album in the gallery: the timeline view, paged from the album
|
||||
/// instead, with the filters that don't apply to it out of the way.
|
||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||
ui.gallery.title.set_label(&album.name);
|
||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
||||
ui.gallery.subtitle.set_visible(true);
|
||||
ui.gallery.title.set_title(&album.name);
|
||||
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
||||
*ui.gallery.album.borrow_mut() = Some(album);
|
||||
ui.gallery.timeline_stale.set(true);
|
||||
|
||||
// An album page carries no kind or date filter, and Upload targets the
|
||||
// timeline rather than an album — hide those rather than offer controls that
|
||||
@@ -219,8 +631,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_label("Gallery");
|
||||
ui.gallery.filters.set_visible(true);
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
@@ -251,15 +662,35 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
||||
if !btn.is_active() {
|
||||
return;
|
||||
}
|
||||
// The timeline is reloaded rather than restored, because an open album
|
||||
// left the model holding its own photos.
|
||||
close_album(&ui_photos);
|
||||
close_place(&ui_photos);
|
||||
ui_photos.gallery.filters.set_visible(true);
|
||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(&ui_photos, false);
|
||||
// The timeline comes back as it was left unless an album took over the
|
||||
// model since; then it has to be reloaded.
|
||||
if ui_photos.gallery.timeline_stale.replace(false) || ui_photos.gallery.model.n_items() == 0
|
||||
{
|
||||
load_gallery(&ui_photos, false);
|
||||
} else {
|
||||
update_gallery_subtitle(&ui_photos);
|
||||
}
|
||||
});
|
||||
|
||||
// Back returns to the grid the album or place was opened from, scrolled
|
||||
// where it was, instead of reloading it.
|
||||
let ui_back = ui.clone();
|
||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
||||
show_albums(&ui_back);
|
||||
if ui_back.gallery.place.borrow().is_some() {
|
||||
show_place_grid(&ui_back);
|
||||
return;
|
||||
}
|
||||
show_album_grid(&ui_back);
|
||||
let count = ui_back.gallery.album_list.borrow().len();
|
||||
ui_back.gallery.title.set_subtitle(&ngettext_f(
|
||||
"{n} album",
|
||||
"{n} albums",
|
||||
count as u64,
|
||||
&[],
|
||||
));
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -36,61 +36,39 @@ pub(crate) struct DevicesWidgets {
|
||||
/// machine's own device is deliberately not among them; see
|
||||
/// [`repaint_devices`].
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
/// "Restore Folders" — re-attach this device's remote folders to local
|
||||
/// directories after adopting it on a new machine.
|
||||
pub(crate) restore: gtk4::Button,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Computers page: a "This computer" section listing the local folders synced
|
||||
/// to this machine's device (each row offering a mode toggle and Remove), plus an
|
||||
/// "Other devices" section listing the account's other registered devices.
|
||||
/// The Computers page: a "This computer" section naming the device this machine
|
||||
/// backs up to (renamable from its header), plus an "Other computers" section
|
||||
/// listing the account's other registered devices. The synced folders
|
||||
/// themselves live on the Sync page.
|
||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Computers")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.label("Restore Folders")
|
||||
.tooltip_text("Sync this computer's Drive folders back to local directories")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&restore);
|
||||
|
||||
// This page is about device *identity* — which computer this is, which other
|
||||
// computers back up to the account, and how to adopt one. The folders
|
||||
// themselves are local paths, so they live on the Locations page; keeping a
|
||||
// themselves are local paths, so they live on the Sync page; keeping a
|
||||
// second copy of that list here would be two places to change the same mode.
|
||||
let sync_group = adw::PreferencesGroup::builder()
|
||||
.title("This computer")
|
||||
.description("The device this machine backs up to.")
|
||||
.title(gettext("This computer"))
|
||||
.description(gettext("The computer this machine backs up as."))
|
||||
.build();
|
||||
// Rename control for *this* machine's device, in the section header. The
|
||||
// current device is filtered out of "Other computers", so this is the only
|
||||
// place it can be renamed; insensitive until the device list identifies it.
|
||||
let rename_this = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename this computer")
|
||||
.tooltip_text(gettext("Rename this computer"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
rename_this.add_css_class("flat");
|
||||
sync_group.set_header_suffix(Some(&rename_this));
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Other computers")
|
||||
.description("Other devices backing up to this account.")
|
||||
.title(gettext("Other computers"))
|
||||
.description(gettext("Other computers backing up to this account."))
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -103,7 +81,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -123,37 +101,35 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
DevicesWidgets {
|
||||
content,
|
||||
status,
|
||||
sync_group,
|
||||
rename_this,
|
||||
group,
|
||||
restore,
|
||||
retry,
|
||||
refresh,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Devices page's retry button and the "Restore Folders" action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
||||
/// Install the Devices page's retry button and the Rename action.
|
||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
load_devices(&ui_retry);
|
||||
});
|
||||
let ui_restore = ui.clone();
|
||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
||||
let ui_ren = ui.clone();
|
||||
ui.devices.rename_this.connect_clicked(move |_| {
|
||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
||||
@@ -182,8 +158,8 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
"Loading…",
|
||||
"Reading your devices.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your computers."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -230,8 +206,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -239,8 +215,8 @@ pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
|
||||
devices_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -271,10 +247,21 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
match devices.iter().find(|d| d.this_device) {
|
||||
Some(me) => {
|
||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
||||
ui.devices
|
||||
.sync_group
|
||||
// Translators: {name} is the name of this computer's device.
|
||||
.set_description(Some(&gettext_f(
|
||||
"Backing up as “{name}”.",
|
||||
&[("name", &me.name)],
|
||||
)));
|
||||
ui.devices.rename_this.set_sensitive(true);
|
||||
ui.devices
|
||||
.rename_this
|
||||
.set_tooltip_text(Some(&format!("Rename this computer ({})", me.name)));
|
||||
// Translators: {name} is the name of this computer's device.
|
||||
.set_tooltip_text(Some(&gettext_f(
|
||||
"Rename this computer ({name})",
|
||||
&[("name", &me.name)],
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
*ui.devices.this_device.borrow_mut() = None;
|
||||
@@ -284,8 +271,8 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
|
||||
if others.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No other computers")
|
||||
.subtitle("Desktop apps syncing to this account appear here.")
|
||||
.title(gettext("No other computers"))
|
||||
.subtitle(gettext("Desktop apps syncing to this account appear here."))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
ui.devices.group.add(&row);
|
||||
@@ -299,43 +286,34 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
.subtitle(device_subtitle(dev))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove this computer and everything it backed up")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let rename = gtk4::Button::builder()
|
||||
.icon_name("document-edit-symbolic")
|
||||
.tooltip_text("Rename computer")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
rename.add_css_class("flat");
|
||||
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
||||
// device it used to be, instead of registering a duplicate. It only
|
||||
// makes sense on *another* device's row, which is the only place this
|
||||
// makes sense on *another* computer's row, which is the only place this
|
||||
// loop paints.
|
||||
let adopt = gtk4::Button::builder()
|
||||
.icon_name("insert-object-symbolic")
|
||||
.tooltip_text("Use this computer's identity for this machine")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
adopt.add_css_class("flat");
|
||||
let ui_ad = ui.clone();
|
||||
let uid_ad = dev.uid.clone();
|
||||
let name_ad = dev.name.clone();
|
||||
adopt.connect_clicked(move |_| prompt_adopt_device(&ui_ad, &uid_ad, &name_ad));
|
||||
row.add_suffix(&adopt);
|
||||
let ui_ren = ui.clone();
|
||||
let uid_ren = dev.uid.clone();
|
||||
let name_ren = dev.name.clone();
|
||||
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
|
||||
let ui_rm = ui.clone();
|
||||
let uid_rm = dev.uid.clone();
|
||||
let name_rm = dev.name.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
|
||||
row.add_suffix(&rename);
|
||||
row.add_suffix(&remove);
|
||||
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||
row.add_suffix(&more_menu_button(vec![
|
||||
(
|
||||
&gettext("Restore to This Computer…"),
|
||||
Box::new(move || {
|
||||
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.clone())))
|
||||
}),
|
||||
),
|
||||
(
|
||||
&gettext("Rename…"),
|
||||
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||
),
|
||||
(
|
||||
&gettext("Use This Computer's Identity…"),
|
||||
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||
),
|
||||
(
|
||||
&gettext("Remove Computer…"),
|
||||
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||
),
|
||||
]));
|
||||
ui.devices.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
@@ -347,10 +325,19 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
||||
/// "never" is the fact that tells the user this computer isn't backing anything up.
|
||||
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
|
||||
match dev.last_sync {
|
||||
Some(secs) if secs > 0 => {
|
||||
format!("{} · last synced {}", dev.device_type, activity_time(secs))
|
||||
}
|
||||
_ => format!("{} · never synced", dev.device_type),
|
||||
// Translators: {platform} is the device's platform (such as "Linux"), {time} a date and time.
|
||||
Some(secs) if secs > 0 => gettext_f(
|
||||
"{platform} · last synced {time}",
|
||||
&[
|
||||
("platform", &dev.device_type),
|
||||
("time", &activity_time(secs)),
|
||||
],
|
||||
),
|
||||
// Translators: {platform} is the device's platform, such as "Linux".
|
||||
_ => gettext_f(
|
||||
"{platform} · never synced",
|
||||
&[("platform", &dev.device_type)],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,19 +352,24 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
ui.devices.sync_group.remove(&row);
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("This computer")
|
||||
.title(gettext("This computer"))
|
||||
.subtitle(this_computer_subtitle(folders))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Locations")
|
||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||
.label(gettext("Open Sync"))
|
||||
.tooltip_text(gettext("Manage this computer's folders and mountpoint"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_go = ui.clone();
|
||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||
row.add_suffix(&manage);
|
||||
let ui_rs = ui.clone();
|
||||
row.add_suffix(&more_menu_button(vec![(
|
||||
&gettext("Restore Folders…"),
|
||||
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
||||
)]));
|
||||
ui.devices.sync_group.add(&row);
|
||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||
}
|
||||
@@ -386,30 +378,41 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||
if folders.is_empty() {
|
||||
return "No folders backed up yet — add one under Locations.".to_string();
|
||||
return gettext("No folders backed up yet — add one on the Sync page.");
|
||||
}
|
||||
let count = match folders.len() {
|
||||
1 => "1 folder backed up".to_string(),
|
||||
n => format!("{n} folders backed up"),
|
||||
};
|
||||
let count = ngettext_f(
|
||||
"{n} folder backed up",
|
||||
"{n} folders backed up",
|
||||
folders.len() as u64,
|
||||
&[],
|
||||
);
|
||||
let attention = folders
|
||||
.iter()
|
||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||
.count();
|
||||
match attention {
|
||||
0 => format!("{count} · manage them under Locations"),
|
||||
1 => format!("{count} · 1 needs attention"),
|
||||
n => format!("{count} · {n} need attention"),
|
||||
// Translators: {folders} is "N folders backed up".
|
||||
0 => gettext_f(
|
||||
"{folders} · manage them on the Sync page",
|
||||
&[("folders", &count)],
|
||||
),
|
||||
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
|
||||
n => ngettext_f(
|
||||
"{folders} · {n} needs attention",
|
||||
"{folders} · {n} need attention",
|
||||
n as u64,
|
||||
&[("folders", &count)],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for a synced folder's `state` column.
|
||||
pub(crate) fn sync_state_label(state: &str) -> &str {
|
||||
pub(crate) fn sync_state_label(state: &str) -> String {
|
||||
match state {
|
||||
"syncing" => "syncing…",
|
||||
"error" => "sync error",
|
||||
"conflict" => "needs attention",
|
||||
_ => "up to date",
|
||||
"syncing" => gettext("syncing…"),
|
||||
"error" => gettext("sync error"),
|
||||
"conflict" => gettext("needs attention"),
|
||||
_ => gettext("up to date"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,18 +424,30 @@ pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
match p.phase {
|
||||
// Before the first pass finishes there is no estimate, so the count would
|
||||
// be "checked 12 of 12" — worse than saying nothing.
|
||||
SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(),
|
||||
SyncPhase::Scanning => format!(
|
||||
"checking for changes — {} of {}",
|
||||
p.done,
|
||||
p.total.max(p.done)
|
||||
SyncPhase::Scanning if p.total == 0 => gettext("checking for changes…"),
|
||||
// Translators: {done} and {total} count folders and files checked so far.
|
||||
SyncPhase::Scanning => gettext_f(
|
||||
"checking for changes — {done} of {total}",
|
||||
&[
|
||||
("done", &p.done.to_string()),
|
||||
("total", &p.total.max(p.done).to_string()),
|
||||
],
|
||||
),
|
||||
SyncPhase::Applying => {
|
||||
let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1));
|
||||
let done = (p.done + 1).to_string();
|
||||
let total = p.total.max(p.done + 1).to_string();
|
||||
if p.current.is_empty() {
|
||||
format!("syncing {count}")
|
||||
// Translators: {done} and {total} count the changes applied so far.
|
||||
gettext_f(
|
||||
"syncing {done} of {total}",
|
||||
&[("done", &done), ("total", &total)],
|
||||
)
|
||||
} else {
|
||||
format!("syncing {} — {count}", p.current)
|
||||
// Translators: {file} is the path being synced; {done} and {total} count the changes applied so far.
|
||||
gettext_f(
|
||||
"syncing {file} — {done} of {total}",
|
||||
&[("file", &p.current), ("done", &done), ("total", &total)],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,7 +457,7 @@ pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
|
||||
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Add Folder to Sync")
|
||||
.title(gettext("Add Folder to Sync"))
|
||||
.build();
|
||||
let ui = ui.clone();
|
||||
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
|
||||
@@ -466,65 +481,81 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
||||
// or too late (row flashes in). Instead let the periodic
|
||||
// `refresh_locations` tick pick the row up whenever it
|
||||
// actually lands, which is what keeps a running pass live too.
|
||||
toast(&ui, "Syncing folder…");
|
||||
toast(&ui, &gettext("Syncing folder…"));
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't add folder", &message, kind)
|
||||
toast_failure(&ui, &gettext("Couldn't add folder"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't add folder",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't add folder"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the daemon what this machine's device holds, then show a picker mapping
|
||||
/// each remote folder onto a local directory.
|
||||
/// Ask the daemon what a device holds, then show a picker mapping each remote
|
||||
/// folder onto a local directory. `device` is another computer as `(uid, name)`;
|
||||
/// `None` is this machine's own device.
|
||||
///
|
||||
/// The daemon's paths are proposals — from the device's `profile.json` when it
|
||||
/// makes sense here, else `~/<name>` — so every one of them is editable and
|
||||
/// nothing is restored without being ticked.
|
||||
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
|
||||
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String)>) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListRestorableFolders);
|
||||
let request = match &device {
|
||||
Some((uid, _)) => Request::ListDeviceRestorableFolders {
|
||||
device: uid.clone(),
|
||||
},
|
||||
None => Request::ListRestorableFolders,
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), request);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
|
||||
Ok(Ok(Response::RestorableFolders { items })) => {
|
||||
show_restore_picker(&ui, items, device)
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(
|
||||
&ui,
|
||||
&gettext("Couldn't list folders to restore"),
|
||||
&message,
|
||||
kind,
|
||||
),
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't list folders to restore",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't list folders to restore"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The restore picker itself: one editable row per restorable folder.
|
||||
fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
fn show_restore_picker(
|
||||
ui: &Rc<Ui>,
|
||||
items: Vec<RestorableFolder>,
|
||||
device: Option<(String, String)>,
|
||||
) {
|
||||
let win = ui_window(ui);
|
||||
let candidates: Vec<RestorableFolder> =
|
||||
items.into_iter().filter(|f| !f.already_synced).collect();
|
||||
if candidates.is_empty() {
|
||||
toast(
|
||||
ui,
|
||||
"Nothing to restore — this computer's folders are all synced here.",
|
||||
¬hing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.description(
|
||||
.description(gettext(
|
||||
"Tick the folders to sync to this machine, and adjust where each one should live.",
|
||||
)
|
||||
))
|
||||
.build();
|
||||
// Held so the response handler can read back what the user ticked and typed.
|
||||
let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new();
|
||||
@@ -546,12 +577,14 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
.child(&group)
|
||||
.build();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore folders")
|
||||
.body("These folders are backed up under this computer in Proton Drive.")
|
||||
.heading(gettext("Restore folders"))
|
||||
.body(restore_picker_body(
|
||||
device.as_ref().map(|(_, n)| n.as_str()),
|
||||
))
|
||||
.extra_child(&scroll)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("restore", &gettext("Restore"));
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("restore"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -577,18 +610,48 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
||||
// rows appear as the periodic refresh picks them up.
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::RestoreSyncFolders { items },
|
||||
"Restoring folders…",
|
||||
"Couldn't restore folders",
|
||||
match &device {
|
||||
Some((uid, _)) => Request::RestoreDeviceFolders {
|
||||
device: uid.clone(),
|
||||
items,
|
||||
},
|
||||
None => Request::RestoreSyncFolders { items },
|
||||
},
|
||||
&gettext("Restoring folders…"),
|
||||
&gettext("Couldn't restore folders"),
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// The restore picker's body: whose backup the folders come from.
|
||||
pub(crate) fn restore_picker_body(device: Option<&str>) -> String {
|
||||
match device {
|
||||
// Translators: {name} is the name of another computer.
|
||||
Some(name) => gettext_f(
|
||||
"These folders are backed up from “{name}”. Restored folders keep syncing with that computer's copy.",
|
||||
&[("name", name)],
|
||||
),
|
||||
None => gettext("These folders are backed up under this computer in Proton Drive."),
|
||||
}
|
||||
}
|
||||
|
||||
/// The toast when every folder of a device is already synced here.
|
||||
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
|
||||
match device {
|
||||
// Translators: {name} is the name of another computer.
|
||||
Some(name) => gettext_f(
|
||||
"Nothing to restore — “{name}”'s folders are all synced here.",
|
||||
&[("name", name)],
|
||||
),
|
||||
None => gettext("Nothing to restore — this computer's folders are all synced here."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
|
||||
/// row's subtitle and switch reflect the daemon's real state (the request may be
|
||||
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
|
||||
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncFolderMode {
|
||||
@@ -599,22 +662,42 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
|
||||
// The daemon's own text is written for the log; the toast says what
|
||||
// the person will see happen. A switch may be queued behind a running
|
||||
// pass, hence "will".
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
&if mode == "ondemand" {
|
||||
gettext("Folder will switch to on-demand")
|
||||
} else {
|
||||
gettext("Folder will download and stay synced")
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
||||
toast_failure(&ui, &gettext("Couldn't change mode"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't change mode",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't change mode"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
||||
load_devices(&ui);
|
||||
}
|
||||
reload_sync_pages(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Repaint whichever of the two pages that show sync folders is on screen. Sync
|
||||
/// folder changes are raised from Locations as well as Computers, and both must
|
||||
/// show the daemon's answer at once rather than on the next tick — a rejected
|
||||
/// mode switch would otherwise stay visibly flipped.
|
||||
pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("devices") => load_devices(ui),
|
||||
Some("locations") => refresh_locations(ui),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
|
||||
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
||||
let win = ui_window(ui);
|
||||
@@ -625,36 +708,36 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
// nasty surprise if the dialog claimed the local files were safe, and is
|
||||
// recoverable only by turning On-demand off *first* and letting it download.
|
||||
let body = if ondemand {
|
||||
format!(
|
||||
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \
|
||||
Drive, not on this disk, so the folder will be empty once it is unmounted. To \
|
||||
keep a local copy, cancel, turn off On-demand, and wait for the download to \
|
||||
finish before removing it."
|
||||
// Translators: {path} is a local folder path.
|
||||
gettext_f(
|
||||
"Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton Drive, not on this disk, so the folder will be empty once it is unmounted. To keep a local copy, cancel, turn off On-demand, and wait for the download to finish before removing it.",
|
||||
&[("path", path)],
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \
|
||||
being synced. Choose whether to also delete the copy in Proton Drive."
|
||||
// Translators: {path} is a local folder path.
|
||||
gettext_f(
|
||||
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop being synced. Choose whether to also delete the copy in Proton Drive.",
|
||||
&[("path", path)],
|
||||
)
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Stop syncing folder")
|
||||
.heading(gettext("Stop syncing folder"))
|
||||
.body(body)
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let delete_remote = adw::SwitchRow::builder()
|
||||
.title("Also delete from Proton Drive")
|
||||
.title(gettext("Also delete from Proton Drive"))
|
||||
.subtitle(if ondemand {
|
||||
"Deletes the only copy of these files."
|
||||
gettext("Deletes the only copy of these files.")
|
||||
} else {
|
||||
"The local copy is unaffected."
|
||||
gettext("The local copy is unaffected.")
|
||||
})
|
||||
.active(false)
|
||||
.build();
|
||||
group.add(&delete_remote);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Stop Syncing");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Stop Syncing"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -667,8 +750,8 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
id,
|
||||
delete_remote: delete_remote.is_active(),
|
||||
},
|
||||
"Stopped syncing folder",
|
||||
"Couldn't stop syncing the folder",
|
||||
&gettext("Stopped syncing folder"),
|
||||
&gettext("Couldn't stop syncing the folder"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -679,19 +762,20 @@ pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondema
|
||||
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Rename device")
|
||||
.body(format!("Rename “{current}”."))
|
||||
.heading(gettext("Rename Computer"))
|
||||
// Translators: {name} is the computer's current name.
|
||||
.body(gettext_f("Rename “{name}”.", &[("name", current)]))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title("New name")
|
||||
.title(gettext("New name"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
row.set_text(current);
|
||||
group.add(&row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("rename", "Rename");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("rename", &gettext("Rename"));
|
||||
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("rename"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -703,7 +787,11 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
}
|
||||
let name = row.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
toast_error(&ui, "Couldn't rename device", "A name is required.");
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't rename the computer"),
|
||||
&gettext("A name is required."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
run_devices_mutation(
|
||||
@@ -712,29 +800,43 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||
uid: uid.clone(),
|
||||
name,
|
||||
},
|
||||
"Device renamed",
|
||||
"Couldn't rename the device",
|
||||
&gettext("Computer renamed"),
|
||||
&gettext("Couldn't rename the computer"),
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Confirm, then remove (deregister) a device.
|
||||
/// Confirm, then remove (deregister) a computer. Removing deletes everything the
|
||||
/// computer backed up, so the confirm button stays off until the name is typed.
|
||||
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove computer")
|
||||
.body(format!(
|
||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
|
||||
deleted along with it. The files on that computer itself are not touched — but \
|
||||
this cannot be undone from here."
|
||||
))
|
||||
let confirm = adw::EntryRow::builder()
|
||||
// Translators: {name} is the computer's name, which the user must type.
|
||||
.title(gettext_f("Type “{name}” to confirm", &[("name", name)]))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&confirm);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Remove Computer"))
|
||||
// Translators: {name} is the name of the computer being removed.
|
||||
.body(gettext_f(
|
||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.extra_child(&group)
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Remove"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_response_enabled("remove", false);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let dialog_typed = dialog.clone();
|
||||
let expected = name.to_string();
|
||||
confirm.connect_changed(move |row| {
|
||||
dialog_typed.set_response_enabled("remove", row.text().trim() == expected);
|
||||
});
|
||||
let ui = ui.clone();
|
||||
let uid = uid.to_string();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
@@ -742,8 +844,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
run_devices_mutation(
|
||||
&ui,
|
||||
Request::DeleteDevice { uid: uid.clone() },
|
||||
"Device removed",
|
||||
"Couldn't remove the device",
|
||||
&gettext("Computer removed"),
|
||||
&gettext("Couldn't remove the computer"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -760,16 +862,15 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Use this computer's identity")
|
||||
.body(format!(
|
||||
"Treat this machine as “{name}”?\n\nNew synced folders are created under that \
|
||||
computer in Proton Drive, and this machine keeps that identity even if its hostname \
|
||||
changes. Folders already synced here are not moved.\n\nUse “Restore folders” \
|
||||
afterwards to bring back what “{name}” was syncing."
|
||||
.heading(gettext("Use this computer's identity"))
|
||||
// Translators: {name} is the name of the other computer whose identity is adopted.
|
||||
.body(gettext_f(
|
||||
"Treat this machine as “{name}”?\n\nNew synced folders are created under that computer in Proton Drive, and this machine keeps that identity even if its hostname changes. Folders already synced here are not moved.\n\nUse “Restore folders” afterwards to bring back what “{name}” was syncing.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("adopt", "Use identity");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("adopt", &gettext("Use identity"));
|
||||
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -782,35 +883,33 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
Request::AdoptDevice {
|
||||
uid: Some(uid.clone()),
|
||||
},
|
||||
"Identity adopted",
|
||||
"Couldn't adopt that computer",
|
||||
&gettext("Identity adopted"),
|
||||
&gettext("Couldn't adopt that computer"),
|
||||
);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Devices page and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
load_devices(&ui);
|
||||
reload_sync_pages(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -829,6 +928,7 @@ mod tests {
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +936,7 @@ mod tests {
|
||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[]),
|
||||
"No folders backed up yet — add one under Locations."
|
||||
"No folders backed up yet — add one on the Sync page."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,7 +946,7 @@ mod tests {
|
||||
// the Computers page can surface that something is wrong.
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
||||
"2 folders backed up · manage them under Locations"
|
||||
"2 folders backed up · manage them on the Sync page"
|
||||
);
|
||||
assert_eq!(
|
||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||
@@ -857,4 +957,11 @@ mod tests {
|
||||
"2 folders backed up · 2 need attention"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_restore_picker_names_the_computer_it_restores_from() {
|
||||
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
|
||||
assert!(restore_picker_body(None).contains("this computer"));
|
||||
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use crate::*;
|
||||
|
||||
/// Side of a copy's thumbnail in the duplicates dialog, in px.
|
||||
const COPY_SIZE: i32 = 150;
|
||||
|
||||
/// Each set's copies with their Keep toggles.
|
||||
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
||||
|
||||
/// Open the duplicate finder: every set of photos stored more than once, side
|
||||
/// by side, with the copy worth keeping ticked. The copies left unticked go to
|
||||
/// Trash — one file each, never the rest of their shot.
|
||||
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.title(gettext("Looking for duplicates…"))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let stack = gtk4::Stack::new();
|
||||
stack.add_named(&status, Some("status"));
|
||||
|
||||
let trash = gtk4::Button::builder()
|
||||
.label(gettext("Move to Trash"))
|
||||
.halign(gtk4::Align::End)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
trash.add_css_class("destructive-action");
|
||||
trash.add_css_class("pill");
|
||||
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bottom.add_css_class("toolbar");
|
||||
bottom.set_halign(gtk4::Align::End);
|
||||
bottom.append(&trash);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
toolbar.set_content(Some(&stack));
|
||||
toolbar.add_bottom_bar(&bottom);
|
||||
toolbar.set_reveal_bottom_bars(false);
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(gettext("Duplicates"))
|
||||
.content_width(760)
|
||||
.content_height(640)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let sets = match rx.recv().await {
|
||||
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
||||
status.set_description(Some(&message));
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
status.set_icon_name(Some("dialog-warning-symbolic"));
|
||||
status.set_title(&gettext("Couldn't look for duplicates"));
|
||||
status.set_description(Some(&gettext("The mount service didn't respond.")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if sets.is_empty() {
|
||||
status.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
status.set_title(&gettext("No duplicates"));
|
||||
status.set_description(Some(&gettext(
|
||||
"Every photo in your library is stored once.",
|
||||
)));
|
||||
return;
|
||||
}
|
||||
let review = duplicate_review(&ui, sets, &trash);
|
||||
stack.add_named(&review, Some("review"));
|
||||
stack.set_visible_child_name("review");
|
||||
toolbar.set_reveal_bottom_bars(true);
|
||||
schedule_thumbs(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// The review list: one section per set, each copy a thumbnail with a Keep
|
||||
/// toggle. Wires `trash` to move every copy not kept to Trash.
|
||||
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: >k4::Button) -> gtk4::Widget {
|
||||
let intro = gtk4::Label::builder()
|
||||
.label(gettext(
|
||||
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
|
||||
))
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
intro.add_css_class("dim-label");
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(18);
|
||||
content.set_margin_end(18);
|
||||
content.append(&intro);
|
||||
|
||||
// Every copy's toggle, by set, so the button can count what goes.
|
||||
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
||||
for set in sets {
|
||||
let Some(first) = set.first() else {
|
||||
continue;
|
||||
};
|
||||
let date = section_heading(first.capture_time, Grouping::Day);
|
||||
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
|
||||
let title = ngettext_f(
|
||||
"{date}, {n} copy",
|
||||
"{date}, {n} copies",
|
||||
set.len() as u64,
|
||||
&[("date", &date)],
|
||||
);
|
||||
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
|
||||
heading.add_css_class("heading");
|
||||
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
let mut set_toggles = Vec::new();
|
||||
for (index, photo) in set.into_iter().enumerate() {
|
||||
let (card, keep) = copy_card(ui, &photo, index == 0);
|
||||
row.append(&card);
|
||||
set_toggles.push((photo, keep));
|
||||
}
|
||||
let row_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.child(&row)
|
||||
.build();
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
section.append(&heading);
|
||||
section.append(&row_scroll);
|
||||
content.append(§ion);
|
||||
toggles.borrow_mut().push(set_toggles);
|
||||
}
|
||||
|
||||
let sync = {
|
||||
let toggles = toggles.clone();
|
||||
let trash = trash.clone();
|
||||
move || {
|
||||
let n = doomed(&toggles.borrow()).len();
|
||||
trash.set_label(&ngettext_f(
|
||||
"Move {n} copy to Trash",
|
||||
"Move {n} copies to Trash",
|
||||
n as u64,
|
||||
&[],
|
||||
));
|
||||
trash.set_sensitive(n > 0);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
for set in toggles.borrow().iter() {
|
||||
for (_, keep) in set {
|
||||
let toggles = toggles.clone();
|
||||
let sync = sync.clone();
|
||||
keep.connect_toggled(move |keep| {
|
||||
// A set always keeps a copy: unticking the last one would trash
|
||||
// the photo itself, which is not what removing duplicates means.
|
||||
if !keep.is_active()
|
||||
&& toggles.borrow().iter().any(|set| {
|
||||
set.iter().any(|(_, t)| t == keep)
|
||||
&& set.iter().all(|(_, t)| !t.is_active())
|
||||
})
|
||||
{
|
||||
keep.set_active(true);
|
||||
return;
|
||||
}
|
||||
sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ui_trash = ui.clone();
|
||||
trash.connect_clicked(move |button| {
|
||||
let uids = doomed(&toggles.borrow());
|
||||
if uids.is_empty() {
|
||||
return;
|
||||
}
|
||||
trash_photos(&ui_trash, uids, true);
|
||||
if let Some(dialog) = button
|
||||
.ancestor(adw::Dialog::static_type())
|
||||
.and_downcast::<adw::Dialog>()
|
||||
{
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&content)
|
||||
.build();
|
||||
scroll.upcast()
|
||||
}
|
||||
|
||||
/// The uids of every copy not kept.
|
||||
fn doomed(toggles: &SetToggles) -> Vec<String> {
|
||||
toggles
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|(_, keep)| !keep.is_active())
|
||||
.map(|(photo, _)| photo.uid.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One copy: its thumbnail, its file name, and whether to keep it.
|
||||
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||
overlay.add_css_class("memory-cover");
|
||||
want_thumb(ui, photo, &picture);
|
||||
|
||||
let name = photo.name.clone().unwrap_or_default();
|
||||
let label = gtk4::Label::builder()
|
||||
.label(&name)
|
||||
.tooltip_text(&name)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.max_width_chars(18)
|
||||
.build();
|
||||
label.add_css_class("caption");
|
||||
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Keep"))
|
||||
.active(keep)
|
||||
.build();
|
||||
toggle.add_css_class("pill");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
card.set_size_request(COPY_SIZE, -1);
|
||||
card.append(&overlay);
|
||||
card.append(&label);
|
||||
card.append(&toggle);
|
||||
(card, toggle)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,34 +6,40 @@ pub(crate) struct LoginState {
|
||||
pub(crate) email: adw::EntryRow,
|
||||
pub(crate) password: adw::PasswordEntryRow,
|
||||
pub(crate) login_button: gtk4::Button,
|
||||
/// Spins inside the Sign in button while a sign-in is running.
|
||||
pub(crate) login_spinner: gtk4::Spinner,
|
||||
pub(crate) login_status: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(crate) fn build_login_page() -> (
|
||||
gtk4::Widget,
|
||||
(
|
||||
adw::EntryRow,
|
||||
adw::PasswordEntryRow,
|
||||
gtk4::Button,
|
||||
gtk4::Label,
|
||||
),
|
||||
) {
|
||||
/// Where the login page's account links go.
|
||||
const SIGNUP_URL: &str = "https://account.proton.me/drive/signup";
|
||||
const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||
|
||||
/// The login page: an email row, password row, a primary "Sign in" button, a
|
||||
/// status label and links to create an account or reset the password, centred
|
||||
/// in a clamp. The 2FA code is prompted lazily in a dialog (see [`prompt_2fa`])
|
||||
/// only when the account actually requires it.
|
||||
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Sign in to Proton")
|
||||
.description("Use your Proton account to connect Drive.")
|
||||
.title(gettext("Sign in to Proton"))
|
||||
.description(gettext("Use your Proton account to connect Drive."))
|
||||
.build();
|
||||
|
||||
let email = adw::EntryRow::builder().title("Email or username").build();
|
||||
let password = adw::PasswordEntryRow::builder().title("Password").build();
|
||||
let email = adw::EntryRow::builder()
|
||||
.title(gettext("Email or username"))
|
||||
.build();
|
||||
let password = adw::PasswordEntryRow::builder()
|
||||
.title(gettext("Password"))
|
||||
.build();
|
||||
group.add(&email);
|
||||
group.add(&password);
|
||||
|
||||
let login_spinner = gtk4::Spinner::builder().visible(false).build();
|
||||
let button_content = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
button_content.append(&login_spinner);
|
||||
button_content.append(>k4::Label::new(Some(&gettext("Sign in"))));
|
||||
let login_button = gtk4::Button::builder()
|
||||
.label("Sign in")
|
||||
.child(&button_content)
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
login_button.add_css_class("suggested-action");
|
||||
@@ -46,9 +52,8 @@ pub(crate) fn build_login_page() -> (
|
||||
login_status.add_css_class("dim-label");
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
let logo = gtk4::Image::from_icon_name("folder-remote-symbolic");
|
||||
logo.set_pixel_size(64);
|
||||
logo.add_css_class("brand-icon");
|
||||
let logo = gtk4::Image::from_icon_name(APP_ID);
|
||||
logo.set_pixel_size(96);
|
||||
let title = gtk4::Label::new(Some("Proton Drive"));
|
||||
title.add_css_class("brand-title");
|
||||
header.append(&logo);
|
||||
@@ -64,6 +69,17 @@ pub(crate) fn build_login_page() -> (
|
||||
inner.append(&group);
|
||||
inner.append(&login_button);
|
||||
inner.append(&login_status);
|
||||
let links = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
links.set_halign(gtk4::Align::Center);
|
||||
links.append(>k4::LinkButton::with_label(
|
||||
SIGNUP_URL,
|
||||
&gettext("Create account"),
|
||||
));
|
||||
links.append(>k4::LinkButton::with_label(
|
||||
RESET_PASSWORD_URL,
|
||||
&gettext("Forgot password?"),
|
||||
));
|
||||
inner.append(&links);
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(420)
|
||||
@@ -73,7 +89,13 @@ pub(crate) fn build_login_page() -> (
|
||||
|
||||
(
|
||||
scroll.upcast(),
|
||||
(email, password, login_button, login_status),
|
||||
LoginState {
|
||||
email,
|
||||
password,
|
||||
login_button,
|
||||
login_spinner,
|
||||
login_status,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,12 +121,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
if username.is_empty() || password.is_empty() {
|
||||
ui.login
|
||||
.login_status
|
||||
.set_text("Enter your email and password.");
|
||||
.set_text(&gettext("Enter your email and password."));
|
||||
return;
|
||||
}
|
||||
|
||||
ui.login.login_button.set_sensitive(false);
|
||||
ui.login.login_status.set_text("Signing in…");
|
||||
set_signing_in(&ui, true);
|
||||
ui.login.login_status.set_text(&gettext("Signing in…"));
|
||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||
|
||||
// Surface the 2FA dialog only if the SDK actually asks for a code (i.e.
|
||||
@@ -129,7 +151,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text("Complete the verification to continue…");
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
prompt_human_verification(&ui_hv, &url, token_tx);
|
||||
}
|
||||
});
|
||||
@@ -139,8 +161,8 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
let result = rx
|
||||
.recv()
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||
ui.login.login_button.set_sensitive(true);
|
||||
.unwrap_or_else(|_| Err(gettext("login cancelled")));
|
||||
set_signing_in(&ui, false);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
ui.login.login_status.set_text("");
|
||||
@@ -151,15 +173,53 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
service::enable_start();
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(e) => ui
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&format!("Sign-in failed: {e}")),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Lock the form and spin the button while a sign-in runs.
|
||||
fn set_signing_in(ui: &Rc<Ui>, running: bool) {
|
||||
ui.login.login_button.set_sensitive(!running);
|
||||
ui.login.email.set_sensitive(!running);
|
||||
ui.login.password.set_sensitive(!running);
|
||||
ui.login.login_spinner.set_visible(running);
|
||||
ui.login.login_spinner.set_spinning(running);
|
||||
}
|
||||
|
||||
/// What to tell the person when sign-in fails. Proton's own API messages are
|
||||
/// written for people ("Incorrect login credentials…"), so they pass through;
|
||||
/// everything else is mapped from its kind rather than shown as a debug string.
|
||||
pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String {
|
||||
use pdfs_core::proton_sdk::ProtonError;
|
||||
match error {
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if api.http_status == 429 => {
|
||||
gettext("Too many sign-in attempts. Wait a few minutes and try again.")
|
||||
}
|
||||
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||
api.message.clone()
|
||||
}
|
||||
pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
|
||||
gettext("Couldn't reach Proton. Check your internet connection and try again.")
|
||||
}
|
||||
pdfs_core::Error::Keyring(_) => gettext(
|
||||
"Signed in, but the session couldn't be saved to the system keyring. Make sure a keyring (GNOME Keyring or KWallet) is running and unlocked.",
|
||||
),
|
||||
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||
gettext("Sign-in cancelled: no two-factor code was entered.")
|
||||
}
|
||||
pdfs_core::Error::Other(message) if message.contains("verification") => gettext(
|
||||
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.",
|
||||
),
|
||||
other => {
|
||||
let error = other.to_string();
|
||||
// Translators: {error} is an error message, usually in English.
|
||||
gettext_f("Sign-in failed: {error}", &[("error", &error)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
||||
/// runtime. Returns two channels: the first yields the final login result once;
|
||||
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||
@@ -223,7 +283,7 @@ pub(crate) fn spawn_login(
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| login_error_message(&e))
|
||||
});
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
@@ -235,20 +295,22 @@ pub(crate) fn spawn_login(
|
||||
/// the worker reads as a cancelled login.
|
||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Two-factor authentication")
|
||||
.body("Enter the code from your authenticator app.")
|
||||
.heading(gettext("Two-factor authentication"))
|
||||
.body(gettext(
|
||||
"Enter the code from your authenticator app, or one of your recovery codes.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let entry = adw::EntryRow::builder()
|
||||
.title("Authentication code")
|
||||
.title(gettext("Authentication code"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&entry);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("confirm", "Confirm");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("confirm", &gettext("Confirm"));
|
||||
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("confirm"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -268,17 +330,45 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
||||
/// isn't left running without credentials), forget the stored session, and drop
|
||||
/// back to the login page.
|
||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||
let Some(window) = ui_window(ui) else { return };
|
||||
let ui = ui.clone();
|
||||
button.connect_clicked(move |_| {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
});
|
||||
confirm_destructive(
|
||||
&window,
|
||||
&gettext("Sign Out?"),
|
||||
&gettext(
|
||||
"Proton Drive will disconnect and stop syncing until you sign in again. Files already on this computer stay where they are.",
|
||||
),
|
||||
&gettext("Sign Out"),
|
||||
move || {
|
||||
service::disable_stop();
|
||||
if let Err(e) = auth::logout() {
|
||||
tracing::error!("logout failed: {e}");
|
||||
}
|
||||
*ui.session.borrow_mut() = None;
|
||||
refresh(&ui);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::login_error_message;
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_prompt_says_which_step_was_skipped() {
|
||||
let totp = pdfs_core::Error::Other("two-factor entry cancelled".into());
|
||||
assert!(login_error_message(&totp).contains("two-factor code"));
|
||||
let hv = pdfs_core::Error::Other("verification cancelled".into());
|
||||
assert!(login_error_message(&hv).contains("verification wasn't completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_error_keeps_its_text() {
|
||||
let error = pdfs_core::Error::Other("something odd".into());
|
||||
assert_eq!(login_error_message(&error), "Sign-in failed: something odd");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ pub(crate) mod activity;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod duplicates;
|
||||
pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
pub(crate) mod photos;
|
||||
pub(crate) mod places;
|
||||
pub(crate) mod places_map;
|
||||
pub(crate) mod shared;
|
||||
pub(crate) mod shared_by_me;
|
||||
pub(crate) mod status;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2456
-417
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,319 @@
|
||||
//! The Places view of the Photos page: a grid of the towns the photos were
|
||||
//! taken in, and the place that opens when one is clicked.
|
||||
//!
|
||||
//! An open place works like an open album: it is parked in
|
||||
//! [`GalleryState::place`], and [`load_gallery`] pages
|
||||
//! [`Request::PlacePhotos`] instead of the timeline.
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Edge length in px of a place's cover, the same as an album's.
|
||||
const COVER_EDGE: i32 = 200;
|
||||
|
||||
/// How often the listing is asked for again while photo locations are read.
|
||||
const MAPPING_POLL: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Show the Places grid and (re)load it. Called when the Places toggle goes on.
|
||||
pub(crate) fn show_places(ui: &Rc<Ui>) {
|
||||
show_place_grid(ui);
|
||||
load_places(ui);
|
||||
}
|
||||
|
||||
/// Switch to the place grid as it was last filled, keeping its scroll
|
||||
/// position. The timeline's filters do not apply to places, so they go.
|
||||
pub(crate) fn show_place_grid(ui: &Rc<Ui>) {
|
||||
close_place(ui);
|
||||
close_album(ui);
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.content.set_visible_child_name("places");
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(ui.gallery.place_count.get()));
|
||||
}
|
||||
|
||||
/// Ask the daemon for the place listing and rebuild the grid.
|
||||
pub(crate) fn load_places(ui: &Rc<Ui>) {
|
||||
if ui.gallery.places_loading.get() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.places_loading.set(true);
|
||||
// A grid already on screen stays there while it refreshes.
|
||||
if ui.gallery.places_stack.visible_child_name().as_deref() != Some("grid") {
|
||||
places_status(
|
||||
ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("Loading places…"),
|
||||
&gettext("Finding where your photos were taken."),
|
||||
);
|
||||
}
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoPlaces);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.gallery.places_loading.set(false);
|
||||
let mapping = match &result {
|
||||
Ok(Ok(Response::Places { mapping, .. })) => *mapping,
|
||||
_ => None,
|
||||
};
|
||||
sync_mapping(&ui, mapping);
|
||||
match result {
|
||||
Ok(Ok(Response::Places { items, .. })) if items.is_empty() && mapping.is_some() => {
|
||||
ui.gallery.place_count.set(0);
|
||||
places_status(
|
||||
&ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("Mapping your photos…"),
|
||||
&gettext("Places show up here once the locations of your photos are read."),
|
||||
)
|
||||
}
|
||||
Ok(Ok(Response::Places { items, .. })) if items.is_empty() => {
|
||||
ui.gallery.place_count.set(0);
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery.title.set_subtitle("");
|
||||
}
|
||||
places_status(
|
||||
&ui,
|
||||
"mark-location-symbolic",
|
||||
&gettext("No places"),
|
||||
&gettext(
|
||||
"Photos that carry the location they were taken at show up here, grouped by town.",
|
||||
),
|
||||
)
|
||||
}
|
||||
Ok(Ok(Response::Places { items, .. })) => {
|
||||
fill_places(&ui, &items);
|
||||
ui.gallery.places_map.set_places(&items);
|
||||
show_places_layout(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &gettext("Couldn't load places"), &message, kind);
|
||||
places_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
&gettext("Couldn't load places"),
|
||||
&message,
|
||||
);
|
||||
}
|
||||
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => places_status(
|
||||
&ui,
|
||||
"network-offline-symbolic",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show how far the daemon has got reading photo locations, and ask again in a
|
||||
/// moment while it is still reading — the new places land once it is done.
|
||||
fn sync_mapping(ui: &Rc<Ui>, mapping: Option<MappingProgress>) {
|
||||
if let Some(source) = ui.gallery.places_poll.take() {
|
||||
source.remove();
|
||||
}
|
||||
ui.gallery.places_mapping.set_visible(mapping.is_some());
|
||||
let Some(MappingProgress { done, total }) = mapping else {
|
||||
return;
|
||||
};
|
||||
ui.gallery.places_mapping_label.set_label(&gettext_f(
|
||||
// Translators: {done} and {total} are numbers of photos.
|
||||
"Mapping photos: {done} of {total}",
|
||||
&[("done", &thousands(done)), ("total", &thousands(total))],
|
||||
));
|
||||
ui.gallery.places_mapping_bar.set_fraction(if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
done as f64 / total as f64
|
||||
});
|
||||
let ui_poll = ui.clone();
|
||||
let source = glib::timeout_add_local_once(MAPPING_POLL, move || {
|
||||
ui_poll.gallery.places_poll.take();
|
||||
let on_places = ui_poll.gallery.content.visible_child_name().as_deref() == Some("places");
|
||||
if on_places && ui_poll.gallery.place.borrow().is_none() {
|
||||
load_places(&ui_poll);
|
||||
}
|
||||
});
|
||||
ui.gallery.places_poll.replace(Some(source));
|
||||
}
|
||||
|
||||
/// Replace the grid's cards with `places`, the one with the most photos first.
|
||||
fn fill_places(ui: &Rc<Ui>, places: &[PlaceInfo]) {
|
||||
let scroll = ui
|
||||
.gallery
|
||||
.places
|
||||
.ancestor(gtk4::ScrolledWindow::static_type())
|
||||
.and_downcast::<gtk4::ScrolledWindow>();
|
||||
let position = scroll.as_ref().map(|s| s.vadjustment().value());
|
||||
while let Some(child) = ui.gallery.places.first_child() {
|
||||
ui.gallery.places.remove(&child);
|
||||
}
|
||||
ui.gallery.place_count.set(places.len());
|
||||
if ui.gallery.place.borrow().is_none() {
|
||||
ui.gallery
|
||||
.title
|
||||
.set_subtitle(&places_subtitle(places.len()));
|
||||
}
|
||||
for place in places {
|
||||
ui.gallery.places.append(&place_card(ui, place));
|
||||
}
|
||||
schedule_thumbs(ui);
|
||||
if let (Some(scroll), Some(position)) = (scroll, position) {
|
||||
glib::idle_add_local_once(move || scroll.vadjustment().set_value(position));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn places_subtitle(count: usize) -> String {
|
||||
ngettext_f("{n} place", "{n} places", count as u64, &[])
|
||||
}
|
||||
|
||||
/// "Germany · 12 photos": where the town is, and how much was taken there.
|
||||
pub(crate) fn place_subtitle(place: &PlaceInfo) -> String {
|
||||
ngettext_f(
|
||||
// Translators: {country} is a country name, {n} a number of photos.
|
||||
"{country} · {n} photo",
|
||||
"{country} · {n} photos",
|
||||
place.photo_count as u64,
|
||||
&[("country", &country_name(&place.country))],
|
||||
)
|
||||
}
|
||||
|
||||
/// One place as a clickable card: its newest photo, the town, and the country
|
||||
/// with the number of photos taken there.
|
||||
fn place_card(ui: &Rc<Ui>, place: &PlaceInfo) -> gtk4::Button {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
picture.add_css_class("photo-thumb");
|
||||
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("mark-location-symbolic")
|
||||
.pixel_size(32)
|
||||
.halign(gtk4::Align::Center)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
|
||||
let cover = gtk4::Overlay::new();
|
||||
cover.set_child(Some(&placeholder));
|
||||
cover.add_overlay(&picture);
|
||||
cover.set_size_request(COVER_EDGE, COVER_EDGE);
|
||||
cover.set_overflow(gtk4::Overflow::Hidden);
|
||||
cover.add_css_class("album-cover");
|
||||
|
||||
let name = gtk4::Label::builder()
|
||||
.label(&place.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
name.add_css_class("heading");
|
||||
|
||||
let subtitle = place_subtitle(place);
|
||||
let count = gtk4::Label::builder()
|
||||
.label(&subtitle)
|
||||
.halign(gtk4::Align::Start)
|
||||
.xalign(0.0)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
count.add_css_class("dim-label");
|
||||
count.add_css_class("caption");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
||||
card.append(&cover);
|
||||
card.append(&name);
|
||||
card.append(&count);
|
||||
|
||||
let button = gtk4::Button::builder().child(&card).build();
|
||||
button.add_css_class("flat");
|
||||
button.add_css_class("album-card");
|
||||
button.set_tooltip_text(Some(&format!("{}\n{subtitle}", place.name)));
|
||||
|
||||
want_cover(ui, place.cover.uid.clone(), &picture);
|
||||
|
||||
let ui_open = ui.clone();
|
||||
let place = place.clone();
|
||||
button.connect_clicked(move |_| open_place(&ui_open, place.clone()));
|
||||
button
|
||||
}
|
||||
|
||||
/// Open one place in the gallery: the timeline view, paged from that town.
|
||||
fn open_place(ui: &Rc<Ui>, place: PlaceInfo) {
|
||||
ui.gallery.title.set_title(&place.name);
|
||||
ui.gallery.title.set_subtitle(&place_subtitle(&place));
|
||||
*ui.gallery.place.borrow_mut() = Some(place);
|
||||
ui.gallery.timeline_stale.set(true);
|
||||
|
||||
// As in an album: no filters, no Upload, no view switch — back leads out.
|
||||
ui.gallery.filters.set_visible(false);
|
||||
ui.gallery.view_switch.set_visible(false);
|
||||
ui.gallery.upload.set_visible(false);
|
||||
ui.gallery.back.set_visible(true);
|
||||
ui.gallery.kind.set(None);
|
||||
ui.gallery.range.set(None);
|
||||
|
||||
ui.gallery.content.set_visible_child_name("timeline");
|
||||
load_gallery(ui, false);
|
||||
}
|
||||
|
||||
/// Leave an open place, restoring the timeline's header. A no-op when no place
|
||||
/// is open.
|
||||
pub(crate) fn close_place(ui: &Rc<Ui>) {
|
||||
if ui.gallery.place.borrow_mut().take().is_none() {
|
||||
return;
|
||||
}
|
||||
ui.gallery.title.set_title(&gettext("Photos"));
|
||||
ui.gallery.view_switch.set_visible(true);
|
||||
ui.gallery.upload.set_visible(true);
|
||||
ui.gallery.back.set_visible(false);
|
||||
}
|
||||
|
||||
/// Show the place grid's own status page (loading, empty, or an error).
|
||||
fn places_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str) {
|
||||
ui.gallery.places_status.set_icon_name(Some(icon));
|
||||
ui.gallery.places_status.set_title(title);
|
||||
ui.gallery.places_status.set_description(Some(description));
|
||||
ui.gallery.places_stack.set_visible_child_name("status");
|
||||
}
|
||||
|
||||
/// Wire the Places toggle of the view switcher.
|
||||
pub(crate) fn wire_places(ui: &Rc<Ui>) {
|
||||
let ui_places = ui.clone();
|
||||
ui.gallery.places_btn.clone().connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
show_places(&ui_places);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_layout = ui.clone();
|
||||
ui.gallery.places_map_btn.connect_toggled(move |_| {
|
||||
// A status page (loading, empty, an error) stays until a listing
|
||||
// replaces it; only the grid and the map swap.
|
||||
if ui_layout
|
||||
.gallery
|
||||
.places_stack
|
||||
.visible_child_name()
|
||||
.as_deref()
|
||||
!= Some("status")
|
||||
{
|
||||
show_places_layout(&ui_layout);
|
||||
}
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
ui.gallery
|
||||
.places_map
|
||||
.connect_open(move |place| open_place(&ui_open, place));
|
||||
}
|
||||
|
||||
/// Show the places as cards or on the map, as the Grid/Map toggle says.
|
||||
fn show_places_layout(ui: &Rc<Ui>) {
|
||||
let layout = if ui.gallery.places_map_btn.is_active() {
|
||||
"map"
|
||||
} else {
|
||||
"grid"
|
||||
};
|
||||
ui.gallery.places_stack.set_visible_child_name(layout);
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//! The map of the Places view: the towns the photos were taken in, drawn as
|
||||
//! bubbles over an outline of the world's land.
|
||||
//!
|
||||
//! The map is drawn here rather than loaded from a tile server: no tile source
|
||||
//! with terms that allow an app like this one to use it is free, and a town
|
||||
//! bubble needs no more detail than coastlines. The land is Natural Earth's
|
||||
//! 1:50m land polygons (public domain), trimmed into `data/land.txt` by
|
||||
//! `scripts/natural-earth-land.py`.
|
||||
//!
|
||||
//! The projection is Web Mercator, the one every other map uses, so the
|
||||
//! world looks the way people expect. Towns that would overlap on screen merge
|
||||
//! into one bubble counting all their photos; clicking it zooms in until they
|
||||
//! part, clicking a single town opens it.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::*;
|
||||
|
||||
const LAND: &str = include_str!("../../../data/land.txt");
|
||||
|
||||
/// Latitude past which Mercator is cut off, as on every web map.
|
||||
const MAX_LATITUDE: f64 = 85.0;
|
||||
|
||||
/// How far in the map zooms, as a multiple of the whole world fitting the
|
||||
/// widget's width.
|
||||
const MAX_ZOOM: f64 = 2048.0;
|
||||
|
||||
/// How close "show all" zooms onto a single town, the same way.
|
||||
const FIT_MAX_ZOOM: f64 = 64.0;
|
||||
|
||||
/// The zoom step of one scroll notch and of the zoom buttons.
|
||||
const ZOOM_STEP: f64 = 1.25;
|
||||
const BUTTON_ZOOM_STEP: f64 = 2.0;
|
||||
|
||||
/// A press that moves less than this many px is a click, not a drag.
|
||||
const CLICK_SLOP: f64 = 4.0;
|
||||
|
||||
/// Coastline points closer than this many px to the last one drawn are
|
||||
/// skipped: at world zoom most of the outline would fall onto one pixel.
|
||||
const MIN_SEGMENT: f64 = 0.75;
|
||||
|
||||
/// A point in projected world space: `x` and `y` both run 0..1, from the
|
||||
/// antimeridian eastwards and from the north edge southwards.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct World {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
fn project(latitude: f64, longitude: f64) -> World {
|
||||
let lat = latitude.clamp(-MAX_LATITUDE, MAX_LATITUDE).to_radians();
|
||||
World {
|
||||
x: (longitude + 180.0) / 360.0,
|
||||
y: (1.0 - (lat.tan() + 1.0 / lat.cos()).ln() / std::f64::consts::PI) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// One land outline, projected, with its bounding box for culling.
|
||||
struct Ring {
|
||||
points: Vec<World>,
|
||||
min: World,
|
||||
max: World,
|
||||
}
|
||||
|
||||
fn land() -> &'static [Ring] {
|
||||
static LAND_RINGS: OnceLock<Vec<Ring>> = OnceLock::new();
|
||||
LAND_RINGS.get_or_init(|| LAND.lines().filter_map(parse_ring).collect())
|
||||
}
|
||||
|
||||
fn parse_ring(line: &str) -> Option<Ring> {
|
||||
let points: Vec<World> = line
|
||||
.split(' ')
|
||||
.filter_map(|pair| {
|
||||
let (lon, lat) = pair.split_once(',')?;
|
||||
Some(project(lat.parse().ok()?, lon.parse().ok()?))
|
||||
})
|
||||
.collect();
|
||||
let first = *points.first()?;
|
||||
let (min, max) = points.iter().fold((first, first), |(min, max), p| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
Some(Ring { points, min, max })
|
||||
}
|
||||
|
||||
/// Radius in px of a bubble holding `photos` photos: grows with the count,
|
||||
/// but slowly, so a home town with thousands doesn't cover a continent.
|
||||
fn bubble_radius(photos: usize) -> f64 {
|
||||
7.0 + 3.0 * (photos.max(1) as f64).ln()
|
||||
}
|
||||
|
||||
/// What the map shows: which part of the world, at what size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct View {
|
||||
/// The world point at the middle of the widget.
|
||||
center: World,
|
||||
/// px per world unit: the whole world is this wide on screen.
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
/// One bubble as last drawn: where, how big, and the places it stands for
|
||||
/// (indices into [`MapState::places`], the one with the most photos first).
|
||||
struct Bubble {
|
||||
x: f64,
|
||||
y: f64,
|
||||
radius: f64,
|
||||
photos: usize,
|
||||
members: Vec<usize>,
|
||||
}
|
||||
|
||||
struct MapState {
|
||||
places: Vec<(PlaceInfo, World)>,
|
||||
view: Cell<View>,
|
||||
/// False until the view has been fitted to the places at a real size, and
|
||||
/// again whenever new places arrive before the user moved the map.
|
||||
fitted: Cell<bool>,
|
||||
moved: Cell<bool>,
|
||||
bubbles: RefCell<Vec<Bubble>>,
|
||||
pointer: Cell<(f64, f64)>,
|
||||
/// The view a drag started from.
|
||||
drag_origin: Cell<Option<View>>,
|
||||
/// The scale a pinch started from.
|
||||
pinch_origin: Cell<Option<View>>,
|
||||
}
|
||||
|
||||
/// What clicking a town on the map does.
|
||||
type OpenPlace = Box<dyn Fn(PlaceInfo)>;
|
||||
|
||||
/// The Places map widget.
|
||||
pub(crate) struct PlacesMap {
|
||||
root: gtk4::Overlay,
|
||||
area: gtk4::DrawingArea,
|
||||
state: RefCell<MapState>,
|
||||
on_open: RefCell<Option<OpenPlace>>,
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
pub(crate) fn new() -> Rc<Self> {
|
||||
let area = gtk4::DrawingArea::builder()
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.has_tooltip(true)
|
||||
.build();
|
||||
area.add_css_class("places-map");
|
||||
|
||||
let zoom_in = map_button("zoom-in-symbolic", &gettext("Zoom In"));
|
||||
let zoom_out = map_button("zoom-out-symbolic", &gettext("Zoom Out"));
|
||||
let fit = map_button("zoom-fit-best-symbolic", &gettext("Show All Places"));
|
||||
let zoom = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
zoom.add_css_class("linked");
|
||||
zoom.append(&zoom_in);
|
||||
zoom.append(&zoom_out);
|
||||
let buttons = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
buttons.set_halign(gtk4::Align::End);
|
||||
buttons.set_valign(gtk4::Align::Start);
|
||||
buttons.set_margin_top(12);
|
||||
buttons.set_margin_end(12);
|
||||
buttons.append(&zoom);
|
||||
buttons.append(&fit);
|
||||
|
||||
let root = gtk4::Overlay::new();
|
||||
root.set_child(Some(&area));
|
||||
root.add_overlay(&buttons);
|
||||
root.set_overflow(gtk4::Overflow::Hidden);
|
||||
root.add_css_class("places-map-frame");
|
||||
|
||||
let map = Rc::new(Self {
|
||||
root,
|
||||
area,
|
||||
state: RefCell::new(MapState {
|
||||
places: Vec::new(),
|
||||
view: Cell::new(View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: 1.0,
|
||||
}),
|
||||
fitted: Cell::new(false),
|
||||
moved: Cell::new(false),
|
||||
bubbles: RefCell::new(Vec::new()),
|
||||
pointer: Cell::new((0.0, 0.0)),
|
||||
drag_origin: Cell::new(None),
|
||||
pinch_origin: Cell::new(None),
|
||||
}),
|
||||
on_open: RefCell::new(None),
|
||||
});
|
||||
|
||||
let weak = Rc::downgrade(&map);
|
||||
map.area.set_draw_func(move |area, cr, width, height| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.draw(area, cr, f64::from(width), f64::from(height));
|
||||
}
|
||||
});
|
||||
map.wire(&zoom_in, &zoom_out, &fit);
|
||||
map
|
||||
}
|
||||
|
||||
pub(crate) fn widget(&self) -> >k4::Overlay {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// What clicking a town does.
|
||||
pub(crate) fn connect_open(&self, f: impl Fn(PlaceInfo) + 'static) {
|
||||
self.on_open.replace(Some(Box::new(f)));
|
||||
}
|
||||
|
||||
/// Show `places`. The view is fitted to them unless the user has already
|
||||
/// moved the map, so a refresh while they look around leaves it be.
|
||||
pub(crate) fn set_places(&self, places: &[PlaceInfo]) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.places = places
|
||||
.iter()
|
||||
.filter(|p| p.latitude != 0.0 || p.longitude != 0.0)
|
||||
.map(|p| (p.clone(), project(p.latitude, p.longitude)))
|
||||
.collect();
|
||||
if !state.moved.get() {
|
||||
state.fitted.set(false);
|
||||
}
|
||||
drop(state);
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
fn wire(self: &Rc<Self>, zoom_in: >k4::Button, zoom_out: >k4::Button, fit: >k4::Button) {
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_in.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
zoom_out.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.zoom_at_middle(1.0 / BUTTON_ZOOM_STEP);
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
fit.connect_clicked(move |_| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.moved.set(false);
|
||||
state.fitted.set(false);
|
||||
drop(state);
|
||||
map.area.queue_draw();
|
||||
}
|
||||
});
|
||||
|
||||
let motion = gtk4::EventControllerMotion::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
motion.connect_motion(move |_, x, y| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
map.state.borrow().pointer.set((x, y));
|
||||
let over = map.bubble_at(x, y).is_some();
|
||||
map.area
|
||||
.set_cursor_from_name(Some(if over { "pointer" } else { "default" }));
|
||||
}
|
||||
});
|
||||
self.area.add_controller(motion);
|
||||
|
||||
let scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL);
|
||||
let weak = Rc::downgrade(self);
|
||||
scroll.connect_scroll(move |_, _, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return glib::Propagation::Proceed;
|
||||
};
|
||||
let (x, y) = map.state.borrow().pointer.get();
|
||||
map.zoom_at(x, y, ZOOM_STEP.powf(-dy));
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
self.area.add_controller(scroll);
|
||||
|
||||
// One drag gesture for both panning and clicking: a press that barely
|
||||
// moves is a click on whatever bubble it started on.
|
||||
let drag = gtk4::GestureDrag::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_begin(move |_, _, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.drag_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_update(move |_, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if dx.hypot(dy) < CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
let state = map.state.borrow();
|
||||
let Some(origin) = state.drag_origin.get() else {
|
||||
return;
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: origin.center.x - dx / origin.scale,
|
||||
y: origin.center.y - dy / origin.scale,
|
||||
},
|
||||
..origin
|
||||
});
|
||||
drop(state);
|
||||
map.clamp_view();
|
||||
map.area.queue_draw();
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
drag.connect_drag_end(move |gesture, dx, dy| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
map.state.borrow().drag_origin.set(None);
|
||||
if dx.hypot(dy) >= CLICK_SLOP {
|
||||
return;
|
||||
}
|
||||
if let Some((x, y)) = gesture.start_point() {
|
||||
map.click(x, y);
|
||||
}
|
||||
});
|
||||
self.area.add_controller(drag);
|
||||
|
||||
let pinch = gtk4::GestureZoom::new();
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_begin(move |_, _| {
|
||||
if let Some(map) = weak.upgrade() {
|
||||
let state = map.state.borrow();
|
||||
state.pinch_origin.set(Some(state.view.get()));
|
||||
}
|
||||
});
|
||||
let weak = Rc::downgrade(self);
|
||||
pinch.connect_scale_changed(move |gesture, scale| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let Some(origin) = map.state.borrow().pinch_origin.get() else {
|
||||
return;
|
||||
};
|
||||
let (x, y) = gesture
|
||||
.bounding_box_center()
|
||||
.unwrap_or_else(|| map.middle());
|
||||
let now = map.state.borrow().view.get().scale;
|
||||
map.zoom_at(x, y, origin.scale * scale / now);
|
||||
});
|
||||
self.area.add_controller(pinch);
|
||||
|
||||
let weak = Rc::downgrade(self);
|
||||
self.area
|
||||
.connect_query_tooltip(move |_, x, y, _keyboard, tooltip| {
|
||||
let Some(map) = weak.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
let Some(text) = map.tooltip_at(f64::from(x), f64::from(y)) else {
|
||||
return false;
|
||||
};
|
||||
tooltip.set_text(Some(&text));
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn middle(&self) -> (f64, f64) {
|
||||
(
|
||||
f64::from(self.area.width()) / 2.0,
|
||||
f64::from(self.area.height()) / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// The whole world's width at the least zoom: the widget's own width.
|
||||
fn min_scale(&self) -> f64 {
|
||||
f64::from(self.area.width().max(self.area.height()).max(1))
|
||||
}
|
||||
|
||||
fn zoom_at_middle(&self, factor: f64) {
|
||||
let (x, y) = self.middle();
|
||||
self.zoom_at(x, y, factor);
|
||||
}
|
||||
|
||||
/// Zoom by `factor`, keeping the world point under `(x, y)` where it is.
|
||||
fn zoom_at(&self, x: f64, y: f64, factor: f64) {
|
||||
let min = self.min_scale();
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
let scale = (view.scale * factor).clamp(min, min * MAX_ZOOM);
|
||||
let under = World {
|
||||
x: view.center.x + (x - width / 2.0) / view.scale,
|
||||
y: view.center.y + (y - height / 2.0) / view.scale,
|
||||
};
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: World {
|
||||
x: under.x - (x - width / 2.0) / scale,
|
||||
y: under.y - (y - height / 2.0) / scale,
|
||||
},
|
||||
scale,
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// Keep the world on screen: the map can't be dragged off into nothing.
|
||||
fn clamp_view(&self) {
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let state = self.state.borrow();
|
||||
let mut view = state.view.get();
|
||||
view.center.x = clamp_axis(view.center.x, width / 2.0 / view.scale);
|
||||
view.center.y = clamp_axis(view.center.y, height / 2.0 / view.scale);
|
||||
state.view.set(view);
|
||||
}
|
||||
|
||||
/// Frame every place, or the whole world when there are none.
|
||||
fn fit(&self, width: f64, height: f64) {
|
||||
let min = self.min_scale();
|
||||
let state = self.state.borrow();
|
||||
let view = match fit_view(&state.places, width, height) {
|
||||
Some(view) => View {
|
||||
scale: view.scale.clamp(min, min * FIT_MAX_ZOOM),
|
||||
..view
|
||||
},
|
||||
None => View {
|
||||
center: World { x: 0.5, y: 0.5 },
|
||||
scale: min,
|
||||
},
|
||||
};
|
||||
state.view.set(view);
|
||||
state.fitted.set(true);
|
||||
drop(state);
|
||||
self.clamp_view();
|
||||
}
|
||||
|
||||
fn draw(&self, area: >k4::DrawingArea, cr: >k4::cairo::Context, width: f64, height: f64) {
|
||||
if width < 1.0 || height < 1.0 {
|
||||
return;
|
||||
}
|
||||
if !self.state.borrow().fitted.get() {
|
||||
self.fit(width, height);
|
||||
}
|
||||
let state = self.state.borrow();
|
||||
// Land in the text colour at low alpha, so it reads in light and dark
|
||||
// alike; bubbles in the accent colour the stylesheet gives the area.
|
||||
let text = area.parent().map_or_else(|| area.color(), |p| p.color());
|
||||
let accent = area.color();
|
||||
let bubbles = paint(
|
||||
cr,
|
||||
state.view.get(),
|
||||
(width, height),
|
||||
&state.places,
|
||||
(&text, &accent),
|
||||
);
|
||||
state.bubbles.replace(bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the land and the bubbles for `places` as seen through `view`, and
|
||||
/// hand back the bubbles for hit-testing.
|
||||
fn paint(
|
||||
cr: >k4::cairo::Context,
|
||||
view: View,
|
||||
(width, height): (f64, f64),
|
||||
places: &[(PlaceInfo, World)],
|
||||
(text, accent): (>k4::gdk::RGBA, >k4::gdk::RGBA),
|
||||
) -> Vec<Bubble> {
|
||||
let to_screen = |p: World| {
|
||||
(
|
||||
(p.x - view.center.x) * view.scale + width / 2.0,
|
||||
(p.y - view.center.y) * view.scale + height / 2.0,
|
||||
)
|
||||
};
|
||||
let min = World {
|
||||
x: view.center.x - width / 2.0 / view.scale,
|
||||
y: view.center.y - height / 2.0 / view.scale,
|
||||
};
|
||||
let max = World {
|
||||
x: view.center.x + width / 2.0 / view.scale,
|
||||
y: view.center.y + height / 2.0 / view.scale,
|
||||
};
|
||||
for ring in land() {
|
||||
if ring.max.x < min.x || ring.min.x > max.x || ring.max.y < min.y || ring.min.y > max.y {
|
||||
continue;
|
||||
}
|
||||
let mut last: Option<(f64, f64)> = None;
|
||||
for &point in &ring.points {
|
||||
let (x, y) = to_screen(point);
|
||||
match last {
|
||||
None => cr.move_to(x, y),
|
||||
Some((lx, ly)) if (x - lx).hypot(y - ly) < MIN_SEGMENT => continue,
|
||||
Some(_) => cr.line_to(x, y),
|
||||
}
|
||||
last = Some((x, y));
|
||||
}
|
||||
cr.close_path();
|
||||
}
|
||||
set_color(cr, text, 0.12);
|
||||
let _ = cr.fill_preserve();
|
||||
set_color(cr, text, 0.25);
|
||||
cr.set_line_width(0.75);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let screen: Vec<(f64, f64)> = places.iter().map(|(_, p)| to_screen(*p)).collect();
|
||||
let counts: Vec<usize> = places.iter().map(|(p, _)| p.photo_count).collect();
|
||||
let bubbles = cluster(&screen, &counts, width, height);
|
||||
|
||||
// Cairo's own text is enough for a few digits, and needs no layout.
|
||||
cr.select_font_face(
|
||||
"Sans",
|
||||
gtk4::cairo::FontSlant::Normal,
|
||||
gtk4::cairo::FontWeight::Bold,
|
||||
);
|
||||
for bubble in &bubbles {
|
||||
// The last label left a current point; an arc would line up from it.
|
||||
cr.new_path();
|
||||
cr.arc(
|
||||
bubble.x,
|
||||
bubble.y,
|
||||
bubble.radius,
|
||||
0.0,
|
||||
std::f64::consts::TAU,
|
||||
);
|
||||
set_color(cr, accent, 0.85);
|
||||
let _ = cr.fill_preserve();
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
|
||||
cr.set_line_width(1.5);
|
||||
let _ = cr.stroke();
|
||||
|
||||
let label = compact_count(bubble.photos);
|
||||
cr.set_font_size(bubble.radius.clamp(7.0, 14.0));
|
||||
let Ok(extents) = cr.text_extents(&label) else {
|
||||
continue;
|
||||
};
|
||||
if extents.width() > bubble.radius * 1.8 {
|
||||
continue;
|
||||
}
|
||||
cr.move_to(
|
||||
bubble.x - extents.width() / 2.0 - extents.x_bearing(),
|
||||
bubble.y - extents.height() / 2.0 - extents.y_bearing(),
|
||||
);
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 1.0);
|
||||
let _ = cr.show_text(&label);
|
||||
}
|
||||
bubbles
|
||||
}
|
||||
|
||||
impl PlacesMap {
|
||||
/// The bubble under `(x, y)`, the topmost (last drawn) first.
|
||||
fn bubble_at(&self, x: f64, y: f64) -> Option<Vec<usize>> {
|
||||
let state = self.state.borrow();
|
||||
let bubbles = state.bubbles.borrow();
|
||||
bubbles
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) <= b.radius)
|
||||
.map(|b| b.members.clone())
|
||||
}
|
||||
|
||||
fn click(&self, x: f64, y: f64) {
|
||||
let Some(members) = self.bubble_at(x, y) else {
|
||||
return;
|
||||
};
|
||||
if let [only] = members[..] {
|
||||
let place = self.state.borrow().places[only].0.clone();
|
||||
if let Some(open) = self.on_open.borrow().as_ref() {
|
||||
open(place);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A merged bubble zooms in on its towns until they part.
|
||||
let (width, height) = (f64::from(self.area.width()), f64::from(self.area.height()));
|
||||
let target = {
|
||||
let state = self.state.borrow();
|
||||
let chosen: Vec<(PlaceInfo, World)> =
|
||||
members.iter().map(|&i| state.places[i].clone()).collect();
|
||||
fit_view(&chosen, width, height)
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return;
|
||||
};
|
||||
let min = self.min_scale();
|
||||
{
|
||||
let state = self.state.borrow();
|
||||
let view = state.view.get();
|
||||
state.moved.set(true);
|
||||
state.view.set(View {
|
||||
center: target.center,
|
||||
// At least one real step in, even when the towns sit on top of
|
||||
// each other at every zoom the fit would pick.
|
||||
scale: target
|
||||
.scale
|
||||
.max(view.scale * BUTTON_ZOOM_STEP)
|
||||
.clamp(min, min * MAX_ZOOM),
|
||||
});
|
||||
}
|
||||
self.clamp_view();
|
||||
self.area.queue_draw();
|
||||
}
|
||||
|
||||
/// "Berlin" over "Germany · 12 photos" for a town, or the number of places
|
||||
/// over the first few of their names for a merged bubble.
|
||||
fn tooltip_at(&self, x: f64, y: f64) -> Option<String> {
|
||||
let members = self.bubble_at(x, y)?;
|
||||
let state = self.state.borrow();
|
||||
if let [only] = members[..] {
|
||||
let place = &state.places[only].0;
|
||||
return Some(format!("{}\n{}", place.name, place_subtitle(place)));
|
||||
}
|
||||
let mut lines = vec![places_subtitle(members.len())];
|
||||
lines.extend(
|
||||
members
|
||||
.iter()
|
||||
.take(TOOLTIP_NAMES)
|
||||
.map(|&i| state.places[i].0.name.clone()),
|
||||
);
|
||||
if members.len() > TOOLTIP_NAMES {
|
||||
lines.push("…".to_owned());
|
||||
}
|
||||
Some(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many town names a merged bubble's tooltip lists.
|
||||
const TOOLTIP_NAMES: usize = 5;
|
||||
|
||||
fn map_button(icon: &str, tooltip: &str) -> gtk4::Button {
|
||||
let button = gtk4::Button::from_icon_name(icon);
|
||||
button.set_tooltip_text(Some(tooltip));
|
||||
button.add_css_class("osd");
|
||||
button
|
||||
}
|
||||
|
||||
/// Keep `center` so that half a view, `half`, either side stays inside 0..1 —
|
||||
/// or centred, when the view is wider than the world.
|
||||
fn clamp_axis(center: f64, half: f64) -> f64 {
|
||||
if half * 2.0 >= 1.0 {
|
||||
0.5
|
||||
} else {
|
||||
center.clamp(half, 1.0 - half)
|
||||
}
|
||||
}
|
||||
|
||||
/// The view framing `places` in a `width` × `height` widget, with room for
|
||||
/// the bubbles at the edges. `None` when there is nothing to frame.
|
||||
fn fit_view(places: &[(PlaceInfo, World)], width: f64, height: f64) -> Option<View> {
|
||||
let first = places.first()?.1;
|
||||
let (min, max) = places.iter().fold((first, first), |(min, max), (_, p)| {
|
||||
(
|
||||
World {
|
||||
x: min.x.min(p.x),
|
||||
y: min.y.min(p.y),
|
||||
},
|
||||
World {
|
||||
x: max.x.max(p.x),
|
||||
y: max.y.max(p.y),
|
||||
},
|
||||
)
|
||||
});
|
||||
// A margin of the largest bubble on every side, plus the zoom buttons'
|
||||
// column on the right.
|
||||
let margin = 2.0 * bubble_radius(places.iter().map(|(p, _)| p.photo_count).max()?) + 48.0;
|
||||
let span_x = (max.x - min.x).max(1e-9);
|
||||
let span_y = (max.y - min.y).max(1e-9);
|
||||
let scale =
|
||||
((width - 2.0 * margin).max(1.0) / span_x).min((height - 2.0 * margin).max(1.0) / span_y);
|
||||
Some(View {
|
||||
center: World {
|
||||
x: (min.x + max.x) / 2.0,
|
||||
y: (min.y + max.y) / 2.0,
|
||||
},
|
||||
scale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Merge places whose bubbles would overlap on screen, the one with the most
|
||||
/// photos claiming its neighbours. Places off screen are left out.
|
||||
fn cluster(screen: &[(f64, f64)], counts: &[usize], width: f64, height: f64) -> Vec<Bubble> {
|
||||
let mut order: Vec<usize> = (0..screen.len()).collect();
|
||||
order.sort_by(|a, b| counts[*b].cmp(&counts[*a]));
|
||||
let mut bubbles: Vec<Bubble> = Vec::new();
|
||||
for index in order {
|
||||
let (x, y) = screen[index];
|
||||
let reach = bubble_radius(counts[index]);
|
||||
if x < -reach || y < -reach || x > width + reach || y > height + reach {
|
||||
continue;
|
||||
}
|
||||
match bubbles
|
||||
.iter_mut()
|
||||
.find(|b| (b.x - x).hypot(b.y - y) < b.radius + reach * 0.5)
|
||||
{
|
||||
Some(bubble) => {
|
||||
bubble.photos += counts[index];
|
||||
bubble.radius = bubble_radius(bubble.photos);
|
||||
bubble.members.push(index);
|
||||
}
|
||||
None => bubbles.push(Bubble {
|
||||
x,
|
||||
y,
|
||||
radius: reach,
|
||||
photos: counts[index],
|
||||
members: vec![index],
|
||||
}),
|
||||
}
|
||||
}
|
||||
// Smallest last, so a small bubble next to a big one stays clickable.
|
||||
bubbles.sort_by(|a, b| b.radius.total_cmp(&a.radius));
|
||||
bubbles
|
||||
}
|
||||
|
||||
/// "7", "840", "1.2k", "35k": short enough to fit inside a bubble. Digits
|
||||
/// and the "k" read the same in every language the app ships.
|
||||
fn compact_count(n: usize) -> String {
|
||||
match n {
|
||||
0..1_000 => n.to_string(),
|
||||
1_000..10_000 => format!("{:.1}k", n as f64 / 1_000.0),
|
||||
_ => format!("{}k", n / 1_000),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_color(cr: >k4::cairo::Context, color: >k4::gdk::RGBA, alpha: f32) {
|
||||
cr.set_source_rgba(
|
||||
f64::from(color.red()),
|
||||
f64::from(color.green()),
|
||||
f64::from(color.blue()),
|
||||
f64::from(color.alpha() * alpha),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn projection_puts_the_equator_and_meridian_in_the_middle() {
|
||||
let p = project(0.0, 0.0);
|
||||
assert!((p.x - 0.5).abs() < 1e-9 && (p.y - 0.5).abs() < 1e-9);
|
||||
// North is up, east is right.
|
||||
let berlin = project(52.52, 13.40);
|
||||
assert!(berlin.x > 0.5 && berlin.y < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_land_ring_parses() {
|
||||
assert_eq!(land().len(), LAND.lines().count());
|
||||
assert!(land().iter().all(|ring| ring.points.len() >= 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_towns_merge_into_one_bubble() {
|
||||
let screen = [(100.0, 100.0), (104.0, 102.0), (400.0, 300.0)];
|
||||
let counts = [3, 10, 1];
|
||||
let bubbles = cluster(&screen, &counts, 800.0, 600.0);
|
||||
assert_eq!(bubbles.len(), 2);
|
||||
let merged = bubbles.iter().find(|b| b.members.len() == 2).unwrap();
|
||||
// The town with more photos claims the other and keeps its position.
|
||||
assert_eq!(merged.members, vec![1, 0]);
|
||||
assert_eq!((merged.x, merged.photos), (104.0, 13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn towns_off_screen_get_no_bubble() {
|
||||
let bubbles = cluster(&[(-500.0, 10.0)], &[1], 800.0, 600.0);
|
||||
assert!(bubbles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_shorten_to_fit_a_bubble() {
|
||||
assert_eq!(compact_count(840), "840");
|
||||
assert_eq!(compact_count(1_240), "1.2k");
|
||||
assert_eq!(compact_count(35_900), "35k");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ pub(crate) struct SharedState {
|
||||
pub(crate) with_me_group: adw::PreferencesGroup,
|
||||
pub(crate) invitations_group: adw::PreferencesGroup,
|
||||
pub(crate) bookmarks_group: adw::PreferencesGroup,
|
||||
/// The header title; its subtitle carries the path inside a shared folder.
|
||||
pub(crate) title: adw::WindowTitle,
|
||||
/// Header button that leaves the current shared folder.
|
||||
pub(crate) back: gtk4::Button,
|
||||
pub(crate) add_bookmark: gtk4::Button,
|
||||
/// Where in a shared folder the page currently is, as `(uid, name)` from the
|
||||
/// top level down. Empty = the top level. Shared subtrees have no path in the
|
||||
/// mount, so descending is uid-addressed and the stack *is* the breadcrumb.
|
||||
@@ -32,6 +37,8 @@ pub(crate) struct SharedWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) add_bookmark: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) title: adw::WindowTitle,
|
||||
pub(crate) back: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Shared page: three stacked sections — items shared *with* me (each with a
|
||||
@@ -39,38 +46,32 @@ pub(crate) struct SharedWidgets {
|
||||
/// links I've bookmarked (open / remove). All three live outside the mount tree,
|
||||
/// so the page addresses them by uid/id/token and always re-lists from the daemon.
|
||||
pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared with me")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
|
||||
let add_bookmark = gtk4::Button::builder()
|
||||
.label("Add Bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.icon_name("bookmark-new-symbolic")
|
||||
.tooltip_text(gettext(
|
||||
"Add Bookmark — save a public link to open it from here",
|
||||
))
|
||||
.build();
|
||||
add_bookmark.add_css_class("flat");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&add_bookmark);
|
||||
|
||||
let shared_with_me = adw::PreferencesGroup::builder()
|
||||
.title("Shared with me")
|
||||
.build();
|
||||
// Only sections with something in them are shown, so an account with one
|
||||
// share reads as one list rather than three boxes, two of them saying
|
||||
// "nothing here". Invitations come first: they are the one thing on the
|
||||
// page waiting for a decision.
|
||||
let invitations = adw::PreferencesGroup::builder()
|
||||
.title("Invitations")
|
||||
.title(gettext("Invitations"))
|
||||
.description(gettext("Accept to add the item to your shared files."))
|
||||
.visible(false)
|
||||
.build();
|
||||
let shared_with_me = adw::PreferencesGroup::new();
|
||||
let bookmarks = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Bookmarks"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&shared_with_me);
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
groups.append(&invitations);
|
||||
groups.append(&shared_with_me);
|
||||
groups.append(&bookmarks);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
@@ -79,7 +80,7 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -99,15 +100,24 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
|
||||
let back = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text(gettext("Back"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let (frame, header, title) = page_frame(&gettext("Shared with Me"), &inner);
|
||||
header.pack_start(&back);
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&add_bookmark);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
SharedWidgets {
|
||||
content,
|
||||
status,
|
||||
@@ -117,12 +127,19 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
||||
retry,
|
||||
add_bookmark,
|
||||
refresh,
|
||||
title,
|
||||
back,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Shared page's retry and Add-Bookmark buttons.
|
||||
/// Install the Shared page's retry, back and Add-Bookmark buttons.
|
||||
pub(crate) fn wire_shared(ui: &Rc<Ui>, retry: >k4::Button, add_bookmark: >k4::Button) {
|
||||
let ui_back = ui.clone();
|
||||
ui.shared.back.connect_clicked(move |_| {
|
||||
ui_back.shared.nav.borrow_mut().pop();
|
||||
load_shared(&ui_back);
|
||||
});
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| {
|
||||
service::restart();
|
||||
@@ -151,6 +168,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
if ui.shared.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
let current = ui.shared.nav.borrow().last().cloned();
|
||||
if let Some((uid, _)) = current {
|
||||
load_shared_folder(ui, uid);
|
||||
@@ -160,8 +178,8 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading your shared items.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your shared items."),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -213,8 +231,8 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
shared_status(
|
||||
ui,
|
||||
"folder-symbolic",
|
||||
"Loading…",
|
||||
"Reading this shared folder.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this shared folder."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -233,7 +251,7 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
// The folder is gone or access was revoked: fall back to the top
|
||||
// level rather than stranding the page on a dead uid.
|
||||
ui.shared.nav.borrow_mut().pop();
|
||||
toast_failure(&ui, "Couldn't open shared folder", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't open shared folder"), &message, kind);
|
||||
load_shared(&ui);
|
||||
}
|
||||
_ => {
|
||||
@@ -244,8 +262,27 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Paint the contents of the shared folder at the top of the nav stack: a row to
|
||||
/// go back up, then the children. Invitations and bookmarks are hidden here —
|
||||
/// The header subtitle inside a shared folder: the folders from the share root
|
||||
/// down, the way a breadcrumb reads.
|
||||
pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
|
||||
nav.iter()
|
||||
.map(|(_, name)| name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
/// The Invitations heading, with the count once there is more than one.
|
||||
pub(crate) fn invitations_title(count: usize) -> String {
|
||||
if count > 1 {
|
||||
// Translators: the Invitations heading; {count} is the number of pending invitations (2 or more).
|
||||
gettext_f("Invitations ({count})", &[("count", &count.to_string())])
|
||||
} else {
|
||||
gettext("Invitations")
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint the contents of the shared folder at the top of the nav stack; the
|
||||
/// header's back button leads up again. Invitations and bookmarks are hidden here —
|
||||
/// they are top-level things, not contents of this folder.
|
||||
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
@@ -254,36 +291,23 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.shared.content.set_visible_child_name("list");
|
||||
ui.shared.invitations_group.set_visible(false);
|
||||
ui.shared.bookmarks_group.set_visible(false);
|
||||
ui.shared.back.set_visible(true);
|
||||
ui.shared.with_me_group.set_visible(true);
|
||||
ui.shared.add_bookmark.set_visible(false);
|
||||
|
||||
let nav = ui.shared.nav.borrow().clone();
|
||||
let title = nav
|
||||
.iter()
|
||||
.map(|(_, name)| name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
ui.shared.with_me_group.set_title(&title);
|
||||
ui.shared.title.set_subtitle(&shared_path(&nav));
|
||||
ui.shared.with_me_group.set_title("");
|
||||
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
let up = adw::ActionRow::builder()
|
||||
.title("Back")
|
||||
.activatable(true)
|
||||
.build();
|
||||
up.add_prefix(>k4::Image::from_icon_name("go-up-symbolic"));
|
||||
let ui_up = ui.clone();
|
||||
up.connect_activated(move |_| {
|
||||
ui_up.shared.nav.borrow_mut().pop();
|
||||
load_shared(&ui_up);
|
||||
});
|
||||
ui.shared.with_me_group.add(&up);
|
||||
rows.push((ui.shared.with_me_group.clone(), up.upcast()));
|
||||
|
||||
if entries.is_empty() {
|
||||
let row = dim_row("This folder is empty.");
|
||||
let row = dim_row(&gettext("This folder is empty."));
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in entries {
|
||||
let row = shared_entry_row(ui, entry);
|
||||
let row = shared_entry_row(ui, entry, false);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
}
|
||||
@@ -293,7 +317,10 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
/// A row for one node shared with me: folders descend into, files download and
|
||||
/// open with the user's default application.
|
||||
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
///
|
||||
/// `leavable` rows are share roots: only those carry a Leave action, since
|
||||
/// leaving a folder's child on its own is not a thing the API offers.
|
||||
fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.name)
|
||||
.activatable(true)
|
||||
@@ -305,14 +332,25 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
// What I am allowed to do with someone else's file is the thing this page
|
||||
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||
// that is only visible if it is said.
|
||||
if let Some(badge) = role_badge(&entry.role) {
|
||||
row.add_suffix(&badge);
|
||||
if let Some(pill) = role_pill(&entry.role) {
|
||||
row.add_suffix(&pill);
|
||||
}
|
||||
// The "shared by" in the subtitle is what the invitation claims. When its
|
||||
// signature does not check out against that person's keys, the claim must
|
||||
// not read as fact — someone may be impersonating a contact to get a file
|
||||
// opened.
|
||||
if entry.shared_by_unverified {
|
||||
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||
warning.add_css_class("warning");
|
||||
warning.set_valign(gtk4::Align::Center);
|
||||
warning.set_tooltip_text(Some(&gettext(UNVERIFIED_INVITER_TOOLTIP)));
|
||||
row.add_suffix(&warning);
|
||||
}
|
||||
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||
row.add_suffix(&shared_entry_menu(ui, entry, leavable));
|
||||
if entry.is_dir {
|
||||
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||
}
|
||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let ui_act = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
@@ -332,18 +370,71 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row
|
||||
}
|
||||
|
||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
||||
/// pointing at a directory that is not there.
|
||||
/// A shared row's ⋮ menu: open it, find it in My Files when the mount has
|
||||
/// interned it, and leave it (share roots only).
|
||||
fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton {
|
||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
||||
let (ui_open, uid, name, is_dir) = (
|
||||
ui.clone(),
|
||||
entry.uid.clone(),
|
||||
entry.name.clone(),
|
||||
entry.is_dir,
|
||||
);
|
||||
items.push((
|
||||
pgettext("verb", "Open"),
|
||||
Box::new(move || {
|
||||
if is_dir {
|
||||
ui_open
|
||||
.shared
|
||||
.nav
|
||||
.borrow_mut()
|
||||
.push((uid.clone(), name.clone()));
|
||||
load_shared(&ui_open);
|
||||
} else {
|
||||
open_shared_file(&ui_open, &uid, &name);
|
||||
}
|
||||
}),
|
||||
));
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_show, entry_show) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Show in My Files"),
|
||||
Box::new(move || show_in_my_files(&ui_show, &entry_show)),
|
||||
));
|
||||
}
|
||||
if leavable {
|
||||
let (ui_leave, uid, name) = (ui.clone(), entry.uid.clone(), entry.name.clone());
|
||||
items.push((
|
||||
gettext("Leave…"),
|
||||
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
|
||||
));
|
||||
}
|
||||
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
|
||||
let button = more_menu_button(labels.iter().map(String::as_str).zip(actions).collect());
|
||||
button.set_valign(gtk4::Align::Center);
|
||||
button
|
||||
}
|
||||
|
||||
/// Tooltip on the warning shown next to a share whose invitation did not verify.
|
||||
const UNVERIFIED_INVITER_TOOLTIP: &str = gettext_noop(
|
||||
"The invitation's signature doesn't match the sender's keys. It may not really be from them.",
|
||||
);
|
||||
|
||||
/// A shared row's subtitle: who shared it and when (share roots only — that is
|
||||
/// where the invitation lives) and its size (files only). Where the share sits
|
||||
/// in the mount is one menu item away ("Show in My Files"), not spelled out on
|
||||
/// every row, where the long path wrapped the subtitle onto a second line.
|
||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !entry.shared_by.is_empty() {
|
||||
parts.push(entry.shared_by.clone());
|
||||
if entry.shared_at > 0 {
|
||||
parts.push(format_modified(entry.shared_at));
|
||||
}
|
||||
}
|
||||
if !entry.is_dir {
|
||||
parts.push(human_bytes(entry.size));
|
||||
}
|
||||
if !entry.path.is_empty() {
|
||||
parts.push(format!("in {}", entry.path));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
@@ -351,22 +442,34 @@ pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||
/// owned content carries no role, and a role the API did not report is not
|
||||
/// guessed at (the SDK's `from_permissions_exact` returns `None` for an
|
||||
/// unrecognised mask rather than degrading it to viewer).
|
||||
pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
||||
pub(crate) fn role_label(role: &str) -> Option<String> {
|
||||
match role {
|
||||
"viewer" => Some("Viewer"),
|
||||
"editor" => Some("Editor"),
|
||||
"admin" => Some("Admin"),
|
||||
"viewer" => Some(pgettext("role", "Viewer")),
|
||||
"editor" => Some(pgettext("role", "Editor")),
|
||||
"admin" => Some(pgettext("role", "Admin")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A dim role pill for a share I did not create.
|
||||
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
||||
let badge = gtk4::Label::new(Some(role_label(role)?));
|
||||
badge.add_css_class("dim-label");
|
||||
badge.add_css_class("caption");
|
||||
badge.set_valign(gtk4::Align::Center);
|
||||
Some(badge)
|
||||
/// What a role lets me do, as a short phrase for the row's pill. A bare
|
||||
/// "Editor" beside a Leave button read like one more button.
|
||||
pub(crate) fn role_access(role: &str) -> Option<String> {
|
||||
match role {
|
||||
"viewer" => Some(gettext("Can view")),
|
||||
"editor" => Some(gettext("Can edit")),
|
||||
"admin" => Some(gettext("Can manage")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A tinted, rounded pill saying what I may do with a share I did not create.
|
||||
pub(crate) fn role_pill(role: &str) -> Option<gtk4::Label> {
|
||||
let pill = gtk4::Label::new(Some(&role_access(role)?));
|
||||
pill.add_css_class("role-pill");
|
||||
pill.add_css_class("caption");
|
||||
pill.set_valign(gtk4::Align::Center);
|
||||
pill.set_tooltip_text(role_label(role).as_deref());
|
||||
Some(pill)
|
||||
}
|
||||
|
||||
/// Download a file shared with me into the daemon's cache and hand it to the
|
||||
@@ -379,7 +482,8 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
return;
|
||||
}
|
||||
ui.busy_begin();
|
||||
toast(ui, &format!("Downloading “{name}”…"));
|
||||
// Translators: {name} is a file name.
|
||||
toast(ui, &gettext_f("Downloading “{name}”…", &[("name", name)]));
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::OpenSharedFile {
|
||||
@@ -398,12 +502,12 @@ fn open_shared_file(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
// the shared node's name instead.
|
||||
Ok(Ok(Response::FilePath { path })) => open_named_path(&path, &name),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't open file", &message, kind)
|
||||
toast_failure(&ui, &gettext("Couldn't open file"), &message, kind)
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't open file",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't open file"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -416,8 +520,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -425,8 +529,8 @@ pub(crate) fn shared_unreachable(ui: &Rc<Ui>) {
|
||||
shared_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -449,119 +553,130 @@ pub(crate) fn repaint_shared(
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
group.remove(&row);
|
||||
}
|
||||
ui.shared.title.set_subtitle("");
|
||||
ui.shared.back.set_visible(false);
|
||||
ui.shared.add_bookmark.set_visible(true);
|
||||
if shared.is_empty() && invitations.is_empty() && bookmarks.is_empty() {
|
||||
shared_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
&gettext("Nothing Shared with You"),
|
||||
&gettext(
|
||||
"Files and folders other people share with you appear here. Public links you bookmark are kept here too.",
|
||||
),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.shared.content.set_visible_child_name("list");
|
||||
ui.shared.invitations_group.set_visible(true);
|
||||
ui.shared.bookmarks_group.set_visible(true);
|
||||
ui.shared.with_me_group.set_title("Shared with me");
|
||||
ui.shared
|
||||
.invitations_group
|
||||
.set_visible(!invitations.is_empty());
|
||||
ui.shared.bookmarks_group.set_visible(!bookmarks.is_empty());
|
||||
ui.shared.with_me_group.set_visible(!shared.is_empty());
|
||||
// The list only needs a heading when another section sits beside it.
|
||||
ui.shared
|
||||
.with_me_group
|
||||
.set_title(&if invitations.is_empty() && bookmarks.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
gettext("Shared with you")
|
||||
});
|
||||
ui.shared
|
||||
.invitations_group
|
||||
.set_title(&invitations_title(invitations.len()));
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
|
||||
// Shared with me: name + Leave.
|
||||
if shared.is_empty() {
|
||||
let row = dim_row("Nothing is shared with you.");
|
||||
for entry in shared {
|
||||
let row = shared_entry_row(ui, entry, true);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for entry in shared {
|
||||
let row = shared_entry_row(ui, entry);
|
||||
let leave = gtk4::Button::builder()
|
||||
.label("Leave")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
leave.add_css_class("flat");
|
||||
let ui_leave = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name));
|
||||
row.add_suffix(&leave);
|
||||
ui.shared.with_me_group.add(&row);
|
||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Invitations: inviter + item, Accept / Reject.
|
||||
if invitations.is_empty() {
|
||||
let row = dim_row("No pending invitations.");
|
||||
for inv in invitations {
|
||||
let item = inv.name.clone().unwrap_or_else(|| gettext("a shared item"));
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item)
|
||||
// Translators: {email} is the address of the person who sent the invitation.
|
||||
.subtitle(gettext_f("From {email}", &[("email", &inv.inviter_email)]))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text(gettext("Reject"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label(gettext("Accept"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
accept.add_css_class("pill");
|
||||
let ui_acc = ui.clone();
|
||||
let id_acc = inv.id.clone();
|
||||
accept.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_acc, &id_acc, true);
|
||||
});
|
||||
let ui_rej = ui.clone();
|
||||
let id_rej = inv.id.clone();
|
||||
let name_rej = inv.name.clone().unwrap_or_else(|| gettext("this item"));
|
||||
reject.connect_clicked(move |btn| {
|
||||
let ui = ui_rej.clone();
|
||||
let id = id_rej.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
&gettext("Reject Invitation?"),
|
||||
// Translators: {name} is the shared item's name, or "this item".
|
||||
&gettext_f(
|
||||
"You won't have access to {name} unless it is shared again.",
|
||||
&[("name", &name_rej)],
|
||||
),
|
||||
&gettext("Reject"),
|
||||
move || respond_invitation(&ui, &id, false),
|
||||
);
|
||||
});
|
||||
row.add_suffix(&accept);
|
||||
row.add_suffix(&reject);
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for inv in invitations {
|
||||
let item = inv
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "a shared item".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item)
|
||||
.subtitle(format!("from {}", inv.inviter_email))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
let reject = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Reject")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
reject.add_css_class("flat");
|
||||
let accept = gtk4::Button::builder()
|
||||
.label("Accept")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
accept.add_css_class("suggested-action");
|
||||
let ui_acc = ui.clone();
|
||||
let id_acc = inv.id.clone();
|
||||
accept.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_acc, &id_acc, true);
|
||||
});
|
||||
let ui_rej = ui.clone();
|
||||
let id_rej = inv.id.clone();
|
||||
reject.connect_clicked(move |_| {
|
||||
respond_invitation(&ui_rej, &id_rej, false);
|
||||
});
|
||||
row.add_suffix(&accept);
|
||||
row.add_suffix(&reject);
|
||||
ui.shared.invitations_group.add(&row);
|
||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarks: name/URL, Open / Remove.
|
||||
if bookmarks.is_empty() {
|
||||
let row = dim_row("No saved bookmarks.");
|
||||
for bm in bookmarks {
|
||||
let title = bm.name.clone().unwrap_or_else(|| gettext("Shared link"));
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text(gettext("Remove bookmark"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text(gettext("Open in browser"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = bm.url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
let ui_rm = ui.clone();
|
||||
let token = bm.token.clone();
|
||||
let name_rm = title.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
||||
row.add_suffix(&open);
|
||||
row.add_suffix(&remove);
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
} else {
|
||||
for bm in bookmarks {
|
||||
let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string());
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&title)
|
||||
.subtitle(&bm.url)
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link"));
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove bookmark")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open in browser")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = bm.url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
let ui_rm = ui.clone();
|
||||
let token = bm.token.clone();
|
||||
let name_rm = title.clone();
|
||||
remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm));
|
||||
row.add_suffix(&open);
|
||||
row.add_suffix(&remove);
|
||||
ui.shared.bookmarks_group.add(&row);
|
||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
||||
}
|
||||
}
|
||||
|
||||
*ui.shared.rows.borrow_mut() = rows;
|
||||
@@ -575,24 +690,32 @@ pub(crate) fn respond_invitation(ui: &Rc<Ui>, id: &str, accept: bool) {
|
||||
Request::RejectInvitation { id: id.to_string() }
|
||||
};
|
||||
let (done, failed) = if accept {
|
||||
("Invitation accepted", "Couldn't accept the invitation")
|
||||
(
|
||||
gettext("Invitation accepted"),
|
||||
gettext("Couldn't accept the invitation"),
|
||||
)
|
||||
} else {
|
||||
("Invitation rejected", "Couldn't reject the invitation")
|
||||
(
|
||||
gettext("Invitation rejected"),
|
||||
gettext("Couldn't reject the invitation"),
|
||||
)
|
||||
};
|
||||
run_shared_mutation(ui, req, done, failed);
|
||||
run_shared_mutation(ui, req, &done, &failed);
|
||||
}
|
||||
|
||||
/// Confirm, then leave a node shared with me.
|
||||
pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Leave shared item")
|
||||
.body(format!(
|
||||
"Leave “{name}”? You'll lose access until you're invited again."
|
||||
.heading(gettext("Leave shared item"))
|
||||
// Translators: {name} is the shared item's name.
|
||||
.body(gettext_f(
|
||||
"Leave “{name}”? You'll lose access until you're invited again.",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("leave", "Leave");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("leave", &pgettext("verb", "Leave"));
|
||||
dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -603,8 +726,8 @@ pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::LeaveShared { uid: uid.clone() },
|
||||
"Left shared item",
|
||||
"Couldn't leave the shared item",
|
||||
&gettext("Left shared item"),
|
||||
&gettext("Couldn't leave the shared item"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -615,11 +738,15 @@ pub(crate) fn prompt_leave_shared(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||
pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove bookmark")
|
||||
.body(format!("Remove the bookmark for “{name}”?"))
|
||||
.heading(gettext("Remove bookmark"))
|
||||
// Translators: {name} is the bookmark's name.
|
||||
.body(gettext_f(
|
||||
"Remove the bookmark for “{name}”?",
|
||||
&[("name", name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("remove", "Remove");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("remove", &gettext("Remove"));
|
||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -632,8 +759,8 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
|
||||
Request::DeleteBookmark {
|
||||
token: token.clone(),
|
||||
},
|
||||
"Bookmark removed",
|
||||
"Couldn't remove the bookmark",
|
||||
&gettext("Bookmark removed"),
|
||||
&gettext("Couldn't remove the bookmark"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -644,23 +771,23 @@ pub(crate) fn prompt_remove_bookmark(ui: &Rc<Ui>, token: &str, name: &str) {
|
||||
pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Add bookmark")
|
||||
.body("Paste a Proton Drive public link to save it here.")
|
||||
.heading(gettext("Add bookmark"))
|
||||
.body(gettext("Paste a Proton Drive public link to save it here."))
|
||||
.build();
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("Public link URL")
|
||||
.title(gettext("Public link URL"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (if the link has one)")
|
||||
.title(gettext("Password (if the link has one)"))
|
||||
.activates_default(true)
|
||||
.build();
|
||||
group.add(&url_row);
|
||||
group.add(&pw_row);
|
||||
dialog.set_extra_child(Some(&group));
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("add", "Add");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("add", &gettext("Add"));
|
||||
dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("add"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -671,7 +798,11 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
}
|
||||
let url = url_row.text().trim().to_string();
|
||||
if url.is_empty() {
|
||||
toast_error(&ui, "Couldn't add bookmark", "A URL is required.");
|
||||
toast_error(
|
||||
&ui,
|
||||
&gettext("Couldn't add bookmark"),
|
||||
&gettext("A URL is required."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pw = pw_row.text().to_string();
|
||||
@@ -679,24 +810,20 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc<Ui>) {
|
||||
run_shared_mutation(
|
||||
&ui,
|
||||
Request::CreateBookmark { url, password },
|
||||
"Bookmark saved",
|
||||
"Couldn't add the bookmark",
|
||||
&gettext("Bookmark saved"),
|
||||
&gettext("Couldn't add the bookmark"),
|
||||
);
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
}
|
||||
|
||||
/// Run a mutation raised from the Shared page and reload the page on success.
|
||||
pub(crate) fn run_shared_mutation(
|
||||
ui: &Rc<Ui>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
) {
|
||||
pub(crate) fn run_shared_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let ui = ui.clone();
|
||||
let done = done.to_string();
|
||||
let failed = failed.to_string();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
@@ -705,8 +832,10 @@ pub(crate) fn run_shared_mutation(
|
||||
load_shared(&ui);
|
||||
toast(&ui, &done);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, &failed, &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, &failed, &gettext("The mount service didn't respond.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -726,9 +855,22 @@ mod tests {
|
||||
uid: "vol~link".into(),
|
||||
path: path.into(),
|
||||
role: role.into(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_root_says_who_shared_it() {
|
||||
let mut root = entry(true, "", "viewer");
|
||||
root.shared_by = "alice@proton.me".into();
|
||||
assert_eq!(shared_row_subtitle(&root), "alice@proton.me");
|
||||
root.shared_at = 1_700_000_000;
|
||||
let subtitle = shared_row_subtitle(&root);
|
||||
assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||
// Before the synthetic directory has been listed, the daemon cannot
|
||||
@@ -742,9 +884,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resident_share_reports_where_it_can_be_reached() {
|
||||
fn a_resident_share_keeps_its_path_out_of_the_subtitle() {
|
||||
let subtitle = shared_row_subtitle(&entry(true, "Shared with me/Team Budget", "editor"));
|
||||
assert_eq!(subtitle, "in Shared with me/Team Budget");
|
||||
assert_eq!(subtitle, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_pill_says_what_it_allows() {
|
||||
assert_eq!(role_access("viewer").as_deref(), Some("Can view"));
|
||||
assert_eq!(role_access("editor").as_deref(), Some("Can edit"));
|
||||
assert_eq!(role_access("admin").as_deref(), Some("Can manage"));
|
||||
assert_eq!(role_access(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_invitations_heading_counts_only_several() {
|
||||
assert_eq!(invitations_title(1), "Invitations");
|
||||
assert_eq!(invitations_title(4), "Invitations (4)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -758,4 +914,14 @@ mod tests {
|
||||
assert!(role_label("").is_none());
|
||||
assert!(role_label("inherited").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_path_reads_from_the_share_root_down() {
|
||||
assert_eq!(shared_path(&[]), "");
|
||||
let nav = vec![
|
||||
("u1".to_string(), "Team".to_string()),
|
||||
("u2".to_string(), "Reports".to_string()),
|
||||
];
|
||||
assert_eq!(shared_path(&nav), "Team / Reports");
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,10 @@ pub(crate) struct SharedByMeWidgets {
|
||||
}
|
||||
|
||||
/// The Shared page: one section listing the items I have shared with others —
|
||||
/// each row summarizing who has access and carrying its public link (copy/open)
|
||||
/// and a Manage shortcut into the per-node Share dialog.
|
||||
/// each row summarizing who has access, opening the item when activated, with
|
||||
/// Copy link and a menu for the link and the per-node Share dialog.
|
||||
pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Shared")
|
||||
.halign(gtk4::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let refresh = refresh_button();
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.set_hexpand(true);
|
||||
header.append(&title);
|
||||
header.append(&refresh);
|
||||
|
||||
let group = adw::PreferencesGroup::new();
|
||||
let clamp = adw::Clamp::builder().child(&group).build();
|
||||
@@ -45,7 +35,7 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
.build();
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -65,15 +55,16 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
let (frame, header, _) = page_frame(&gettext("Shared by Me"), &inner);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
SharedByMeWidgets {
|
||||
content,
|
||||
status,
|
||||
@@ -113,12 +104,13 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
if ui.shared_by_me.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
ui.shared_by_me.inflight.set(true);
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Loading…",
|
||||
"Reading what you've shared.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
ui.busy_begin();
|
||||
@@ -136,15 +128,15 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Error { message, .. })) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => shared_by_me_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read your shares",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read your shares"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
@@ -161,8 +153,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -170,8 +162,8 @@ pub(crate) fn shared_by_me_unreachable(ui: &Rc<Ui>) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -191,8 +183,8 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"emblem-shared-symbolic",
|
||||
"Nothing shared yet",
|
||||
"Items you share with people or by link show up here.",
|
||||
&gettext("Nothing shared yet"),
|
||||
&gettext("Items you share with people or by link show up here."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
@@ -200,54 +192,38 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
ui.shared_by_me.content.set_visible_child_name("list");
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
for item in items {
|
||||
let entry = shared_item_as_entry(item);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.subtitle(shared_item_summary(item))
|
||||
.build();
|
||||
row.add_prefix(>k4::Image::from_icon_name(if item.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"text-x-generic-symbolic"
|
||||
}));
|
||||
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||
|
||||
// Copy / open the public link, when there is one with a recovered URL.
|
||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
||||
// A node the daemon can place in my tree opens like it would in My Files.
|
||||
if !item.path.is_empty() {
|
||||
row.set_activatable(true);
|
||||
let ui_open = ui.clone();
|
||||
let entry_open = entry.clone();
|
||||
row.connect_activated(move |_| open_shared_by_me(&ui_open, &entry_open));
|
||||
}
|
||||
|
||||
let url = item.link.as_ref().and_then(|l| l.url.clone());
|
||||
// The one action people come here for most gets its own button.
|
||||
if let Some(url) = url.clone() {
|
||||
let copy = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text("Copy link")
|
||||
.tooltip_text(gettext("Copy link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
copy.add_css_class("flat");
|
||||
let ui_copy = ui.clone();
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
toast(&ui_copy, "Link copied");
|
||||
btn.clipboard().set_text(&url);
|
||||
toast(&ui_copy, &gettext("Link copied"));
|
||||
});
|
||||
let open = gtk4::Button::builder()
|
||||
.icon_name("external-link-symbolic")
|
||||
.tooltip_text("Open link")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("flat");
|
||||
let url_open = url.clone();
|
||||
open.connect_clicked(move |_| open_uri(&url_open));
|
||||
row.add_suffix(©);
|
||||
row.add_suffix(&open);
|
||||
}
|
||||
|
||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
||||
// pathless node by uid, so every shared item can be managed from here.
|
||||
let manage = gtk4::Button::builder()
|
||||
.label("Manage")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
manage.add_css_class("flat");
|
||||
let ui_manage = ui.clone();
|
||||
let entry = shared_item_as_entry(item);
|
||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
||||
row.add_suffix(&manage);
|
||||
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
||||
|
||||
ui.shared_by_me.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
@@ -255,28 +231,122 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
||||
}
|
||||
|
||||
/// The ⋮ menu on a shared item: open it, the link, the Share dialog, and
|
||||
/// Stop Sharing.
|
||||
///
|
||||
/// Manage access opens the per-node Share dialog, which addresses a pathless
|
||||
/// node by uid, so every shared item can be managed from here.
|
||||
fn shared_by_me_menu(ui: &Rc<Ui>, entry: &DirEntry, url: Option<String>) -> gtk4::MenuButton {
|
||||
let mut items: Vec<(String, MenuAction)> = Vec::new();
|
||||
if !entry.path.is_empty() {
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
pgettext("verb", "Open"),
|
||||
Box::new(move || open_shared_by_me(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Show in My Files"),
|
||||
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||
));
|
||||
}
|
||||
if let Some(url) = url {
|
||||
items.push((gettext("Open Link"), Box::new(move || open_uri(&url))));
|
||||
}
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Manage Access…"),
|
||||
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||
));
|
||||
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||
items.push((
|
||||
gettext("Stop Sharing…"),
|
||||
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
||||
));
|
||||
let (labels, actions): (Vec<String>, Vec<MenuAction>) = items.into_iter().unzip();
|
||||
more_menu_button(labels.iter().map(String::as_str).zip(actions).collect())
|
||||
}
|
||||
|
||||
/// Confirm, then remove every member, invitation and the public link.
|
||||
pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Stop sharing?"))
|
||||
// Translators: {name} is the shared item's name.
|
||||
.body(gettext_f(
|
||||
"Everyone you invited loses access to “{name}”, pending invitations are withdrawn and its public link stops working.",
|
||||
&[("name", &entry.name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("stop", &gettext("Stop Sharing"));
|
||||
dialog.set_response_appearance("stop", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
let ui_c = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
dialog.connect_response(Some("stop"), move |_, _| {
|
||||
run_mutation(
|
||||
&ui_c,
|
||||
Request::StopSharingByUid { uid: uid.clone() },
|
||||
// Translators: {name} is the item's name.
|
||||
gettext_f("Stopped sharing {name}", &[("name", &name)]),
|
||||
gettext_noop("Couldn't stop sharing"),
|
||||
);
|
||||
});
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
}
|
||||
|
||||
/// Open a shared item: a folder in My Files, a file the way My Files would.
|
||||
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if entry.is_dir {
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
ui.browser.search.set_text("");
|
||||
browse_to(ui, entry.path.clone());
|
||||
} else {
|
||||
activate_entry(ui, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Go to the folder holding a shared item in My Files.
|
||||
pub(crate) fn show_in_my_files(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let parent = entry
|
||||
.path
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent.to_string())
|
||||
.unwrap_or_default();
|
||||
ui.stack.set_visible_child_name("browser");
|
||||
ui.browser.search.set_text("");
|
||||
browse_to(ui, parent);
|
||||
}
|
||||
|
||||
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if item.member_count > 0 {
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
item.member_count,
|
||||
if item.member_count == 1 {
|
||||
"person"
|
||||
} else {
|
||||
"people"
|
||||
}
|
||||
// Translators: how many people have access to a shared item.
|
||||
parts.push(ngettext_f(
|
||||
"{n} person",
|
||||
"{n} people",
|
||||
item.member_count as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
if item.invite_count > 0 {
|
||||
parts.push(format!("{} pending", item.invite_count));
|
||||
// Translators: how many invitations to a shared item are not accepted yet.
|
||||
parts.push(ngettext_f(
|
||||
"{n} pending",
|
||||
"{n} pending",
|
||||
item.invite_count as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
if item.link.is_some() {
|
||||
parts.push("Public link".to_string());
|
||||
parts.push(gettext("Public link"));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"Shared".to_string()
|
||||
// Translators: a shared item's summary when no people or link can be counted.
|
||||
pgettext("state", "Shared")
|
||||
} else {
|
||||
parts.join(" · ")
|
||||
}
|
||||
@@ -289,12 +359,15 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
||||
name: item.name.clone(),
|
||||
is_dir: item.is_dir,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
modified: item.modified,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: item.uid.clone(),
|
||||
path: item.path.clone(),
|
||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||
role: String::new(),
|
||||
shared_by: String::new(),
|
||||
shared_at: 0,
|
||||
shared_by_unverified: false,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
||||
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||
//! Photos.
|
||||
//!
|
||||
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
|
||||
//! import button), because it is a one-off migration rather than something the
|
||||
//! Reached from the Photos page's import button (and its empty timeline),
|
||||
//! because it is a one-off migration rather than something the
|
||||
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
||||
//! for two reasons: an export is a *set* of archives that has to be staged and
|
||||
//! checked before anything is sent, and the import that follows runs for hours,
|
||||
@@ -17,12 +17,12 @@
|
||||
//! The daemon owns the run itself ([`Request::ImportTakeout`]): this page only
|
||||
//! stages paths, launches, polls [`Request::ImportStatus`] for the final report,
|
||||
//! and reads live progress off the import's job in the transfer snapshot the
|
||||
//! Settings tick already fetches (see [`takeout_progress`]).
|
||||
//! status tick already fetches (see [`takeout_progress`]).
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The title the daemon gives the import's job in [`Request::GetQueueStatus`].
|
||||
/// Matching on it is how this page borrows the progress the Settings tick is
|
||||
/// Matching on it is how this page borrows the progress the status tick is
|
||||
/// already polling instead of adding a second poll of its own.
|
||||
const IMPORT_JOB: &str = "Importing Google Photos";
|
||||
|
||||
@@ -70,8 +70,11 @@ fn save_staged(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_LIST_DESCRIPTION: &str = "Photos already in Proton Photos are matched by name and content and skipped, so \
|
||||
re-running an interrupted import is safe.";
|
||||
fn empty_list_description() -> String {
|
||||
gettext(
|
||||
"Photos already in Proton Photos are matched by name and content and skipped, so re-running an interrupted import is safe.",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) struct TakeoutState {
|
||||
/// Archives staged for this import, in the order they were added. Sent as
|
||||
@@ -126,37 +129,26 @@ pub(crate) struct TakeoutWidgets {
|
||||
}
|
||||
|
||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
// Header: a back button out to Settings, since this page is reached from
|
||||
// Header: a back button out to Photos, since this page is reached from
|
||||
// there and has no sidebar row of its own to navigate back with.
|
||||
let back_button = gtk4::Button::builder()
|
||||
.icon_name("go-previous-symbolic")
|
||||
.tooltip_text("Back to Settings")
|
||||
.tooltip_text(gettext("Back to Photos"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
back_button.add_css_class("flat");
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Import from Google Photos")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder()
|
||||
.label("Add a Google Takeout export to your Proton Photos timeline.")
|
||||
.label(gettext(
|
||||
"Add a Google Takeout export to your Proton Photos timeline.",
|
||||
))
|
||||
.halign(gtk4::Align::Start)
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&back_button);
|
||||
header.append(&titles);
|
||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
||||
let subtitle_clamp = adw::Clamp::builder().child(&subtitle).build();
|
||||
|
||||
// Drop zone. It is a hint, not the only way in — the button under it does
|
||||
// the same thing for anyone not dragging, and the drop target covers the
|
||||
@@ -165,18 +157,18 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
drop_icon.set_pixel_size(48);
|
||||
drop_icon.add_css_class("dim-label");
|
||||
let drop_title = gtk4::Label::builder()
|
||||
.label("Drop your Takeout .zip files here")
|
||||
.label(gettext("Drop your Takeout .zip files here"))
|
||||
.build();
|
||||
drop_title.add_css_class("title-4");
|
||||
let drop_hint = gtk4::Label::builder()
|
||||
.label("Add every part of the export at once — a photo and its metadata often sit in different parts.")
|
||||
.label(gettext("Add every part of the export at once — a photo and its metadata often sit in different parts."))
|
||||
.wrap(true)
|
||||
.justify(gtk4::Justification::Center)
|
||||
.max_width_chars(48)
|
||||
.build();
|
||||
drop_hint.add_css_class("dim-label");
|
||||
let choose_button = gtk4::Button::builder()
|
||||
.label("Choose files…")
|
||||
.label(gettext("Choose files…"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
choose_button.add_css_class("pill");
|
||||
@@ -194,13 +186,13 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
// group carries a placeholder row while empty so it never renders as a bare
|
||||
// title.
|
||||
let list_group = adw::PreferencesGroup::builder()
|
||||
.title("Archives to import")
|
||||
.description(EMPTY_LIST_DESCRIPTION)
|
||||
.title(gettext("Archives to import"))
|
||||
.description(empty_list_description())
|
||||
.build();
|
||||
|
||||
let clear_button = gtk4::Button::builder()
|
||||
.label("Clear")
|
||||
.tooltip_text("Remove every staged archive")
|
||||
.label(gettext("Clear"))
|
||||
.tooltip_text(gettext("Remove every staged archive"))
|
||||
.build();
|
||||
clear_button.add_css_class("flat");
|
||||
|
||||
@@ -208,16 +200,22 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
// what an import *would* do, without uploading a byte. It is offered first
|
||||
// because the import behind it is hours long and hard to take back.
|
||||
let scan_button = gtk4::Button::builder()
|
||||
.label("Scan")
|
||||
.tooltip_text("Check the archives and report what would be imported, without uploading")
|
||||
.label(pgettext("verb", "Scan"))
|
||||
.tooltip_text(gettext(
|
||||
"Check the archives and report what would be imported, without uploading",
|
||||
))
|
||||
.build();
|
||||
scan_button.add_css_class("pill");
|
||||
let import_button = gtk4::Button::builder().label("Import").build();
|
||||
let import_button = gtk4::Button::builder()
|
||||
.label(pgettext("verb", "Import"))
|
||||
.build();
|
||||
import_button.add_css_class("pill");
|
||||
import_button.add_css_class("suggested-action");
|
||||
let cancel_button = gtk4::Button::builder()
|
||||
.label("Stop import")
|
||||
.tooltip_text("Finish the photo on the wire, file what was uploaded, then stop")
|
||||
.label(gettext("Stop import"))
|
||||
.tooltip_text(gettext(
|
||||
"Finish the photo on the wire, file what was uploaded, then stop",
|
||||
))
|
||||
.build();
|
||||
cancel_button.add_css_class("pill");
|
||||
cancel_button.add_css_class("destructive-action");
|
||||
@@ -235,9 +233,9 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
actions.append(&import_button);
|
||||
|
||||
// Progress: the import's job from the transfer snapshot, so this page shows
|
||||
// the same numbers the Settings Activity list does.
|
||||
// the same numbers the Sync page's Transfers list does.
|
||||
let progress_group = adw::PreferencesGroup::builder()
|
||||
.title("Import in progress")
|
||||
.title(gettext("Import in progress"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
@@ -259,7 +257,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
// outlives the window being on this page, and "what happened" is the whole
|
||||
// reason to come back to it.
|
||||
let summary_group = adw::PreferencesGroup::builder()
|
||||
.title("Last import")
|
||||
.title(gettext("Last import"))
|
||||
.visible(false)
|
||||
.build();
|
||||
|
||||
@@ -284,8 +282,10 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
page.set_margin_bottom(18);
|
||||
page.set_margin_start(18);
|
||||
page.set_margin_end(18);
|
||||
page.append(&header_clamp);
|
||||
page.append(&subtitle_clamp);
|
||||
page.append(&scroll);
|
||||
let (frame, header, _) = page_frame(&gettext("Import from Google Photos"), &page);
|
||||
header.pack_start(&back_button);
|
||||
|
||||
let widgets = TakeoutWidgets {
|
||||
list_group: list_group.clone(),
|
||||
@@ -302,7 +302,7 @@ pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||
progress_bar: progress_bar.clone(),
|
||||
summary_group: summary_group.clone(),
|
||||
};
|
||||
(page.upcast(), widgets)
|
||||
(frame.upcast(), widgets)
|
||||
}
|
||||
|
||||
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
@@ -341,10 +341,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
widgets.choose_button.connect_clicked(move |_| {
|
||||
// Multi-select on purpose: the whole export goes in one request.
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Select your Google Takeout archives")
|
||||
.title(gettext("Select your Google Takeout archives"))
|
||||
.build();
|
||||
let filter = gtk4::FileFilter::new();
|
||||
filter.set_name(Some("Takeout archives"));
|
||||
filter.set_name(Some(&gettext("Takeout archives")));
|
||||
filter.add_pattern("*.zip");
|
||||
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||
filters.append(&filter);
|
||||
@@ -384,15 +384,16 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let count = ui_import.takeout.archives.borrow().len();
|
||||
let ui = ui_import.clone();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Import this export?")
|
||||
.body(format!(
|
||||
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
|
||||
appear in the export. This can take hours; you can stop it at any point and \
|
||||
what has been uploaded stays. Photos you already have are skipped.",
|
||||
.heading(gettext("Import this export?"))
|
||||
.body(ngettext_f(
|
||||
"{n} archive will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||
"{n} archives will be uploaded to Proton Photos, creating albums as they appear in the export. This can take hours; you can stop it at any point and what has been uploaded stays. Photos you already have are skipped.",
|
||||
count as u64,
|
||||
&[],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("import", "Import");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("import", &pgettext("verb", "Import"));
|
||||
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -413,14 +414,14 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
match result {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
|
||||
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Stopping the import…")),
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't stop the import", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't stop the import"), &message, kind);
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't stop the import",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't stop the import"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -429,7 +430,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||
let ui_back = ui.clone();
|
||||
widgets
|
||||
.back_button
|
||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
|
||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
|
||||
|
||||
// Restore whatever was staged when the window last closed, before the first
|
||||
// paint, so the page comes up as the user left it.
|
||||
@@ -470,10 +471,15 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||
if rejected > 0 {
|
||||
toast(
|
||||
ui,
|
||||
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||
&gettext(
|
||||
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||
),
|
||||
);
|
||||
} else if added > 0 {
|
||||
toast(ui, &format!("Added {added} archive(s)"));
|
||||
toast(
|
||||
ui,
|
||||
&ngettext_f("Added {n} archive", "Added {n} archives", added as u64, &[]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,8 +494,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
if staged.is_empty() {
|
||||
ui.takeout
|
||||
.list_group
|
||||
.set_description(Some(EMPTY_LIST_DESCRIPTION));
|
||||
let row = dim_row("No archives added yet.");
|
||||
.set_description(Some(&empty_list_description()));
|
||||
let row = dim_row(&gettext("No archives added yet."));
|
||||
ui.takeout.list_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
} else {
|
||||
@@ -507,7 +513,7 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
.build();
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text("Remove from this import")
|
||||
.tooltip_text(gettext("Remove from this import"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
@@ -525,10 +531,14 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||
ui.takeout.list_group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
ui.takeout.list_group.set_description(Some(&format!(
|
||||
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||
staged.len(),
|
||||
human_bytes(total)
|
||||
let size = human_bytes(total);
|
||||
let note = empty_list_description();
|
||||
// Translators: {size} is a file size such as "4.2 MB"; {note} is the sentence about skipped duplicates.
|
||||
ui.takeout.list_group.set_description(Some(&ngettext_f(
|
||||
"{n} archive, {size} in total. {note}",
|
||||
"{n} archives, {size} in total. {note}",
|
||||
staged.len() as u64,
|
||||
&[("size", &size), ("note", ¬e)],
|
||||
)));
|
||||
}
|
||||
*ui.takeout.rows.borrow_mut() = rows;
|
||||
@@ -582,28 +592,28 @@ fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
||||
ui.takeout.summary_group.set_visible(false);
|
||||
ui.takeout.progress_group.set_visible(true);
|
||||
ui.takeout.progress_bar.set_fraction(0.0);
|
||||
ui.takeout.progress_label.set_text(if dry_run {
|
||||
"Reading archives…"
|
||||
ui.takeout.progress_label.set_text(&if dry_run {
|
||||
gettext("Reading archives…")
|
||||
} else {
|
||||
"Starting import…"
|
||||
gettext("Starting import…")
|
||||
});
|
||||
sync_takeout_actions(&ui);
|
||||
toast(
|
||||
&ui,
|
||||
if dry_run {
|
||||
"Scanning the export…"
|
||||
&if dry_run {
|
||||
gettext("Scanning the export…")
|
||||
} else {
|
||||
"Import started — you can leave this page, it keeps running."
|
||||
gettext("Import started — you can leave this page, it keeps running.")
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't start the import", &message, kind);
|
||||
toast_failure(&ui, &gettext("Couldn't start the import"), &message, kind);
|
||||
}
|
||||
_ => toast_error(
|
||||
&ui,
|
||||
"Couldn't start the import",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't start the import"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -636,23 +646,35 @@ pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||
if was_running {
|
||||
let dry = ui.takeout.dry_run.get();
|
||||
let headline = if dry {
|
||||
"Scan finished"
|
||||
gettext("Scan finished")
|
||||
} else {
|
||||
"Import finished"
|
||||
gettext("Import finished")
|
||||
};
|
||||
toast(&ui, headline);
|
||||
toast(&ui, &headline);
|
||||
if !dry {
|
||||
let body = match &summary {
|
||||
Some(s) if s.failed > 0 => {
|
||||
format!("{} photos added, {} failed.", s.uploaded, s.failed)
|
||||
}
|
||||
Some(s) if s.cancelled => {
|
||||
format!("Stopped after {} photos.", s.uploaded)
|
||||
}
|
||||
Some(s) => format!("{} photos added to Proton Photos.", s.uploaded),
|
||||
None => "The Google Photos import has finished.".to_string(),
|
||||
// Translators: {n} is the number of photos uploaded, {failed} the number that failed.
|
||||
Some(s) if s.failed > 0 => ngettext_f(
|
||||
"{n} photo added, {failed} failed.",
|
||||
"{n} photos added, {failed} failed.",
|
||||
s.uploaded as u64,
|
||||
&[("failed", &s.failed.to_string())],
|
||||
),
|
||||
Some(s) if s.cancelled => ngettext_f(
|
||||
"Stopped after {n} photo.",
|
||||
"Stopped after {n} photos.",
|
||||
s.uploaded as u64,
|
||||
&[],
|
||||
),
|
||||
Some(s) => ngettext_f(
|
||||
"{n} photo added to Proton Photos.",
|
||||
"{n} photos added to Proton Photos.",
|
||||
s.uploaded as u64,
|
||||
&[],
|
||||
),
|
||||
None => gettext("The Google Photos import has finished."),
|
||||
};
|
||||
notify("takeout-import", headline, &body);
|
||||
notify("takeout-import", &headline, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,69 +694,72 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.remove(&row);
|
||||
}
|
||||
let dry = ui.takeout.dry_run.get();
|
||||
ui.takeout
|
||||
.summary_group
|
||||
.set_title(if dry { "Scan result" } else { "Last import" });
|
||||
ui.takeout.summary_group.set_description(Some(if dry {
|
||||
"Nothing was uploaded — this is what an import would do."
|
||||
} else if summary.cancelled {
|
||||
"Stopped early. Everything uploaded so far is in your timeline; run it again to \
|
||||
continue where it left off."
|
||||
ui.takeout.summary_group.set_title(&if dry {
|
||||
gettext("Scan result")
|
||||
} else {
|
||||
"Finished."
|
||||
gettext("Last import")
|
||||
});
|
||||
ui.takeout.summary_group.set_description(Some(&if dry {
|
||||
gettext("Nothing was uploaded — this is what an import would do.")
|
||||
} else if summary.cancelled {
|
||||
gettext("Stopped early. Everything uploaded so far is in your timeline; run it again to continue where it left off.")
|
||||
} else {
|
||||
gettext("Finished.")
|
||||
}));
|
||||
|
||||
// Only lines that carry information: a run with no failures should not have
|
||||
// to show a "0 failed" row for the user to read past.
|
||||
let mut lines: Vec<(String, String)> = vec![
|
||||
("Photos in the export".into(), summary.found.to_string()),
|
||||
(gettext("Photos in the export"), summary.found.to_string()),
|
||||
(
|
||||
if dry { "Would upload" } else { "Uploaded" }.into(),
|
||||
if dry {
|
||||
gettext("Would upload")
|
||||
} else {
|
||||
gettext("Uploaded")
|
||||
},
|
||||
summary.uploaded.to_string(),
|
||||
),
|
||||
];
|
||||
if summary.duplicates > 0 {
|
||||
lines.push((
|
||||
"Already in Proton Photos".into(),
|
||||
gettext("Already in Proton Photos"),
|
||||
summary.duplicates.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.albums_created > 0 {
|
||||
lines.push((
|
||||
if dry {
|
||||
"Albums to create"
|
||||
gettext("Albums to create")
|
||||
} else {
|
||||
"Albums created"
|
||||
}
|
||||
.into(),
|
||||
gettext("Albums created")
|
||||
},
|
||||
summary.albums_created.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.album_links > 0 {
|
||||
lines.push((
|
||||
"Photos filed into albums".into(),
|
||||
gettext("Photos filed into albums"),
|
||||
summary.album_links.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.skipped_trashed > 0 {
|
||||
lines.push((
|
||||
"Skipped (in Google's trash)".into(),
|
||||
gettext("Skipped (in Google's trash)"),
|
||||
summary.skipped_trashed.to_string(),
|
||||
));
|
||||
}
|
||||
if summary.bytes > 0 {
|
||||
lines.push((
|
||||
if dry {
|
||||
"Data to upload"
|
||||
gettext("Data to upload")
|
||||
} else {
|
||||
"Data uploaded"
|
||||
}
|
||||
.into(),
|
||||
gettext("Data uploaded")
|
||||
},
|
||||
human_bytes(summary.bytes),
|
||||
));
|
||||
}
|
||||
if summary.failed > 0 {
|
||||
lines.push(("Failed".into(), summary.failed.to_string()));
|
||||
lines.push((pgettext("state", "Failed"), summary.failed.to_string()));
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
@@ -751,7 +776,7 @@ fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||
ui.takeout.summary_group.set_visible(true);
|
||||
}
|
||||
|
||||
/// Paint the import's progress from the transfer snapshot the Settings tick
|
||||
/// Paint the import's progress from the transfer snapshot the status tick
|
||||
/// already fetched, so this page costs no extra poll. A job with no known total
|
||||
/// pulses instead of showing a fraction, the same as the Activity list.
|
||||
pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||
@@ -763,17 +788,22 @@ pub(crate) fn takeout_progress(ui: &Rc<Ui>, jobs: &[JobItem]) {
|
||||
ui.takeout
|
||||
.progress_bar
|
||||
.set_fraction((job.done as f64 / job.total as f64).min(1.0));
|
||||
ui.takeout
|
||||
.progress_label
|
||||
.set_text(&format!("{} of {} — {}", job.done, job.total, job.detail));
|
||||
let done = job.done.to_string();
|
||||
let total = job.total.to_string();
|
||||
// Translators: progress such as "120 of 4000 — IMG_0001.jpg"; {detail} is what is being worked on.
|
||||
let text = gettext_f(
|
||||
"{done} of {total} — {detail}",
|
||||
&[("done", &done), ("total", &total), ("detail", &job.detail)],
|
||||
);
|
||||
ui.takeout.progress_label.set_text(&text);
|
||||
} else {
|
||||
ui.takeout.progress_bar.pulse();
|
||||
ui.takeout
|
||||
.progress_label
|
||||
.set_text(if job.detail.is_empty() {
|
||||
"Working…"
|
||||
.set_text(&if job.detail.is_empty() {
|
||||
gettext("Working…")
|
||||
} else {
|
||||
&job.detail
|
||||
job.detail.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,11 @@ pub(crate) struct TrashState {
|
||||
/// Empties the trash; insensitive while it is empty (or unread).
|
||||
pub(crate) empty: gtk4::Button,
|
||||
/// "12 items" under the page title.
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
}
|
||||
|
||||
/// The widgets of the Trash page that a load repaints.
|
||||
@@ -22,11 +26,18 @@ pub(crate) struct TrashWidgets {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) empty: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) subtitle: gtk4::Label,
|
||||
pub(crate) subtitle: adw::WindowTitle,
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
pub(crate) restore_selected: gtk4::Button,
|
||||
pub(crate) delete_selected: gtk4::Button,
|
||||
}
|
||||
|
||||
/// The Trash page: a flat list of everything Drive is holding in the trash, each
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header.
|
||||
/// row offering Restore and Delete Forever, with Empty Trash in the header. A
|
||||
/// selection (click, Shift/Ctrl+click, Ctrl+A) gets the same two actions in a
|
||||
/// bottom bar.
|
||||
///
|
||||
/// A trashed node has no path inside the mount — the daemon forgets it when it is
|
||||
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
||||
@@ -35,33 +46,15 @@ pub(crate) struct TrashWidgets {
|
||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
|
||||
let title = gtk4::Label::builder()
|
||||
.label("Trash")
|
||||
.halign(gtk4::Align::Start)
|
||||
.build();
|
||||
title.add_css_class("title-2");
|
||||
let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||
subtitle.add_css_class("dim-label");
|
||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
titles.set_hexpand(true);
|
||||
titles.append(&title);
|
||||
titles.append(&subtitle);
|
||||
|
||||
let empty = gtk4::Button::builder()
|
||||
.label("Empty Trash")
|
||||
.valign(gtk4::Align::Center)
|
||||
.label(gettext("Empty Trash…"))
|
||||
.tooltip_text(gettext("Permanently delete everything in the Trash"))
|
||||
.sensitive(false)
|
||||
.build();
|
||||
empty.add_css_class("destructive-action");
|
||||
let refresh = refresh_button();
|
||||
|
||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header.append(&titles);
|
||||
header.append(&refresh);
|
||||
header.append(&empty);
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -74,11 +67,10 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
// No selection model: every action lives on the row it acts on, so a
|
||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
||||
.build();
|
||||
// Bulk Delete Forever always confirms with the count, so a stale selection
|
||||
// can't delete anything silently.
|
||||
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||
let list = gtk4::ListView::builder().model(&selection).build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.child(&list)
|
||||
@@ -91,15 +83,32 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
inner.set_margin_top(12);
|
||||
inner.set_margin_bottom(12);
|
||||
inner.set_margin_start(12);
|
||||
inner.set_margin_end(12);
|
||||
inner.append(&header);
|
||||
inner.set_margin_top(18);
|
||||
inner.set_margin_bottom(18);
|
||||
inner.set_margin_start(18);
|
||||
inner.set_margin_end(18);
|
||||
inner.append(&content);
|
||||
|
||||
let selection_label = gtk4::Label::new(None);
|
||||
let restore_selected = gtk4::Button::with_label(&pgettext("verb", "Restore"));
|
||||
let delete_selected = gtk4::Button::with_label(&gettext("Delete Permanently…"));
|
||||
delete_selected.add_css_class("destructive-action");
|
||||
let bar = gtk4::ActionBar::new();
|
||||
bar.set_center_widget(Some(&selection_label));
|
||||
bar.pack_start(&restore_selected);
|
||||
bar.pack_end(&delete_selected);
|
||||
let selection_bar = gtk4::Revealer::builder()
|
||||
.transition_type(gtk4::RevealerTransitionType::SlideUp)
|
||||
.child(&bar)
|
||||
.build();
|
||||
inner.append(&selection_bar);
|
||||
|
||||
let (frame, header, subtitle) = page_frame(&gettext("Trash"), &inner);
|
||||
header.pack_start(&empty);
|
||||
header.pack_end(&refresh);
|
||||
|
||||
(
|
||||
inner.upcast(),
|
||||
frame.upcast(),
|
||||
TrashWidgets {
|
||||
model,
|
||||
list,
|
||||
@@ -109,6 +118,11 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
empty,
|
||||
refresh,
|
||||
subtitle,
|
||||
selection,
|
||||
selection_bar,
|
||||
selection_label,
|
||||
restore_selected,
|
||||
delete_selected,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -116,13 +130,15 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
||||
/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a
|
||||
/// captured copy, so a recycled row always acts on the item it currently shows.
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Button) {
|
||||
pub(crate) fn wire_trash(ui: &Rc<Ui>, widgets: &TrashWidgets) {
|
||||
let list = &widgets.list;
|
||||
let empty = &widgets.empty;
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
let ui_setup = ui.clone();
|
||||
factory.connect_setup(move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
|
||||
let icon = gtk4::Image::builder().pixel_size(32).build();
|
||||
let thumbnail = file_thumbnail_widget(40, 32);
|
||||
let name = gtk4::Label::builder()
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
@@ -138,13 +154,13 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore to its original folder")
|
||||
.tooltip_text(gettext("Restore to its original folder"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
let purge = gtk4::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.tooltip_text("Delete permanently")
|
||||
.tooltip_text(gettext("Delete permanently"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
purge.add_css_class("flat");
|
||||
@@ -160,7 +176,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
let item_purge = item.clone();
|
||||
purge.connect_clicked(move |_| {
|
||||
if let Some(entry) = bound_entry(&item_purge) {
|
||||
prompt_delete_forever(&ui_purge, &entry);
|
||||
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,41 +185,93 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
row.set_margin_bottom(6);
|
||||
row.set_margin_start(6);
|
||||
row.set_margin_end(6);
|
||||
row.append(&icon);
|
||||
row.append(&thumbnail);
|
||||
row.append(&text);
|
||||
row.append(&restore);
|
||||
row.append(&purge);
|
||||
item.set_child(Some(&row));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
||||
return;
|
||||
};
|
||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(entry) = bound_entry(item) else {
|
||||
return;
|
||||
};
|
||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
||||
return;
|
||||
};
|
||||
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||
return;
|
||||
};
|
||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||
name.set_label(&entry.name);
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
gettext("Folder")
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
};
|
||||
let modified = format_modified(entry.modified);
|
||||
// Translators: {kind} is "Folder" or a file size; {modified} is a date.
|
||||
let text = gettext_f(
|
||||
"{kind} · {modified}",
|
||||
&[("kind", &kind), ("modified", &modified)],
|
||||
);
|
||||
meta.set_label(&text);
|
||||
}
|
||||
}
|
||||
});
|
||||
list.set_factory(Some(&factory));
|
||||
|
||||
let ui_empty = ui.clone();
|
||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
||||
|
||||
let ui_sel = ui.clone();
|
||||
widgets
|
||||
.selection
|
||||
.connect_selection_changed(move |_, _, _| sync_trash_selection(&ui_sel));
|
||||
let ui_restore = ui.clone();
|
||||
widgets.restore_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_restore);
|
||||
if !entries.is_empty() {
|
||||
restore_entries(&ui_restore, &entries);
|
||||
}
|
||||
});
|
||||
let ui_delete = ui.clone();
|
||||
widgets.delete_selected.connect_clicked(move |_| {
|
||||
let entries = selected_trash(&ui_delete);
|
||||
if !entries.is_empty() {
|
||||
prompt_delete_forever(&ui_delete, &entries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The trashed entries currently selected, in list order.
|
||||
fn selected_trash(ui: &Rc<Ui>) -> Vec<DirEntry> {
|
||||
let selected = ui.trash.selection.selection();
|
||||
(0..selected.size())
|
||||
.filter_map(|i| ui.trash.model.item(selected.nth(i as u32)))
|
||||
.filter_map(|obj| obj.downcast::<BoxedAnyObject>().ok())
|
||||
.map(|obj| obj.borrow::<DirEntry>().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reveal the selection bar while anything is selected and count what is.
|
||||
fn sync_trash_selection(ui: &Rc<Ui>) {
|
||||
let count = ui.trash.selection.selection().size() as usize;
|
||||
ui.trash.selection_bar.set_reveal_child(count > 0);
|
||||
if count > 0 {
|
||||
ui.trash.selection_label.set_label(&ngettext_f(
|
||||
"{n} item selected",
|
||||
"{n} items selected",
|
||||
count as u64,
|
||||
&[],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
||||
@@ -216,15 +284,17 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
||||
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
// Drop the old rows first: a stale row here would offer Restore on something
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(false);
|
||||
sync_trash_selection(ui);
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Loading…",
|
||||
"Reading the trash.",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -239,15 +309,15 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
&gettext("Couldn't read the trash"),
|
||||
&message,
|
||||
false,
|
||||
),
|
||||
Ok(Ok(_)) => trash_status(
|
||||
&ui,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read the trash",
|
||||
"Unexpected reply from the mount service.",
|
||||
&gettext("Couldn't read the trash"),
|
||||
&gettext("Unexpected reply from the mount service."),
|
||||
false,
|
||||
),
|
||||
Ok(Err(_)) | Err(_) => trash_unreachable(&ui),
|
||||
@@ -262,8 +332,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
trash_status(
|
||||
ui,
|
||||
"network-offline-symbolic",
|
||||
"Not connected",
|
||||
"The Proton Drive mount service isn't running.",
|
||||
&gettext("Not connected"),
|
||||
&gettext("The Proton Drive mount service isn't running."),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
@@ -271,8 +341,8 @@ pub(crate) fn trash_unreachable(ui: &Rc<Ui>) {
|
||||
trash_status(
|
||||
ui,
|
||||
"folder-remote-symbolic",
|
||||
"Connecting…",
|
||||
"Waiting for the Proton Drive mount service to come up.",
|
||||
&gettext("Connecting…"),
|
||||
&gettext("Waiting for the Proton Drive mount service to come up."),
|
||||
false,
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -290,29 +360,32 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
||||
ui.trash.status.set_description(Some(description));
|
||||
ui.trash.retry.set_visible(retry);
|
||||
ui.trash.content.set_visible_child_name("status");
|
||||
ui.trash.subtitle.set_label("");
|
||||
ui.trash.subtitle.set_subtitle("");
|
||||
}
|
||||
|
||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||
/// user looks for what they just deleted.
|
||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.trash.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||
if entries.is_empty() {
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
"Trash is empty",
|
||||
"Items you delete from Proton Drive show up here.",
|
||||
&gettext("Trash is empty"),
|
||||
&gettext("Items you delete from Proton Drive show up here."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
ui.trash.subtitle.set_label(&match entries.len() {
|
||||
1 => "1 item".to_string(),
|
||||
n => format!("{n} items"),
|
||||
});
|
||||
ui.trash.subtitle.set_subtitle(&ngettext_f(
|
||||
"{n} item",
|
||||
"{n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
));
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
@@ -323,30 +396,66 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
/// Restore one trashed entry to the folder it was trashed from.
|
||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let name = entry.name.clone();
|
||||
restore_entries(ui, std::slice::from_ref(entry));
|
||||
}
|
||||
|
||||
/// Restore trashed entries to the folders they were trashed from.
|
||||
pub(crate) fn restore_entries(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
run_mutation(
|
||||
ui,
|
||||
Request::Restore {
|
||||
uids: vec![entry.uid.clone()],
|
||||
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
},
|
||||
format!("Restored “{name}”"),
|
||||
"Couldn't restore",
|
||||
match entries {
|
||||
// Translators: {name} is a file or folder name.
|
||||
[one] => gettext_f("Restored “{name}”", &[("name", &one.name)]),
|
||||
_ => ngettext_f(
|
||||
"Restored {n} item",
|
||||
"Restored {n} items",
|
||||
entries.len() as u64,
|
||||
&[],
|
||||
),
|
||||
},
|
||||
gettext_noop("Couldn't restore"),
|
||||
);
|
||||
}
|
||||
|
||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
/// Confirm, then permanently delete trashed entries. Irreversible, so it asks.
|
||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
let win = ui_window(ui);
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||
let count = entries.len() as u64;
|
||||
let (body, done) = match entries {
|
||||
[one] => {
|
||||
let args = [("name", one.name.as_str())];
|
||||
(
|
||||
// Translators: {name} is a file or folder name.
|
||||
gettext_f("Permanently delete “{name}”? This cannot be undone.", &args),
|
||||
// Translators: {name} is a file or folder name.
|
||||
gettext_f("Deleted “{name}” permanently", &args),
|
||||
)
|
||||
}
|
||||
_ => (
|
||||
ngettext_f(
|
||||
"Permanently delete {n} item? This cannot be undone.",
|
||||
"Permanently delete {n} items? This cannot be undone.",
|
||||
count,
|
||||
&[],
|
||||
),
|
||||
ngettext_f(
|
||||
"Deleted {n} item permanently",
|
||||
"Deleted {n} items permanently",
|
||||
count,
|
||||
&[],
|
||||
),
|
||||
),
|
||||
};
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete permanently")
|
||||
.body(format!(
|
||||
"Permanently delete “{name}”? This cannot be undone."
|
||||
))
|
||||
.heading(gettext("Delete Permanently"))
|
||||
.body(body)
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete Permanently");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("delete", &gettext("Delete Permanently"));
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -356,11 +465,9 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if resp == "delete" {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::DeleteForever {
|
||||
uids: vec![uid.clone()],
|
||||
},
|
||||
format!("Deleted “{name}” permanently"),
|
||||
"Couldn't delete",
|
||||
Request::DeleteForever { uids: uids.clone() },
|
||||
done.clone(),
|
||||
gettext_noop("Couldn't delete"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -372,13 +479,16 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
let win = ui_window(ui);
|
||||
let count = ui.trash.model.n_items();
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Empty Trash")
|
||||
.body(format!(
|
||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
||||
.heading(gettext("Empty Trash"))
|
||||
.body(ngettext_f(
|
||||
"Permanently delete all {n} item in the trash? This cannot be undone.",
|
||||
"Permanently delete all {n} items in the trash? This cannot be undone.",
|
||||
count as u64,
|
||||
&[],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("empty", "Empty Trash");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("empty", &gettext("Empty Trash"));
|
||||
dialog.set_response_appearance("empty", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -389,8 +499,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
||||
run_mutation(
|
||||
&ui,
|
||||
Request::EmptyTrash,
|
||||
"Trash emptied".to_string(),
|
||||
"Couldn't empty the trash",
|
||||
gettext("Trash emptied"),
|
||||
gettext_noop("Couldn't empty the trash"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(crate) fn prompt_human_verification(
|
||||
.build();
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Verification")
|
||||
.title(gettext("Verification"))
|
||||
.content_width(420)
|
||||
.content_height(560)
|
||||
.build();
|
||||
|
||||
@@ -9,10 +9,9 @@ pub(crate) struct DetailsState {
|
||||
/// Set while the details pane is being populated, so setting the offline
|
||||
/// switch programmatically doesn't fire a pin/unpin round-trip.
|
||||
pub(crate) details_suppress: Cell<bool>,
|
||||
/// The grid's and list's selection models. Both wrap the one browser model, so
|
||||
/// a selection in either drives the details pane.
|
||||
pub(crate) grid_selection: gtk4::MultiSelection,
|
||||
pub(crate) list_selection: gtk4::MultiSelection,
|
||||
/// The one selection model behind both the grid and the list, so switching
|
||||
/// views keeps what is selected; it drives the details pane.
|
||||
pub(crate) selection: gtk4::MultiSelection,
|
||||
}
|
||||
|
||||
/// The widgets in the browser's details pane that a selection repaints.
|
||||
@@ -26,20 +25,37 @@ pub(crate) struct DetailsWidgets {
|
||||
pub(crate) pin_row: adw::SwitchRow,
|
||||
pub(crate) open_button: gtk4::Button,
|
||||
pub(crate) rename_button: gtk4::Button,
|
||||
/// Who has access and whether there is a public link; filled on selection.
|
||||
pub(crate) sharing_row: adw::ActionRow,
|
||||
pub(crate) share_button: gtk4::Button,
|
||||
pub(crate) copy_link_button: gtk4::Button,
|
||||
/// Files only — folders have no revision history.
|
||||
pub(crate) versions_group: adw::PreferencesGroup,
|
||||
pub(crate) versions_row: adw::ActionRow,
|
||||
pub(crate) versions_button: gtk4::Button,
|
||||
pub(crate) trash_button: gtk4::Button,
|
||||
pub(crate) close_button: gtk4::Button,
|
||||
/// The header toggle that asks for the pane. Selecting an entry only
|
||||
/// fills the pane; it is shown while this is on, so a click never moves
|
||||
/// the files under the pointer halfway through a double-click.
|
||||
pub(crate) toggle: gtk4::ToggleButton,
|
||||
/// "details" while an entry is shown, "empty" with nothing selected.
|
||||
pub(crate) pages: gtk4::Stack,
|
||||
}
|
||||
|
||||
/// The details pane shown beside the file views: a big type icon over the entry's
|
||||
/// name, its properties, an offline (pin) toggle and the primary actions. Built
|
||||
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
|
||||
/// [`wire_details`] connects the buttons.
|
||||
/// name, then Info, Offline, Sharing and Versions sections and the primary
|
||||
/// actions. Built empty; [`show_details`] fills it from the selected
|
||||
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.tooltip_text(gettext("Details (Alt+Enter)"))
|
||||
.build();
|
||||
|
||||
let close_button = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text("Close details")
|
||||
.tooltip_text(gettext("Close details"))
|
||||
.halign(gtk4::Align::End)
|
||||
.build();
|
||||
close_button.add_css_class("flat");
|
||||
@@ -65,19 +81,21 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
head.append(&name);
|
||||
head.append(&kind);
|
||||
|
||||
let props = adw::PreferencesGroup::new();
|
||||
let props = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Info"))
|
||||
.build();
|
||||
let size_row = adw::ActionRow::builder()
|
||||
.title("Size")
|
||||
.title(pgettext("column", "Size"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
size_row.add_css_class("property");
|
||||
let modified_row = adw::ActionRow::builder()
|
||||
.title("Modified")
|
||||
.title(pgettext("column", "Modified"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
modified_row.add_css_class("property");
|
||||
let path_row = adw::ActionRow::builder()
|
||||
.title("Location")
|
||||
.title(gettext("Location"))
|
||||
.subtitle("—")
|
||||
.subtitle_lines(3)
|
||||
.build();
|
||||
@@ -86,21 +104,63 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
props.add(&modified_row);
|
||||
props.add(&path_row);
|
||||
|
||||
let offline = adw::PreferencesGroup::new();
|
||||
let offline = adw::PreferencesGroup::builder()
|
||||
.title(pgettext("state", "Offline"))
|
||||
.build();
|
||||
let pin_row = adw::SwitchRow::builder()
|
||||
.title("Available offline")
|
||||
.subtitle("Keep a local copy on this device.")
|
||||
.title(gettext("Available offline"))
|
||||
.subtitle(gettext(
|
||||
"Keep a copy on this computer, even without a connection.",
|
||||
))
|
||||
.build();
|
||||
offline.add(&pin_row);
|
||||
|
||||
let open_button = gtk4::Button::builder().label("Open").build();
|
||||
let sharing = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Sharing"))
|
||||
.build();
|
||||
let sharing_row = adw::ActionRow::builder()
|
||||
.title(gettext("Access"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
sharing_row.add_css_class("property");
|
||||
sharing.add(&sharing_row);
|
||||
let share_button = gtk4::Button::builder().label(gettext("Share…")).build();
|
||||
let copy_link_button = gtk4::Button::builder().label(gettext("Copy Link")).build();
|
||||
let share_actions = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(6)
|
||||
.homogeneous(true)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
share_actions.append(&share_button);
|
||||
share_actions.append(©_link_button);
|
||||
sharing.add(&share_actions);
|
||||
|
||||
let versions_group = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Versions"))
|
||||
.build();
|
||||
let versions_row = adw::ActionRow::builder()
|
||||
.title(gettext("History"))
|
||||
.subtitle("—")
|
||||
.build();
|
||||
versions_row.add_css_class("property");
|
||||
versions_group.add(&versions_row);
|
||||
let versions_button = gtk4::Button::builder()
|
||||
.label(gettext("Versions…"))
|
||||
.margin_top(6)
|
||||
.build();
|
||||
versions_group.add(&versions_button);
|
||||
|
||||
let open_button = gtk4::Button::builder()
|
||||
.label(pgettext("verb", "Open"))
|
||||
.build();
|
||||
open_button.add_css_class("suggested-action");
|
||||
open_button.add_css_class("pill");
|
||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||
let rename_button = gtk4::Button::builder().label(gettext("Rename")).build();
|
||||
rename_button.add_css_class("pill");
|
||||
let versions_button = gtk4::Button::builder().label("Versions").build();
|
||||
versions_button.add_css_class("pill");
|
||||
let trash_button = gtk4::Button::builder().label("Move to Trash").build();
|
||||
let trash_button = gtk4::Button::builder()
|
||||
.label(gettext("Move to Trash"))
|
||||
.build();
|
||||
trash_button.add_css_class("destructive-action");
|
||||
trash_button.add_css_class("pill");
|
||||
|
||||
@@ -108,7 +168,6 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
actions.set_margin_top(6);
|
||||
actions.append(&open_button);
|
||||
actions.append(&rename_button);
|
||||
actions.append(&versions_button);
|
||||
actions.append(&trash_button);
|
||||
|
||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
@@ -120,14 +179,45 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
inner.append(&head);
|
||||
inner.append(&props);
|
||||
inner.append(&offline);
|
||||
inner.append(&sharing);
|
||||
inner.append(&versions_group);
|
||||
inner.append(&actions);
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.child(&inner)
|
||||
.build();
|
||||
// With the pane open and nothing selected (a fresh folder, a cleared
|
||||
// selection) it says so rather than closing: opening and closing it by
|
||||
// itself would move the files under the pointer.
|
||||
let empty_close = gtk4::Button::builder()
|
||||
.icon_name("window-close-symbolic")
|
||||
.tooltip_text(gettext("Close details"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
empty_close.add_css_class("flat");
|
||||
empty_close.add_css_class("circular");
|
||||
let toggle_off = toggle.clone();
|
||||
empty_close.connect_clicked(move |_| toggle_off.set_active(false));
|
||||
let empty_status = adw::StatusPage::builder()
|
||||
.icon_name("sidebar-show-right-symbolic")
|
||||
.title(gettext("No item selected"))
|
||||
.description(gettext("Select a file or folder to see its details."))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
empty_status.add_css_class("compact");
|
||||
let empty = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
empty.append(&empty_close);
|
||||
empty.append(&empty_status);
|
||||
|
||||
let pages = gtk4::Stack::new();
|
||||
pages.add_named(&scroll, Some("details"));
|
||||
pages.add_named(&empty, Some("empty"));
|
||||
pages.set_visible_child_name("empty");
|
||||
let pane = adw::ToolbarView::new();
|
||||
pane.set_content(Some(&scroll));
|
||||
pane.set_content(Some(&pages));
|
||||
pane.add_css_class("background");
|
||||
|
||||
(
|
||||
@@ -140,11 +230,18 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
modified_row,
|
||||
path_row,
|
||||
pin_row,
|
||||
sharing_row,
|
||||
share_button,
|
||||
copy_link_button,
|
||||
versions_group,
|
||||
versions_row,
|
||||
open_button,
|
||||
rename_button,
|
||||
versions_button,
|
||||
trash_button,
|
||||
close_button,
|
||||
toggle,
|
||||
pages,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -152,29 +249,40 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||
/// Connect the details pane: mirror the grid's and list's selection into it, and
|
||||
/// wire its buttons back onto the entry it's showing.
|
||||
pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
// Both views share one model but have their own selection, so watch both.
|
||||
for selection in [
|
||||
ui.details.grid_selection.clone(),
|
||||
ui.details.list_selection.clone(),
|
||||
] {
|
||||
{
|
||||
let ui_sel = ui.clone();
|
||||
selection.connect_selection_changed(move |_, _, _| {
|
||||
// The details pane describes *one* entry. A batch is the bulk bar's
|
||||
// business, so the pane steps aside rather than picking a member of
|
||||
// the selection to speak for the rest.
|
||||
let entries = selected_entries(&ui_sel);
|
||||
match entries.as_slice() {
|
||||
[entry] => show_details(&ui_sel, entry),
|
||||
_ => hide_details(&ui_sel),
|
||||
}
|
||||
sync_bulk_bar(&ui_sel);
|
||||
});
|
||||
ui.details
|
||||
.selection
|
||||
.connect_selection_changed(move |_, _, _| {
|
||||
// The details pane describes *one* entry. A batch is the bulk bar's
|
||||
// business, so the pane steps aside rather than picking a member of
|
||||
// the selection to speak for the rest.
|
||||
let entries = selected_entries(&ui_sel);
|
||||
match entries.as_slice() {
|
||||
[entry] => show_details(&ui_sel, entry),
|
||||
_ => clear_details(&ui_sel),
|
||||
}
|
||||
sync_bulk_bar(&ui_sel);
|
||||
});
|
||||
}
|
||||
|
||||
let ui_close = ui.clone();
|
||||
ui.details.details.close_button.connect_clicked(move |_| {
|
||||
clear_selection(&ui_close);
|
||||
hide_details(&ui_close);
|
||||
ui_close.details.details.toggle.set_active(false);
|
||||
});
|
||||
|
||||
let ui_toggle = ui.clone();
|
||||
ui.details.details.toggle.connect_toggled(move |toggle| {
|
||||
ui_toggle.browser.split.set_show_sidebar(toggle.is_active());
|
||||
});
|
||||
// On a narrow window the pane overlays the files, and a click outside it
|
||||
// closes it; the toggle follows so the next click on it opens it again.
|
||||
let ui_split = ui.clone();
|
||||
ui.browser.split.connect_show_sidebar_notify(move |split| {
|
||||
let toggle = &ui_split.details.details.toggle;
|
||||
if toggle.is_active() != split.shows_sidebar() {
|
||||
toggle.set_active(split.shows_sidebar());
|
||||
}
|
||||
});
|
||||
|
||||
let ui_open = ui.clone();
|
||||
@@ -204,11 +312,28 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
open_versions_dialog(&ui_versions, &entry);
|
||||
}
|
||||
});
|
||||
let ui_share = ui.clone();
|
||||
ui.details.details.share_button.connect_clicked(move |_| {
|
||||
let entry = ui_share.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
open_share_dialog(&ui_share, &entry);
|
||||
}
|
||||
});
|
||||
let ui_link = ui.clone();
|
||||
ui.details
|
||||
.details
|
||||
.copy_link_button
|
||||
.connect_clicked(move |_| {
|
||||
let entry = ui_link.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
copy_entry_link(&ui_link, &entry);
|
||||
}
|
||||
});
|
||||
let ui_trash = ui.clone();
|
||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||
if let Some(entry) = entry {
|
||||
prompt_delete(&ui_trash, &entry);
|
||||
trash_entry(&ui_trash, &entry);
|
||||
}
|
||||
});
|
||||
let ui_pin = ui.clone();
|
||||
@@ -230,14 +355,17 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Reveal the details pane and paint it from `entry`.
|
||||
/// Paint the details pane from `entry`. It shows while the header toggle is on.
|
||||
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.details.details_suppress.set(true);
|
||||
let d = &ui.details.details;
|
||||
d.icon.set_icon_name(Some(icon_base_for(entry)));
|
||||
d.name.set_label(&entry.name);
|
||||
d.kind
|
||||
.set_label(if entry.is_dir { "Folder" } else { "File" });
|
||||
d.kind.set_label(&if entry.is_dir {
|
||||
gettext("Folder")
|
||||
} else {
|
||||
gettext("File")
|
||||
});
|
||||
d.size_row.set_subtitle(&if entry.is_dir {
|
||||
"—".to_string()
|
||||
} else {
|
||||
@@ -251,39 +379,149 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
Some(i) => &rel[..i],
|
||||
None => "",
|
||||
};
|
||||
d.path_row.set_subtitle(if parent.is_empty() {
|
||||
"Proton Drive"
|
||||
d.path_row.set_subtitle(&if parent.is_empty() {
|
||||
gettext("Proton Drive")
|
||||
} else {
|
||||
parent
|
||||
parent.to_string()
|
||||
});
|
||||
// Pinning is per file: the context menu and the selection bar offer it for
|
||||
// files only, so the pane must not offer it for folders either.
|
||||
d.pin_row.set_active(entry.pinned);
|
||||
d.pin_row.set_visible(!entry.is_dir);
|
||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||
d.open_button
|
||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||
d.open_button.set_label(&if entry.is_dir {
|
||||
gettext("Open folder")
|
||||
} else {
|
||||
pgettext("verb", "Open")
|
||||
});
|
||||
// Only files have revisions, and only a connected daemon can fetch them.
|
||||
d.versions_button.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(*ui.mounted.borrow());
|
||||
let mounted = *ui.mounted.borrow();
|
||||
d.versions_group.set_visible(!entry.is_dir);
|
||||
d.versions_button.set_sensitive(mounted);
|
||||
d.share_button.set_sensitive(mounted);
|
||||
d.copy_link_button.set_sensitive(mounted);
|
||||
d.sharing_row.set_subtitle("—");
|
||||
d.versions_row.set_subtitle("—");
|
||||
ui.details.details_suppress.set(false);
|
||||
|
||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
|
||||
// which fires on the *first* press of a double-click; mutating the widget
|
||||
// tree here cancels GtkGridView's multi-press tracking, so the second press
|
||||
// restarts the count and `activate` never fires — the folder needs two
|
||||
// double-clicks. Deferring lets the click gesture finish first. Guard on the
|
||||
// entry still being present so a navigation that clears it wins the race.
|
||||
let ui = ui.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
if ui.details.details_entry.borrow().is_some() {
|
||||
ui.browser.split.set_show_sidebar(true);
|
||||
if mounted {
|
||||
load_details_extras(ui, entry);
|
||||
}
|
||||
// Only the content changes. Whether the pane shows is the toggle's call:
|
||||
// this runs from `selection_changed`, on the *first* press of a
|
||||
// double-click, and sliding the pane in there would move the files so the
|
||||
// second press lands elsewhere and the folder never opens.
|
||||
d.pages.set_visible_child_name("details");
|
||||
}
|
||||
|
||||
/// Fill the Sharing and Versions rows, which need the network. The answers
|
||||
/// land only if the pane still shows the same entry.
|
||||
fn load_details_extras(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
let by_uid = entry.path.is_empty() && !entry.uid.is_empty();
|
||||
let rel = entry_rel(ui, entry);
|
||||
let share_req = if by_uid {
|
||||
Request::ListShareByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::ListShare { path: rel.clone() }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), share_req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::Share { entries, link })) => {
|
||||
sharing_summary(entries.len(), link.is_some())
|
||||
}
|
||||
_ => gettext("Couldn't load sharing"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.sharing_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
|
||||
if entry.is_dir {
|
||||
return;
|
||||
}
|
||||
let req = if by_uid {
|
||||
Request::ListRevisionsByUid {
|
||||
uid: entry.uid.clone(),
|
||||
}
|
||||
} else {
|
||||
Request::ListRevisions { path: rel }
|
||||
};
|
||||
let rx = spawn_request(ui.dirs.control_socket(), req);
|
||||
let (ui_c, uid) = (ui.clone(), entry.uid.clone());
|
||||
glib::spawn_future_local(async move {
|
||||
let text = match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => versions_summary(&items),
|
||||
_ => gettext("Couldn't load versions"),
|
||||
};
|
||||
if details_showing(&ui_c, &uid) {
|
||||
ui_c.details.details.versions_row.set_subtitle(&text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide the details pane and forget the entry it was showing, so a stale entry
|
||||
/// can't be acted on after the listing moves on.
|
||||
pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
||||
ui.browser.split.set_show_sidebar(false);
|
||||
fn details_showing(ui: &Rc<Ui>, uid: &str) -> bool {
|
||||
ui.details
|
||||
.details_entry
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.uid == uid)
|
||||
}
|
||||
|
||||
/// "Only you", "3 people", "Public link" or both.
|
||||
pub(crate) fn sharing_summary(people: usize, link: bool) -> String {
|
||||
let n = people as u64;
|
||||
match (people, link) {
|
||||
(0, false) => gettext("Only you"),
|
||||
(0, true) => gettext("Anyone with the link"),
|
||||
(_, false) => ngettext_f("Shared with {n} person", "Shared with {n} people", n, &[]),
|
||||
(_, true) => ngettext_f(
|
||||
"Shared with {n} person · Public link",
|
||||
"Shared with {n} people · Public link",
|
||||
n,
|
||||
&[],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 versions · latest 2 Sep 2026".
|
||||
pub(crate) fn versions_summary(items: &[RevisionInfo]) -> String {
|
||||
let latest = items.iter().map(|r| r.created).max();
|
||||
match (items.len(), latest) {
|
||||
(0, _) | (_, None) => gettext("No earlier versions"),
|
||||
// Translators: {date} is the date of the only version.
|
||||
(1, Some(t)) => gettext_f("1 version · {date}", &[("date", &format_modified(t))]),
|
||||
// Translators: {date} is the date of the newest version.
|
||||
(n, Some(t)) => ngettext_f(
|
||||
"{n} version · latest {date}",
|
||||
"{n} versions · latest {date}",
|
||||
n as u64,
|
||||
&[("date", &format_modified(t))],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the pane for `entry`, turning the header toggle on.
|
||||
pub(crate) fn open_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
ui.details.details.toggle.set_active(true);
|
||||
show_details(ui, entry);
|
||||
}
|
||||
|
||||
/// Alt+Enter: show or hide the pane for the selected entry.
|
||||
pub(crate) fn toggle_details(ui: &Rc<Ui>) {
|
||||
let toggle = &ui.details.details.toggle;
|
||||
toggle.set_active(!toggle.is_active());
|
||||
}
|
||||
|
||||
/// Forget the entry the pane was showing, so a stale entry can't be acted on
|
||||
/// after the listing moves on. An open pane stays open and says nothing is
|
||||
/// selected.
|
||||
pub(crate) fn clear_details(ui: &Rc<Ui>) {
|
||||
ui.details.details.pages.set_visible_child_name("empty");
|
||||
*ui.details.details_entry.borrow_mut() = None;
|
||||
}
|
||||
|
||||
@@ -293,3 +531,35 @@ pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
||||
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||
ui.details.details_entry.borrow().clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sharing_summary_names_people_and_link() {
|
||||
assert_eq!(sharing_summary(0, false), "Only you");
|
||||
assert_eq!(sharing_summary(0, true), "Anyone with the link");
|
||||
assert_eq!(sharing_summary(1, false), "Shared with 1 person");
|
||||
assert_eq!(
|
||||
sharing_summary(3, true),
|
||||
"Shared with 3 people · Public link"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_summary_counts_revisions() {
|
||||
let rev = |created| RevisionInfo {
|
||||
id: String::new(),
|
||||
is_active: false,
|
||||
created,
|
||||
size_on_storage: 0,
|
||||
claimed_size: None,
|
||||
claimed_modified: None,
|
||||
signed_by: None,
|
||||
has_thumbnails: false,
|
||||
};
|
||||
assert_eq!(versions_summary(&[]), "No earlier versions");
|
||||
assert!(versions_summary(&[rev(10), rev(20)]).starts_with("2 versions · latest "));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user