Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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)
|
||||
@@ -199,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 }}
|
||||
@@ -467,6 +467,12 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -1814,6 +1820,26 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext-rs"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5857dc1b7f0fee86961de833f434e29494d72af102ce5355738c0664222bdf"
|
||||
dependencies = [
|
||||
"gettext-sys",
|
||||
"locale_config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext-sys"
|
||||
version = "0.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"temp-dir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
@@ -2829,6 +2855,19 @@ version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "locale_config"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
"regex",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -2868,6 +2907,15 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3189,6 +3237,35 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||
dependencies = [
|
||||
"malloc_buf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc-foundation"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
|
||||
dependencies = [
|
||||
"block",
|
||||
"objc",
|
||||
"objc_id",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc_id"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
|
||||
dependencies = [
|
||||
"objc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ocb3"
|
||||
version = "0.1.0"
|
||||
@@ -3372,7 +3449,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "1.11.0"
|
||||
version = "2.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -3387,7 +3464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "1.11.0"
|
||||
version = "2.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbus-secret-service-keyring-store",
|
||||
@@ -3411,7 +3488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "1.11.0"
|
||||
version = "2.2.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"fuser",
|
||||
@@ -3431,10 +3508,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "1.11.0"
|
||||
version = "2.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
"gettext-rs",
|
||||
"gtk4",
|
||||
"kamadak-exif",
|
||||
"ksni",
|
||||
@@ -3668,9 +3746,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-drive-rs"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46cbe05623d9b73bd48b1473f42d43180142b2ee676b1cb9e9a06b562c60ba26"
|
||||
checksum = "34d587f1ef2ffa9183f48f0cca407c4b641b636be516dde8b13e082de5c83757"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
@@ -3695,9 +3773,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proton-sdk"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7310d93bed440dfe4fd60306923f6a491901ee0d618b65ace4e5e22ff0387c1f"
|
||||
checksum = "7decd4f7ec188815192a1d2a16a83f2d52944141c5b8e648184ef17061c9ecce"
|
||||
dependencies = [
|
||||
"aes-gcm 0.11.1",
|
||||
"async-trait",
|
||||
@@ -4055,6 +4133,18 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
@@ -4796,6 +4886,12 @@ version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "temp-dir"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.11.0"
|
||||
version = "2.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
@@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||
categories = ["filesystem", "command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
proton-sdk = { version = "0.6.5" }
|
||||
proton-drive-rs = { version = "0.6.5" }
|
||||
proton-sdk = { version = "0.6.6" }
|
||||
proton-drive-rs = { version = "0.6.6" }
|
||||
|
||||
# Internal crates.
|
||||
pdfs-core = { path = "crates/pdfs-core" }
|
||||
|
||||
@@ -32,8 +32,8 @@ sudo apt install ./proton-drive-linux_*.deb
|
||||
# Fedora
|
||||
sudo dnf install ./proton-drive-linux-*.rpm
|
||||
|
||||
# Arch Linux
|
||||
cd packaging && makepkg -fi
|
||||
# Arch Linux (AUR)
|
||||
paru -S proton-drive-for-linux # or -git / -bin
|
||||
```
|
||||
|
||||
Packages for each release are attached to the [latest
|
||||
@@ -55,7 +55,7 @@ pdfs-app # the desktop app
|
||||
- [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) ·
|
||||
[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)
|
||||
|
||||
@@ -348,11 +348,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>
|
||||
@@ -431,8 +435,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
|
||||
```
|
||||
@@ -483,6 +503,10 @@ Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](
|
||||
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
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
|
||||
@@ -13,8 +13,9 @@ use pdfs_core::auth;
|
||||
use pdfs_core::cache::ContentCache;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||
RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary,
|
||||
ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest,
|
||||
Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase,
|
||||
pending_summary,
|
||||
};
|
||||
use pdfs_core::db::Db;
|
||||
use pdfs_core::service;
|
||||
@@ -145,11 +146,11 @@ enum Command {
|
||||
/// Skip this many photos from the start of the timeline.
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
/// List only favourited photos.
|
||||
/// List only favorited photos.
|
||||
#[arg(long)]
|
||||
favorites: bool,
|
||||
},
|
||||
/// Add or remove a photo's favourite mark.
|
||||
/// Add or remove a photo's favorite mark.
|
||||
Favorite {
|
||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||
uid: String,
|
||||
@@ -187,7 +188,7 @@ enum Command {
|
||||
/// and rewrites them at the right time.
|
||||
///
|
||||
/// Proton cannot edit a sealed capture time, so a rewrite means re-uploading
|
||||
/// the photo and trashing the original — favourites and albums are carried
|
||||
/// the photo and trashing the original — favorites and albums are carried
|
||||
/// across, and the original goes to Proton's trash, not away. Run it with
|
||||
/// `--dry-run` first.
|
||||
RedatePhotos {
|
||||
@@ -229,6 +230,43 @@ enum Command {
|
||||
#[arg(long, default_value_t = 0)]
|
||||
offset: usize,
|
||||
},
|
||||
/// Create a photo album and print its uid.
|
||||
NewAlbum {
|
||||
/// Album name.
|
||||
name: String,
|
||||
},
|
||||
/// Rename a photo album.
|
||||
RenameAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// New name.
|
||||
name: String,
|
||||
},
|
||||
/// Delete a photo album. Its photos stay in the timeline.
|
||||
DeleteAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Also delete photos that exist only in this album (saved from a
|
||||
/// shared album); without it the server refuses to delete such an album.
|
||||
#[arg(long)]
|
||||
with_photos: bool,
|
||||
},
|
||||
/// Add photos to an album.
|
||||
AddToAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Photo node uids (from `pdfs photos`).
|
||||
#[arg(required = true)]
|
||||
photos: Vec<String>,
|
||||
},
|
||||
/// Take photos out of an album. They stay in the timeline.
|
||||
RemoveFromAlbum {
|
||||
/// Album node uid in `volume~link` form (from `pdfs albums`).
|
||||
uid: String,
|
||||
/// Photo node uids (from `pdfs album <uid>`).
|
||||
#[arg(required = true)]
|
||||
photos: Vec<String>,
|
||||
},
|
||||
/// Download a photo by uid (`volume~link`) and print its cached path.
|
||||
OpenPhoto {
|
||||
/// Photo node uid in `volume~link` form (from `pdfs photos`).
|
||||
@@ -241,6 +279,9 @@ enum Command {
|
||||
/// Max hits to return.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
/// Only search below this folder (under the mountpoint).
|
||||
#[arg(long = "in", value_name = "FOLDER")]
|
||||
scope: Option<PathBuf>,
|
||||
},
|
||||
/// Rename a file or folder via the running daemon.
|
||||
Rename {
|
||||
@@ -327,6 +368,11 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
action: SyncCmd,
|
||||
},
|
||||
/// List `(sync-conflict …)` copies, or resolve one.
|
||||
Conflicts {
|
||||
#[command(subcommand)]
|
||||
action: Option<ConflictCmd>,
|
||||
},
|
||||
/// Share a file or folder with Proton and/or external email addresses.
|
||||
Share {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
@@ -357,14 +403,18 @@ enum Command {
|
||||
/// New role: viewer, editor or admin.
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member or pending invitation from a node's share.
|
||||
/// Stop sharing a node, or remove one member or pending invitation.
|
||||
///
|
||||
/// With only a path, removes the public link, every invitation and every
|
||||
/// member. With an id and kind (from `pdfs members`), removes that entry.
|
||||
Unshare {
|
||||
/// File/folder path, inside the mountpoint or relative to it.
|
||||
path: PathBuf,
|
||||
/// Entry id (membership or invitation id), from `pdfs members`.
|
||||
id: String,
|
||||
#[arg(requires = "kind")]
|
||||
id: Option<String>,
|
||||
/// Entry kind: member, proton or external.
|
||||
kind: ShareKindArg,
|
||||
kind: Option<ShareKindArg>,
|
||||
},
|
||||
/// Inspect and restore a file's earlier versions.
|
||||
Versions {
|
||||
@@ -521,6 +571,22 @@ enum DeviceCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ConflictCmd {
|
||||
/// Decide which side of a conflict to keep. Whatever is removed goes to Trash.
|
||||
Resolve {
|
||||
/// The conflict copy, absolute or relative to the mountpoint.
|
||||
path: PathBuf,
|
||||
/// `original` trashes the copy; `copy` trashes the original and gives the
|
||||
/// copy its name; `both` renames the copy to `--name`.
|
||||
#[arg(long, value_parser = ["original", "copy", "both"])]
|
||||
keep: String,
|
||||
/// New name for the copy with `--keep both`.
|
||||
#[arg(long, required_if_eq("keep", "both"))]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SyncCmd {
|
||||
/// Add a local folder to this machine's device and upload its contents.
|
||||
@@ -543,6 +609,38 @@ enum SyncCmd {
|
||||
/// Optional synced-folder id; omit to reconcile every folder.
|
||||
id: Option<i64>,
|
||||
},
|
||||
/// Pause syncing: queued uploads and mirror folders wait, reads keep working.
|
||||
Pause {
|
||||
/// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`.
|
||||
/// Omit to pause until `pdfs sync resume`.
|
||||
#[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")]
|
||||
duration: Option<u64>,
|
||||
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// Resume syncing after `pdfs sync pause`.
|
||||
Resume {
|
||||
/// Resume only this folder, paused with `sync pause --folder`.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// Cap upload and download speed, or show the caps with no options.
|
||||
Limit {
|
||||
/// Upload cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||
#[arg(long, value_parser = parse_rate)]
|
||||
up: Option<u64>,
|
||||
/// Download cap per second, e.g. `500K` or `2M`; `0` removes it.
|
||||
#[arg(long, value_parser = parse_rate)]
|
||||
down: Option<u64>,
|
||||
},
|
||||
/// List uploads and changes not yet on Proton Drive.
|
||||
Queue,
|
||||
/// Retry a queued operation now (by id from `sync queue`), or every failed one.
|
||||
Retry {
|
||||
/// Queue id; omit to retry every failed operation.
|
||||
id: Option<i64>,
|
||||
},
|
||||
/// Switch a folder between full-copy sync and on-demand (FUSE, no local storage).
|
||||
Mode {
|
||||
/// Synced-folder id (from `sync list`).
|
||||
@@ -557,6 +655,10 @@ enum SyncCmd {
|
||||
/// Accept every proposed local path without asking.
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Restore another computer's folders instead, by device uid (from
|
||||
/// `pdfs devices list`).
|
||||
#[arg(long)]
|
||||
device: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -675,8 +777,28 @@ fn main() -> Result<()> {
|
||||
Command::ImportStatus => cmd_import_status(),
|
||||
Command::CancelImport => cmd_cancel_import(),
|
||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||
Command::NewAlbum { name } => cmd_new_album(name),
|
||||
Command::RenameAlbum { uid, name } => {
|
||||
ok_or_bail(control_request(CtlRequest::RenameAlbum { uid, name })?)
|
||||
}
|
||||
Command::DeleteAlbum { uid, with_photos } => {
|
||||
ok_or_bail(control_request(CtlRequest::DeleteAlbum {
|
||||
uid,
|
||||
delete_photos: with_photos,
|
||||
})?)
|
||||
}
|
||||
Command::AddToAlbum { uid, photos } => {
|
||||
cmd_album_change(CtlRequest::AddToAlbum { uid, photos }, "added")
|
||||
}
|
||||
Command::RemoveFromAlbum { uid, photos } => {
|
||||
cmd_album_change(CtlRequest::RemoveFromAlbum { uid, photos }, "removed")
|
||||
}
|
||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||
Command::Search { query, limit } => cmd_search(query, limit),
|
||||
Command::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
} => cmd_search(query, limit, scope),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
@@ -691,6 +813,7 @@ fn main() -> Result<()> {
|
||||
Command::Refresh { target, full } => cmd_refresh(target, full),
|
||||
Command::Devices { action } => cmd_devices(action),
|
||||
Command::Sync { action } => cmd_sync(action),
|
||||
Command::Conflicts { action } => cmd_conflicts(action),
|
||||
Command::Share {
|
||||
path,
|
||||
emails,
|
||||
@@ -798,6 +921,10 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
Some(pending) => format!("{} → {pending}", f.mode),
|
||||
None => f.mode.clone(),
|
||||
};
|
||||
let mode = match f.paused {
|
||||
true => format!("{mode}, paused"),
|
||||
false => mode,
|
||||
};
|
||||
println!(
|
||||
"[{}] {} ({mode}, {}, synced: {sync})",
|
||||
f.id, f.local_path, f.state
|
||||
@@ -834,10 +961,114 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?,
|
||||
SyncCmd::Pause {
|
||||
folder: Some(id), ..
|
||||
} => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||
id,
|
||||
paused: true,
|
||||
})?)?,
|
||||
SyncCmd::Resume { folder: Some(id) } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused {
|
||||
id,
|
||||
paused: false,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Pause {
|
||||
duration,
|
||||
folder: None,
|
||||
} => {
|
||||
let until = duration.map(|secs| unix_now().saturating_add(secs as i64));
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||
paused: true,
|
||||
until,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Queue => return cmd_sync_queue(),
|
||||
SyncCmd::Limit { up, down } => return cmd_sync_limit(up, down),
|
||||
SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?,
|
||||
SyncCmd::Resume { folder: None } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncPaused {
|
||||
paused: false,
|
||||
until: None,
|
||||
})?)?
|
||||
}
|
||||
SyncCmd::Mode { id, mode } => {
|
||||
ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)?
|
||||
}
|
||||
SyncCmd::Restore { yes } => return cmd_sync_restore(yes),
|
||||
SyncCmd::Restore { yes, device } => return cmd_sync_restore(yes, device),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pdfs conflicts`: list conflict copies, or resolve one.
|
||||
fn cmd_conflicts(action: Option<ConflictCmd>) -> Result<()> {
|
||||
if let Some(ConflictCmd::Resolve { path, keep, name }) = action {
|
||||
let keep = match keep.as_str() {
|
||||
"original" => ConflictKeep::Original,
|
||||
"copy" => ConflictKeep::Copy,
|
||||
_ => ConflictKeep::Both {
|
||||
name: name.unwrap_or_default(),
|
||||
},
|
||||
};
|
||||
let path = path_arg(&path)?;
|
||||
return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?);
|
||||
}
|
||||
let response = control_request(CtlRequest::ListConflicts)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."),
|
||||
CtlResponse::Conflicts { items } => {
|
||||
for c in items {
|
||||
println!("{}", c.path);
|
||||
let verdict = match (c.original_exists, c.identical) {
|
||||
(false, _) => "original is gone".to_string(),
|
||||
(true, true) => format!("identical to {}", c.original_path),
|
||||
(true, false) => format!(
|
||||
"differs from {} ({} vs {})",
|
||||
c.original_path,
|
||||
human_bytes(c.size),
|
||||
human_bytes(c.original_size.unwrap_or_default()),
|
||||
),
|
||||
};
|
||||
println!(" {verdict}");
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pdfs sync queue`: what has not reached Proton Drive yet, and why.
|
||||
fn cmd_sync_queue() -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListPendingOps)?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
match response {
|
||||
CtlResponse::PendingOps { items } if items.is_empty() => {
|
||||
println!("Nothing queued: everything is on Proton Drive.");
|
||||
}
|
||||
CtlResponse::PendingOps { items } => {
|
||||
let now = unix_now();
|
||||
for op in items {
|
||||
let state = match (op.parked, op.next_attempt_at) {
|
||||
(true, _) => "waiting for its final name".to_string(),
|
||||
(false, Some(at)) if at > now => {
|
||||
format!("next try in {}", human_age((at - now) as u64))
|
||||
}
|
||||
(false, _) => "due now".to_string(),
|
||||
};
|
||||
println!("[{}] {} {} ({state})", op.id, op.kind, op.path);
|
||||
if let Some(error) = &op.last_error {
|
||||
println!(" failed {} time(s): {error}", op.attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -848,8 +1079,13 @@ fn cmd_sync(action: SyncCmd) -> Result<()> {
|
||||
/// The daemon only ever *proposes* local paths — a path recorded by another
|
||||
/// machine may name a home directory that does not exist here — so the default
|
||||
/// flow is propose-and-confirm. `--yes` accepts the proposals for scripting.
|
||||
fn cmd_sync_restore(yes: bool) -> Result<()> {
|
||||
let response = control_request(CtlRequest::ListRestorableFolders)?;
|
||||
fn cmd_sync_restore(yes: bool, device: Option<String>) -> Result<()> {
|
||||
let response = control_request(match &device {
|
||||
Some(device) => CtlRequest::ListDeviceRestorableFolders {
|
||||
device: device.clone(),
|
||||
},
|
||||
None => CtlRequest::ListRestorableFolders,
|
||||
})?;
|
||||
if emit_json(&response)? {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -899,8 +1135,12 @@ fn cmd_sync_restore(yes: bool) -> Result<()> {
|
||||
println!("Nothing selected.");
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(control_request(CtlRequest::RestoreSyncFolders {
|
||||
items: chosen,
|
||||
ok_or_bail(control_request(match device {
|
||||
Some(device) => CtlRequest::RestoreDeviceFolders {
|
||||
device,
|
||||
items: chosen,
|
||||
},
|
||||
None => CtlRequest::RestoreSyncFolders { items: chosen },
|
||||
})?)
|
||||
}
|
||||
|
||||
@@ -1038,12 +1278,17 @@ fn cmd_share_role(path: PathBuf, id: String, kind: ShareKindArg, role: String) -
|
||||
})?)
|
||||
}
|
||||
|
||||
fn cmd_unshare(path: PathBuf, id: String, kind: ShareKindArg) -> Result<()> {
|
||||
ok_or_bail(control_request(CtlRequest::RemoveShareEntry {
|
||||
path: path_arg(&path)?,
|
||||
id,
|
||||
kind: kind.to_kind(),
|
||||
})?)
|
||||
fn cmd_unshare(path: PathBuf, id: Option<String>, kind: Option<ShareKindArg>) -> Result<()> {
|
||||
let path = path_arg(&path)?;
|
||||
let request = match (id, kind) {
|
||||
(Some(id), Some(kind)) => CtlRequest::RemoveShareEntry {
|
||||
path,
|
||||
id,
|
||||
kind: kind.to_kind(),
|
||||
},
|
||||
_ => CtlRequest::StopSharing { path },
|
||||
};
|
||||
ok_or_bail(control_request(request)?)
|
||||
}
|
||||
|
||||
fn cmd_public_link(action: PublicLinkCmd) -> Result<()> {
|
||||
@@ -1472,9 +1717,15 @@ fn cmd_status() -> Result<()> {
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
staged_oldest_secs,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
}) => {
|
||||
let state = if online { "" } else { ", offline" };
|
||||
let state = match (online, paused) {
|
||||
(_, true) => ", sync paused",
|
||||
(false, false) => ", offline",
|
||||
(true, false) => "",
|
||||
};
|
||||
let queued = match pending_summary(pending_uploads, pending_changes) {
|
||||
Some(s) => format!(", {s}"),
|
||||
None => String::new(),
|
||||
@@ -1490,6 +1741,10 @@ fn cmd_status() -> Result<()> {
|
||||
human_age(staged_oldest_secs),
|
||||
);
|
||||
}
|
||||
if let Some(until) = paused_until {
|
||||
let left = until.saturating_sub(unix_now()).max(0) as u64;
|
||||
println!("Paused sync resumes in {}", human_age(left.max(1)));
|
||||
}
|
||||
if parked_uploads > 0 {
|
||||
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
||||
}
|
||||
@@ -1682,6 +1937,8 @@ fn mount_once(mountpoint: Option<PathBuf>) -> Result<pdfs_fuse::MountOutcome> {
|
||||
pdfs_fuse::MountOptions {
|
||||
username,
|
||||
sweep_mode: config.resolved_conflict_sweep(),
|
||||
upload_limit: config.upload_limit.unwrap_or(0),
|
||||
download_limit: config.download_limit.unwrap_or(0),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1886,6 +2143,42 @@ fn cmd_albums() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_new_album(name: String) -> Result<()> {
|
||||
match control_request(CtlRequest::CreateAlbum { name })? {
|
||||
CtlResponse::AlbumCreated { uid } => println!("{uid}"),
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an album add or remove and report it per photo; any failed photo
|
||||
/// fails the command.
|
||||
fn cmd_album_change(request: CtlRequest, verb: &str) -> Result<()> {
|
||||
match control_request(request)? {
|
||||
CtlResponse::AlbumChanged { changed, failed } => {
|
||||
println!("{} {verb}", photo_count(changed.len()));
|
||||
for failure in &failed {
|
||||
eprintln!("{}: {}", failure.uid, failure.message);
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
bail!("{} failed", photo_count(failed.len()));
|
||||
}
|
||||
}
|
||||
CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)),
|
||||
other => bail!("unexpected response: {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn photo_count(n: usize) -> String {
|
||||
if n == 1 {
|
||||
"1 photo".to_string()
|
||||
} else {
|
||||
format!("{n} photos")
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_import_google_photos(archives: Vec<PathBuf>, dry_run: bool, wait: bool) -> Result<()> {
|
||||
// The daemon opens the archives itself off the shared filesystem, so it gets
|
||||
// absolute paths — the same arrangement `pdfs upload` uses.
|
||||
@@ -2112,8 +2405,13 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_search(query: String, limit: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::Search { query, limit })? {
|
||||
fn cmd_search(query: String, limit: usize, scope: Option<PathBuf>) -> Result<()> {
|
||||
let scope = scope.map(|p| path_arg(&p)).transpose()?;
|
||||
match control_request(CtlRequest::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
})? {
|
||||
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
||||
CtlResponse::SearchResults { hits } => {
|
||||
for h in hits {
|
||||
@@ -2384,6 +2682,72 @@ fn path_arg(path: &Path) -> Result<String> {
|
||||
|
||||
/// Format an age in seconds for a report line, coarsely — the point of the
|
||||
/// number is whether it is minutes or weeks.
|
||||
/// Parse a `sync pause --for` duration: a whole number followed by `m`, `h` or
|
||||
/// `d`. Returns seconds.
|
||||
fn parse_pause_duration(text: &str) -> Result<u64, String> {
|
||||
let text = text.trim();
|
||||
let split = text.char_indices().last().map_or(0, |(at, _)| at);
|
||||
let (number, unit) = text.split_at(split);
|
||||
let per_unit: u64 = match unit {
|
||||
"m" => 60,
|
||||
"h" => 3600,
|
||||
"d" => 86_400,
|
||||
_ => return Err("use a number followed by m, h or d, e.g. 30m".to_string()),
|
||||
};
|
||||
match number.parse::<u64>() {
|
||||
Ok(n) if n > 0 => Ok(n.saturating_mul(per_unit)),
|
||||
_ => Err("use a positive whole number, e.g. 30m".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a bytes-per-second rate: a whole number with an optional `K`, `M` or
|
||||
/// `G` suffix in the same binary units `human_bytes` prints.
|
||||
fn parse_rate(text: &str) -> Result<u64, String> {
|
||||
let text = text.trim();
|
||||
let (number, scale) = match text.char_indices().last() {
|
||||
Some((at, 'K' | 'k')) => (&text[..at], 1024),
|
||||
Some((at, 'M' | 'm')) => (&text[..at], 1_048_576),
|
||||
Some((at, 'G' | 'g')) => (&text[..at], 1_073_741_824),
|
||||
_ => (text, 1),
|
||||
};
|
||||
number
|
||||
.parse::<u64>()
|
||||
.map(|n| n.saturating_mul(scale))
|
||||
.map_err(|_| "use a whole number with an optional K, M or G, e.g. 2M".to_string())
|
||||
}
|
||||
|
||||
fn human_rate(rate: u64) -> String {
|
||||
if rate == 0 {
|
||||
"unlimited".to_string()
|
||||
} else {
|
||||
format!("{}/s", human_bytes(rate))
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdfs sync limit`: set the bandwidth caps, keeping whichever one is not
|
||||
/// given, or print both when neither is.
|
||||
fn cmd_sync_limit(up: Option<u64>, down: Option<u64>) -> Result<()> {
|
||||
let config = AppDirs::new()?.load_config();
|
||||
let upload = up.or(config.upload_limit).unwrap_or(0);
|
||||
let download = down.or(config.download_limit).unwrap_or(0);
|
||||
if up.is_none() && down.is_none() {
|
||||
println!("Upload {}", human_rate(upload));
|
||||
println!("Download {}", human_rate(download));
|
||||
return Ok(());
|
||||
}
|
||||
ok_or_bail(control_request(CtlRequest::SetBandwidthLimits {
|
||||
upload,
|
||||
download,
|
||||
})?)
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn human_age(secs: u64) -> String {
|
||||
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
||||
for (unit, scale) in UNITS {
|
||||
@@ -2847,6 +3211,40 @@ fn control_request(req: CtlRequest) -> Result<CtlResponse> {
|
||||
.with_context(|| format!("talk to mount daemon at {}", socket.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pause_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pause_durations_read_minutes_hours_and_days() {
|
||||
assert_eq!(parse_pause_duration("30m"), Ok(1800));
|
||||
assert_eq!(parse_pause_duration("1h"), Ok(3600));
|
||||
assert_eq!(parse_pause_duration("2d"), Ok(172_800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_durations_reject_what_they_cannot_read() {
|
||||
assert!(parse_pause_duration("").is_err());
|
||||
assert!(parse_pause_duration("0h").is_err());
|
||||
assert!(parse_pause_duration("1w").is_err());
|
||||
assert!(parse_pause_duration("h").is_err());
|
||||
assert!(parse_pause_duration("-1h").is_err());
|
||||
assert!(parse_pause_duration("1ä").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rates_read_plain_bytes_and_binary_suffixes() {
|
||||
assert_eq!(parse_rate("0"), Ok(0));
|
||||
assert_eq!(parse_rate("1500"), Ok(1500));
|
||||
assert_eq!(parse_rate("500K"), Ok(512_000));
|
||||
assert_eq!(parse_rate("2m"), Ok(2_097_152));
|
||||
assert!(parse_rate("").is_err());
|
||||
assert!(parse_rate("M").is_err());
|
||||
assert!(parse_rate("1.5M").is_err());
|
||||
assert!(parse_rate("2MB").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod diagnose_tests {
|
||||
use super::*;
|
||||
@@ -2977,3 +3375,36 @@ mod login_wait_tests {
|
||||
assert!(shared_by_line(&entry).unwrap().contains("WARNING"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unshare_tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn unshare_with_only_a_path_stops_sharing() {
|
||||
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs/report.pdf"]).unwrap();
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Command::Unshare {
|
||||
id: None,
|
||||
kind: None,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unshare_with_an_id_needs_its_kind() {
|
||||
assert!(Cli::try_parse_from(["pdfs", "unshare", "docs", "abc"]).is_err());
|
||||
let cli = Cli::try_parse_from(["pdfs", "unshare", "docs", "abc", "member"]).unwrap();
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Command::Unshare {
|
||||
id: Some(_),
|
||||
kind: Some(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -298,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
|
||||
@@ -1854,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(),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub enum Request {
|
||||
/// timeline. Like `kind`, the offset is relative to the filtered set.
|
||||
#[serde(default)]
|
||||
range: Option<(i64, i64)>,
|
||||
/// Restrict the page to favourited photos. Older front-ends omit it and
|
||||
/// Restrict the page to favorited photos. Older front-ends omit it and
|
||||
/// get the whole timeline, as before.
|
||||
#[serde(default)]
|
||||
favorites: bool,
|
||||
@@ -94,6 +94,25 @@ pub enum Request {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
},
|
||||
/// Create an album named `name`. Replies with [`Response::AlbumCreated`].
|
||||
CreateAlbum { name: String },
|
||||
/// Rename the album `uid`. Replies with [`Response::Ok`].
|
||||
RenameAlbum { uid: String, name: String },
|
||||
/// Delete the album `uid`. Its photos stay in the timeline; with
|
||||
/// `delete_photos` set, photos that exist only in the album (saved from a
|
||||
/// shared album) are deleted with it, which the server otherwise refuses.
|
||||
/// Replies with [`Response::Ok`].
|
||||
DeleteAlbum {
|
||||
uid: String,
|
||||
#[serde(default)]
|
||||
delete_photos: bool,
|
||||
},
|
||||
/// Add timeline photos to the album `uid`. Replies with
|
||||
/// [`Response::AlbumChanged`].
|
||||
AddToAlbum { uid: String, photos: Vec<String> },
|
||||
/// Take photos out of the album `uid`. They stay in the timeline: this is
|
||||
/// not a trash. Replies with [`Response::AlbumChanged`].
|
||||
RemoveFromAlbum { uid: String, photos: Vec<String> },
|
||||
/// Fetch thumbnails for the given photo uids, downloading the ones not
|
||||
/// already cached (one batched round-trip) and replying with their on-disk
|
||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||
@@ -181,7 +200,7 @@ pub enum Request {
|
||||
/// time by more than a day: `IMG-20230219-WA0001.jpg` filed under 2026 is
|
||||
/// the signature of an import whose metadata sidecars did not match.
|
||||
///
|
||||
/// Favourites and album memberships are carried across; the original is
|
||||
/// Favorites and album memberships are carried across; the original is
|
||||
/// **trashed**, not deleted, so a bad run is recoverable from Proton's
|
||||
/// trash. With `dry_run` nothing is uploaded or trashed and the report says
|
||||
/// what would have been done.
|
||||
@@ -245,8 +264,15 @@ pub enum Request {
|
||||
uid: Option<String>,
|
||||
},
|
||||
/// Full-text search node names against the daemon's local metadata index.
|
||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||
Search { query: String, limit: usize },
|
||||
/// `limit` caps the number of hits returned. `scope`, a mountpoint-relative
|
||||
/// folder, restricts hits to that subtree; older front-ends omit it and
|
||||
/// search everything. Replies with [`Response::SearchResults`].
|
||||
Search {
|
||||
query: String,
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
},
|
||||
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
||||
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
||||
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
||||
@@ -299,6 +325,10 @@ pub enum Request {
|
||||
/// persist it to config so the next mount keeps it. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SetCacheBudget { bytes: u64 },
|
||||
/// Cap uploads and downloads at these bytes per second (`0` = no cap),
|
||||
/// live for running transfers, and persist both to config so the next
|
||||
/// mount keeps them. Replies with [`Response::Ok`].
|
||||
SetBandwidthLimits { upload: u64, download: u64 },
|
||||
/// Report on the health of the metadata database and content cache: sizes,
|
||||
/// row counts, and — when `deep` — SQLite's own integrity check. Replies
|
||||
/// with [`Response::CacheReport`].
|
||||
@@ -381,9 +411,31 @@ pub enum Request {
|
||||
/// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies
|
||||
/// with [`Response::Ok`].
|
||||
SetSyncFolderMode { id: i64, mode: String },
|
||||
/// Pause or resume one mirror folder's reconcile, independent of the
|
||||
/// global pause. Persisted until resumed. Replies with [`Response::Ok`].
|
||||
SetSyncFolderPaused { id: i64, paused: bool },
|
||||
/// Force a reconcile pass: one folder by id, or all when `id` is `None`.
|
||||
/// Replies with [`Response::Ok`].
|
||||
SyncNow { id: Option<i64> },
|
||||
/// Pause or resume syncing: queued uploads and changes stop draining and
|
||||
/// mirror folders stop reconciling, while reads through the mount keep
|
||||
/// working. `until` is the Unix second a pause ends by itself; `None`
|
||||
/// pauses until resumed. Ignored when resuming. Replies with
|
||||
/// [`Response::Ok`].
|
||||
SetSyncPaused { paused: bool, until: Option<i64> },
|
||||
/// List the queue of uploads and changes not yet on the remote. Replies
|
||||
/// with [`Response::PendingOps`].
|
||||
ListPendingOps,
|
||||
/// Retry a backed-off queued op now instead of waiting out its backoff;
|
||||
/// every failed op when `id` is `None`. Replies with [`Response::Ok`].
|
||||
RetryPendingOp { id: Option<i64> },
|
||||
/// List the `(sync-conflict …)` copies under My Files. Replies with
|
||||
/// [`Response::Conflicts`].
|
||||
ListConflicts,
|
||||
/// Resolve one conflict copy (`path` mountpoint-relative or absolute).
|
||||
/// Everything removed goes to the Proton trash. Replies with
|
||||
/// [`Response::Ok`].
|
||||
ResolveConflict { path: String, keep: ConflictKeep },
|
||||
/// List the folders under this machine's device that can be synced here,
|
||||
/// each with a proposed local path (features.md 5.2). Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
@@ -391,6 +443,18 @@ pub enum Request {
|
||||
/// Attach the given remote device folders to local paths and sync them down.
|
||||
/// Replies with [`Response::Ok`].
|
||||
RestoreSyncFolders { items: Vec<RestoreItem> },
|
||||
/// [`Request::ListRestorableFolders`] for another registered device, by
|
||||
/// uid, so its folders can be synced down to this machine. Replies with
|
||||
/// [`Response::RestorableFolders`].
|
||||
ListDeviceRestorableFolders { device: String },
|
||||
/// [`Request::RestoreSyncFolders`] for folders under another device. A
|
||||
/// separate variant rather than an optional field: an older daemon that
|
||||
/// ignored the field would attach the folders under this machine's device
|
||||
/// share instead. Replies with [`Response::Ok`].
|
||||
RestoreDeviceFolders {
|
||||
device: String,
|
||||
items: Vec<RestoreItem>,
|
||||
},
|
||||
|
||||
// ---- sharing a node ---------------------------------------------------
|
||||
/// Invite `emails` (Proton and/or external addresses, auto-detected) to the
|
||||
@@ -466,6 +530,13 @@ pub enum Request {
|
||||
RemovePublicLink { path: String, id: String },
|
||||
/// The by-uid twin of [`Request::RemovePublicLink`].
|
||||
RemovePublicLinkByUid { uid: String, id: String },
|
||||
/// Stop sharing the node at `path`: remove its public link, every pending
|
||||
/// invitation and every member. Each removal is attempted even if an
|
||||
/// earlier one fails. Replies with [`Response::Ok`], or an error naming
|
||||
/// what is still shared.
|
||||
StopSharing { path: String },
|
||||
/// The by-uid twin of [`Request::StopSharing`].
|
||||
StopSharingByUid { uid: String },
|
||||
|
||||
// ---- revisions --------------------------------------------------------
|
||||
/// List the version history of the file at mountpoint-relative `path`,
|
||||
@@ -626,6 +697,60 @@ pub struct DeviceInfo {
|
||||
pub adopted: bool,
|
||||
}
|
||||
|
||||
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
||||
/// [`Response::Conflicts`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct ConflictInfo {
|
||||
/// Mountpoint-relative path of the copy.
|
||||
pub path: String,
|
||||
/// Mountpoint-relative path of the file it is a copy of.
|
||||
pub original_path: String,
|
||||
/// False when the original is gone and only the copy is left.
|
||||
pub original_exists: bool,
|
||||
pub size: u64,
|
||||
/// Unix seconds.
|
||||
pub modified: i64,
|
||||
pub original_size: Option<u64>,
|
||||
pub original_modified: Option<i64>,
|
||||
/// Same size and content hash as the original: keeping either loses nothing.
|
||||
pub identical: bool,
|
||||
}
|
||||
|
||||
/// Which side of a conflict to keep (in [`Request::ResolveConflict`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConflictKeep {
|
||||
/// Keep the original; the copy goes to Trash.
|
||||
Original,
|
||||
/// Keep the copy under the original's name; the original goes to Trash.
|
||||
Copy,
|
||||
/// Keep both; the copy is renamed to `name`.
|
||||
Both { name: String },
|
||||
}
|
||||
|
||||
/// One queued upload or change (in [`Response::PendingOps`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PendingOpInfo {
|
||||
/// Queue row id, for [`Request::RetryPendingOp`].
|
||||
pub id: i64,
|
||||
/// `revision`, `create`, `mkdir`, `rename` or `trash`.
|
||||
pub kind: String,
|
||||
/// Mountpoint-relative path of the file or folder, or its name when the
|
||||
/// path is no longer known.
|
||||
pub path: String,
|
||||
/// Failed attempts so far.
|
||||
pub attempts: i64,
|
||||
/// The latest failure, when there has been one.
|
||||
pub last_error: Option<String>,
|
||||
/// Unix second the op was queued.
|
||||
pub queued_at: i64,
|
||||
/// Unix second of the next attempt; `None` while parked.
|
||||
pub next_attempt_at: Option<i64>,
|
||||
/// Held back until a transient file gets its final name.
|
||||
pub parked: bool,
|
||||
/// Failed often enough to count as stuck.
|
||||
pub failing: bool,
|
||||
}
|
||||
|
||||
/// One synced local folder on this machine's device (in [`Response::SyncFolders`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SyncFolderInfo {
|
||||
@@ -651,6 +776,9 @@ pub struct SyncFolderInfo {
|
||||
/// running. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]).
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// A folder under this machine's device offered for restore (in
|
||||
@@ -976,6 +1104,10 @@ pub struct SearchHit {
|
||||
pub size: u64,
|
||||
pub modified: i64,
|
||||
pub pinned: bool,
|
||||
/// Whether a valid copy of the content is in the local cache. Legacy
|
||||
/// daemons omit it; clients then treat the file as online-only.
|
||||
#[serde(default)]
|
||||
pub cached: bool,
|
||||
/// Node uid in `volume~link` form.
|
||||
pub uid: String,
|
||||
/// Absolute path through the most specific active on-demand mount, when the
|
||||
@@ -1308,8 +1440,8 @@ pub struct PhotoItem {
|
||||
/// the split omit it; a front-end then treats everything as a still photo.
|
||||
#[serde(default = "default_photo_kind")]
|
||||
pub kind: PhotoKind,
|
||||
/// Whether the photo is favourited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favourited" — the same thing it would show for a photo whose
|
||||
/// Whether the photo is favorited. Older daemons omit it, which a front-end
|
||||
/// reads as "not favorited" — the same thing it would show for a photo whose
|
||||
/// tags it has not learned yet.
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
@@ -1558,6 +1690,13 @@ pub enum Response {
|
||||
/// Age of the oldest staged write, in seconds.
|
||||
#[serde(default)]
|
||||
staged_oldest_secs: u64,
|
||||
/// True while the user has sync paused (see [`Request::SetSyncPaused`]).
|
||||
#[serde(default)]
|
||||
paused: bool,
|
||||
/// The Unix second a timed pause ends; `None` when paused until
|
||||
/// resumed, or not paused.
|
||||
#[serde(default)]
|
||||
paused_until: Option<i64>,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -1644,6 +1783,15 @@ pub enum Response {
|
||||
trashed: Vec<String>,
|
||||
failed: Vec<TrashFailure>,
|
||||
},
|
||||
/// The album a [`Request::CreateAlbum`] made.
|
||||
AlbumCreated { uid: String },
|
||||
/// Outcome of a [`Request::AddToAlbum`] or [`Request::RemoveFromAlbum`],
|
||||
/// per photo like [`Response::Trashed`]: `changed` photos were added or
|
||||
/// removed, `failed` ones were not.
|
||||
AlbumChanged {
|
||||
changed: Vec<String>,
|
||||
failed: Vec<TrashFailure>,
|
||||
},
|
||||
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
||||
/// retryable after reserving a fresh generation and must never be interpreted
|
||||
/// as a permanent "no thumbnail" verdict.
|
||||
@@ -1687,6 +1835,10 @@ pub enum Response {
|
||||
Locations { items: Vec<MountSpec> },
|
||||
/// This device's synced folders (reply to [`Request::ListSyncFolders`]).
|
||||
SyncFolders { items: Vec<SyncFolderInfo> },
|
||||
/// The upload/change queue (reply to [`Request::ListPendingOps`]).
|
||||
PendingOps { items: Vec<PendingOpInfo> },
|
||||
/// Conflict copies (reply to [`Request::ListConflicts`]).
|
||||
Conflicts { items: Vec<ConflictInfo> },
|
||||
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
|
||||
RestorableFolders { items: Vec<RestorableFolder> },
|
||||
/// A node's share: members + pending invitations, and its public link if any
|
||||
@@ -2133,6 +2285,7 @@ mod tests {
|
||||
size: 12,
|
||||
modified: 123,
|
||||
pinned: true,
|
||||
cached: false,
|
||||
uid: "vol~link".into(),
|
||||
mounted_path: Some("/home/me/Videos/report.pdf".into()),
|
||||
score: 985,
|
||||
@@ -2243,7 +2396,7 @@ mod tests {
|
||||
serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
drive,
|
||||
Request::Search { query, limit } if query == "old" && limit == 20
|
||||
Request::Search { query, limit, scope: None } if query == "old" && limit == 20
|
||||
));
|
||||
|
||||
let local: Request =
|
||||
@@ -2254,6 +2407,15 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_album_keeps_photos_unless_asked() {
|
||||
let request: Request = serde_json::from_str(r#"{"DeleteAlbum":{"uid":"v~a"}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
request,
|
||||
Request::DeleteAlbum { uid, delete_photos: false } if uid == "v~a"
|
||||
));
|
||||
}
|
||||
|
||||
/// The trash requests carry uids rather than paths; they must survive the same
|
||||
/// line-delimited round-trip, since a mangled uid would restore or destroy the
|
||||
/// wrong node.
|
||||
@@ -2320,6 +2482,14 @@ mod tests {
|
||||
id: 3,
|
||||
mode: "ondemand".into(),
|
||||
},
|
||||
Request::SetSyncFolderPaused {
|
||||
id: 3,
|
||||
paused: true,
|
||||
},
|
||||
Request::SetBandwidthLimits {
|
||||
upload: 1_000_000,
|
||||
download: 0,
|
||||
},
|
||||
Request::SyncNow { id: Some(3) },
|
||||
Request::AdoptDevice {
|
||||
uid: Some("dev-1".into()),
|
||||
@@ -2333,6 +2503,17 @@ mod tests {
|
||||
mode: "mirror".into(),
|
||||
}],
|
||||
},
|
||||
Request::ListDeviceRestorableFolders {
|
||||
device: "dev-2".into(),
|
||||
},
|
||||
Request::RestoreDeviceFolders {
|
||||
device: "dev-2".into(),
|
||||
items: vec![RestoreItem {
|
||||
remote_uid: "vol~link".into(),
|
||||
local_path: "/home/me/Work".into(),
|
||||
mode: "ondemand".into(),
|
||||
}],
|
||||
},
|
||||
Request::ShareNode {
|
||||
path: "a/b".into(),
|
||||
emails: vec!["x@proton.me".into(), "y@example.com".into()],
|
||||
@@ -2441,6 +2622,7 @@ mod tests {
|
||||
pending_mode: None,
|
||||
mounted: true,
|
||||
progress: None,
|
||||
paused: false,
|
||||
},
|
||||
MountSpec {
|
||||
id: 2,
|
||||
@@ -2455,6 +2637,7 @@ mod tests {
|
||||
pending_mode: Some(MountMode::OnDemand),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
},
|
||||
MountSpec {
|
||||
id: 3,
|
||||
@@ -2476,6 +2659,7 @@ mod tests {
|
||||
total: 5,
|
||||
current: "report.pdf".into(),
|
||||
}),
|
||||
paused: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -2593,7 +2777,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A front-end that predates the favourites filter sends no `favorites`
|
||||
/// A front-end that predates the favorites filter sends no `favorites`
|
||||
/// field, and must keep getting the whole timeline rather than an empty page.
|
||||
#[test]
|
||||
fn timeline_requests_without_the_favorites_filter_still_decode() {
|
||||
@@ -2731,6 +2915,12 @@ mod tests {
|
||||
},
|
||||
"RemovePublicLinkByUid",
|
||||
),
|
||||
(
|
||||
Request::StopSharingByUid {
|
||||
uid: "vol~link".into(),
|
||||
},
|
||||
"StopSharingByUid",
|
||||
),
|
||||
];
|
||||
|
||||
for (request, variant) in cases {
|
||||
@@ -2749,7 +2939,8 @@ mod tests {
|
||||
| Request::UpdateShareRoleByUid { uid, .. }
|
||||
| Request::RemoveShareEntryByUid { uid, .. }
|
||||
| Request::CreatePublicLinkByUid { uid, .. }
|
||||
| Request::RemovePublicLinkByUid { uid, .. } => uid,
|
||||
| Request::RemovePublicLinkByUid { uid, .. }
|
||||
| Request::StopSharingByUid { uid } => uid,
|
||||
other => panic!("decoded into the wrong dispatch variant: {other:?}"),
|
||||
};
|
||||
assert_eq!(decoded_uid, "vol~link");
|
||||
|
||||
@@ -178,7 +178,7 @@ 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,
|
||||
@@ -239,7 +239,7 @@ 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,
|
||||
|
||||
@@ -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 = 31;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 32;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -270,6 +270,20 @@ impl Db {
|
||||
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), [])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -684,7 +698,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
|
||||
@@ -938,7 +952,7 @@ 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,
|
||||
/// favourite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// 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`).
|
||||
@@ -952,3 +966,10 @@ 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";
|
||||
@@ -56,8 +56,8 @@ 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 pins::PinRow;
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -455,7 +455,7 @@ impl Db {
|
||||
/// 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.
|
||||
///
|
||||
/// 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
|
||||
@@ -620,7 +620,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<()> {
|
||||
|
||||
@@ -222,7 +222,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1"],
|
||||
"only the favourited photo is in a favourites page"
|
||||
"only the favorited photo is in a favorites page"
|
||||
);
|
||||
|
||||
// A local toggle survives a refresh that could not resolve the photo's node
|
||||
@@ -1083,6 +1083,40 @@ fn search_trigram_substring_and_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_scope_only_returns_hits_below_the_folder() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("work", Some("root"), "Work"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("sub", Some("work"), "Old")).unwrap();
|
||||
db.upsert_node(&folder("work2", Some("root"), "Work 2"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "work", "report.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f2", "sub", "report-2019.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f3", "work2", "report-copy.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f4", "root", "report-top.pdf", 1))
|
||||
.unwrap();
|
||||
|
||||
let mut paths: Vec<String> = db
|
||||
.search_in("report", 10, Some("Work"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|h| h.path)
|
||||
.collect();
|
||||
paths.sort();
|
||||
// A sibling whose name starts with the scope is not inside it.
|
||||
assert_eq!(paths, vec!["Work/Old/report-2019.pdf", "Work/report.pdf"]);
|
||||
// Short queries take the LIKE path and scope the same way.
|
||||
assert_eq!(db.search_in("re", 10, Some("Work/Old")).unwrap().len(), 1);
|
||||
// No scope, or the root, is everywhere.
|
||||
assert_eq!(db.search_in("report", 10, None).unwrap().len(), 4);
|
||||
assert_eq!(db.search_in("report", 10, Some("")).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_excludes_trashed_and_respects_limit() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -3049,6 +3083,57 @@ fn a_parked_transient_create_stays_off_the_drain_until_finalized() {
|
||||
assert_eq!(due.blob_path.as_deref(), Some("/staging/blob1"));
|
||||
}
|
||||
|
||||
/// The park sweep's query: parked creates are the rows nothing else in the drain
|
||||
/// can see, so it has to find them by the sentinel and report how long each has
|
||||
/// been parked. Ordinary queued work is not its business.
|
||||
#[test]
|
||||
fn parked_creates_are_listed_with_the_age_the_sweep_judges_them_by() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let parent = uid("root").to_string();
|
||||
let parked = uid("swap").to_string();
|
||||
let due = uid("ordinary").to_string();
|
||||
for (node, name, next_attempt_at, created_at) in [
|
||||
(&parked, ".notes.txt.swp", PARK_UNTIL, 1_000),
|
||||
(&due, "notes.txt", 0, 2_000),
|
||||
] {
|
||||
db.enqueue_op(&PendingOp {
|
||||
id: 0,
|
||||
kind: OP_CREATE.to_string(),
|
||||
uid: node.clone(),
|
||||
parent_uid: Some(parent.clone()),
|
||||
name: Some(name.to_string()),
|
||||
blob_path: Some(format!("/staging/{name}")),
|
||||
meta_json: None,
|
||||
created_at,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let rows = db.parked_create_ops().unwrap();
|
||||
assert_eq!(rows.len(), 1, "only the parked create is swept");
|
||||
assert_eq!(rows[0].uid, parked);
|
||||
assert_eq!(rows[0].created_at, 1_000, "the park's age is readable");
|
||||
assert_eq!(
|
||||
rows[0].blob_path.as_deref(),
|
||||
Some("/staging/.notes.txt.swp")
|
||||
);
|
||||
|
||||
// What the sweep does with an expired park: un-park it, and it drains like
|
||||
// any other create rather than waiting for a rename that is not coming.
|
||||
assert!(db.set_create_hold(&parked, false).unwrap());
|
||||
assert!(
|
||||
db.parked_create_ops().unwrap().is_empty(),
|
||||
"an un-parked create is no longer the sweep's business"
|
||||
);
|
||||
assert_eq!(
|
||||
db.next_due_op(1_000 + PARK_EXPIRY_MS).unwrap().unwrap().uid,
|
||||
parked
|
||||
);
|
||||
}
|
||||
|
||||
/// Backoff still gates: nothing is returned before an op is due.
|
||||
#[test]
|
||||
fn next_due_op_respects_backoff() {
|
||||
@@ -4150,6 +4235,48 @@ fn the_servers_photo_relation_groups_a_live_photo() {
|
||||
/// A database written before grouping existed opens, keeps its timeline, and
|
||||
/// shows every photo — a row with no `group_key` is its own group, which is how
|
||||
/// the gallery behaved before the column.
|
||||
#[test]
|
||||
fn migration_v32_retries_videos_written_off_as_unthumbnailable() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v31-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let db = Db::open(&path).unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("VID-20260804-WA0023.mp4".into()),
|
||||
..TimelineRow::new("video", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("broken.jpg".into()),
|
||||
..TimelineRow::new("photo", 200)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
db.photo_set_thumb("video", THUMB_NONE, None).unwrap();
|
||||
db.photo_set_thumb("photo", THUMB_NONE, None).unwrap();
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch("UPDATE sync_state SET value = '31' WHERE key = 'schema_version';")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
let state = |uid: &str| db.photos_by_uid(&[uid.into()]).unwrap()[0].thumb_state;
|
||||
assert_eq!(state("video"), THUMB_UNKNOWN, "the video is tried again");
|
||||
assert_eq!(
|
||||
state("photo"),
|
||||
THUMB_NONE,
|
||||
"an undecodable photo stays written off"
|
||||
);
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -4429,3 +4556,58 @@ fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
fn queued_op(db: &Db, uid: &str, failures: i64, next_attempt_at: i64) -> i64 {
|
||||
let (id, _) = db
|
||||
.enqueue_op(&ops::PendingOp {
|
||||
id: 0,
|
||||
kind: ops::OP_REVISION.to_string(),
|
||||
uid: uid.to_string(),
|
||||
parent_uid: None,
|
||||
name: None,
|
||||
blob_path: None,
|
||||
meta_json: None,
|
||||
created_at: 1000,
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
next_attempt_at,
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..failures {
|
||||
db.record_op_failure(id, "boom", next_attempt_at).unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// "Retry now" must make a backed-off op due, but never un-park a transient
|
||||
/// file's op: that one waits for its finishing rename, not for time.
|
||||
#[test]
|
||||
fn retry_now_makes_a_backed_off_op_due_but_leaves_parked_ops_parked() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let backed_off = queued_op(&db, "vol~a", 3, 90_000);
|
||||
let parked = queued_op(&db, "vol~b", 0, ops::PARK_UNTIL);
|
||||
|
||||
assert!(db.retry_op_now(backed_off, 5_000).unwrap());
|
||||
assert!(!db.retry_op_now(parked, 5_000).unwrap());
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||
assert_eq!(due(backed_off), 5_000);
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_all_touches_only_ops_that_have_failed() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let failed = queued_op(&db, "vol~a", 2, 90_000);
|
||||
let debounced = queued_op(&db, "vol~b", 0, 7_000);
|
||||
let parked = queued_op(&db, "vol~c", 4, ops::PARK_UNTIL);
|
||||
|
||||
assert_eq!(db.retry_failed_ops_now(5_000).unwrap(), 1);
|
||||
|
||||
let ops = db.pending_ops().unwrap();
|
||||
let due = |id: i64| ops.iter().find(|op| op.id == id).unwrap().next_attempt_at;
|
||||
assert_eq!(due(failed), 5_000);
|
||||
assert_eq!(due(debounced), 7_000);
|
||||
assert_eq!(due(parked), ops::PARK_UNTIL);
|
||||
}
|
||||
@@ -21,6 +21,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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -812,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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -221,6 +221,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 +308,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 {
|
||||
|
||||
@@ -511,7 +511,7 @@ impl PhotoEventPlan {
|
||||
///
|
||||
/// `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 favourite the user toggles here would mark its own photo stale and buy
|
||||
/// 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],
|
||||
@@ -529,7 +529,7 @@ fn classify_photo_events(
|
||||
if *is_trashed {
|
||||
plan.gone.push(node_uid.to_string());
|
||||
} else if !is_echo(node_uid) {
|
||||
// An untrash, a rename, a favourite toggled on the phone: the
|
||||
// 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());
|
||||
@@ -974,7 +974,7 @@ mod tests {
|
||||
},
|
||||
DriveEvent::NodeUpdated {
|
||||
id: DriveEventId::from("e3"),
|
||||
node_uid: uid("favourited"),
|
||||
node_uid: uid("favorited"),
|
||||
parent_node_uid: None,
|
||||
is_trashed: false,
|
||||
is_shared: false,
|
||||
@@ -986,7 +986,7 @@ mod tests {
|
||||
plan,
|
||||
PhotoEventPlan {
|
||||
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||
touched: vec![uid("favourited").to_string()],
|
||||
touched: vec![uid("favorited").to_string()],
|
||||
full: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Only copies under My Files are handled here. A copy inside a mirror folder is
|
||||
//! a local file first; the sync engine owns it.
|
||||
|
||||
use super::*;
|
||||
use pdfs_core::control::{ConflictInfo, ConflictKeep};
|
||||
use sweep::conflict_base_name;
|
||||
|
||||
impl Core {
|
||||
/// Every conflict copy under My Files, 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 root = self.primary_root_uid.to_string();
|
||||
let mut items = Vec::new();
|
||||
for node in &nodes {
|
||||
let Some(base) = conflict_base_name(&node.name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) 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");
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,33 @@ use pdfs_core::control::{
|
||||
};
|
||||
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;
|
||||
@@ -164,6 +210,50 @@ fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||
|
||||
/// 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");
|
||||
@@ -202,6 +292,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(),
|
||||
@@ -238,7 +330,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) {
|
||||
@@ -247,45 +339,69 @@ 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 { 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),
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -317,9 +433,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
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),
|
||||
@@ -351,6 +467,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
|
||||
@@ -561,7 +709,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),
|
||||
},
|
||||
@@ -742,9 +894,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),
|
||||
@@ -824,6 +981,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),
|
||||
@@ -907,6 +1087,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 {
|
||||
@@ -916,22 +1107,75 @@ 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 rel_to_mount(mountpoint, &path) {
|
||||
Ok(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 {
|
||||
@@ -1102,6 +1346,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 },
|
||||
@@ -1370,3 +1621,28 @@ mod request_limit_tests {
|
||||
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);
|
||||
|
||||
@@ -73,7 +73,29 @@ pub(crate) fn request_kind(line: &str) -> String {
|
||||
line.strip_prefix('"')
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
};
|
||||
name.unwrap_or("unknown").to_string()
|
||||
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.
|
||||
@@ -181,6 +203,26 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -33,7 +33,9 @@ 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 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};
|
||||
@@ -283,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
|
||||
@@ -299,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.
|
||||
@@ -930,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);
|
||||
@@ -940,7 +956,7 @@ impl Core {
|
||||
media_type_for(name),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1130,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);
|
||||
@@ -1140,7 +1157,7 @@ impl Core {
|
||||
media_type_for(&alt),
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
false,
|
||||
))?;
|
||||
@@ -1201,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
|
||||
@@ -1328,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);
|
||||
@@ -1341,7 +1454,7 @@ impl Core {
|
||||
&uid,
|
||||
reader,
|
||||
meta.len as i64,
|
||||
Vec::new(),
|
||||
thumbnails,
|
||||
None,
|
||||
));
|
||||
let took = started.elapsed();
|
||||
@@ -1573,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::*;
|
||||
@@ -1598,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] {
|
||||
|
||||
@@ -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;
|
||||
@@ -80,6 +80,7 @@ use proton_drive_rs::{
|
||||
|
||||
mod albums;
|
||||
mod background;
|
||||
mod conflicts;
|
||||
mod control;
|
||||
use control::run_control_socket;
|
||||
mod devices;
|
||||
@@ -88,8 +89,10 @@ mod drain;
|
||||
mod filesystem;
|
||||
pub use filesystem::ProtonFs;
|
||||
mod mount;
|
||||
mod pause;
|
||||
mod photos;
|
||||
mod profile;
|
||||
mod queue;
|
||||
mod reads;
|
||||
mod redate;
|
||||
mod revisions;
|
||||
@@ -197,6 +200,22 @@ 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
|
||||
@@ -492,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.
|
||||
@@ -1394,6 +1417,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,
|
||||
@@ -1718,6 +1750,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;
|
||||
@@ -1725,9 +1760,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) {
|
||||
@@ -1773,8 +1816,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3801,10 +3857,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();
|
||||
@@ -3816,6 +3887,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,
|
||||
@@ -3883,6 +3960,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,
|
||||
@@ -5085,7 +5168,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,
|
||||
@@ -5095,6 +5182,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
||||
state: f.state,
|
||||
last_sync: f.last_sync,
|
||||
progress,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6124,14 +6212,64 @@ mod tests {
|
||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||
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,
|
||||
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>)> {
|
||||
|
||||
@@ -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,6 +343,8 @@ 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.
|
||||
@@ -357,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(),
|
||||
@@ -377,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())),
|
||||
@@ -417,6 +424,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.
|
||||
@@ -451,6 +459,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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use std::io::{Read as _, Write as _};
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Once, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine as _;
|
||||
@@ -25,8 +25,8 @@ use pdfs_core::db::{self, StoredPhoto};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, ThumbnailType};
|
||||
use tracing::{info, warn};
|
||||
use proton_drive_rs::{NodeKind, PhotoTag, PhotoTagsUpdate, Thumbnail, ThumbnailType};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::{
|
||||
Core, PHOTOS_AVAILABLE, PHOTOS_SYNCED_MS, TIMELINE_ENRICH_CHUNK, TIMELINE_TTL, WriteAuthority,
|
||||
@@ -55,6 +55,20 @@ const THUMB_CANCEL_POLL: Duration = Duration::from_millis(100);
|
||||
const EXIFTOOL_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
static RAW_TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
|
||||
static EXIFTOOL_MISSING_WARNING: Once = Once::new();
|
||||
static FFMPEG_MISSING_WARNING: Once = Once::new();
|
||||
/// Largest video downloaded whole to grab one frame for its thumbnail. The
|
||||
/// download lands in memory, so a long 4K recording is left without one.
|
||||
const VIDEO_THUMB_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
/// A single frame is seconds of work even for 4K; this only stops a wedged
|
||||
/// ffmpeg from holding a thumbnail permit.
|
||||
const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Largest thumbnail attached to an upload. Proton's own clients hold theirs to
|
||||
/// this, stepping the JPEG quality down until it fits.
|
||||
const UPLOAD_THUMB_MAX_BYTES: usize = 60 * 1024;
|
||||
/// JPEG qualities tried, in order, for an upload thumbnail over that size.
|
||||
const UPLOAD_THUMB_QUALITIES: [u8; 4] = [65, 50, 35, 20];
|
||||
/// Largest still image read whole to render the thumbnail its upload carries.
|
||||
const UPLOAD_IMAGE_MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// The aspect ratio (w/h) of an encoded image, read from its header alone — no
|
||||
/// pixels are decoded. `None` when the format is unknown or the header is torn.
|
||||
@@ -90,7 +104,8 @@ struct RawPreviewUnavailable;
|
||||
/// the photo will never have a thumbnail, and only that verdict is persisted.
|
||||
enum ThumbAttempt {
|
||||
Made(GeneratedThumb),
|
||||
/// Decoded nothing — a format this build has no decoder for. Permanent.
|
||||
/// Decoded nothing — a format this build has no decoder for, or a video
|
||||
/// too large to fetch for one frame. Permanent.
|
||||
Undecodable,
|
||||
/// The photo couldn't be downloaded. Transient: try again next time.
|
||||
Unavailable,
|
||||
@@ -196,6 +211,11 @@ fn scale_thumbnail_with_exiftool(
|
||||
}
|
||||
None => return ScaleAttempt::Undecodable,
|
||||
};
|
||||
encode_thumb(&image)
|
||||
}
|
||||
|
||||
/// Shrink a decoded image to a [`THUMB_EDGE`] JPEG, keeping its aspect ratio.
|
||||
fn encode_thumb(image: &image::DynamicImage) -> ScaleAttempt {
|
||||
let (width, height) = (image.width(), image.height());
|
||||
if width == 0 || height == 0 {
|
||||
return ScaleAttempt::Undecodable;
|
||||
@@ -268,9 +288,69 @@ impl Drop for RawTempFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one bounded exiftool JSON query while draining stdout concurrently. The
|
||||
/// latter matters for full-size `JpgFromRaw` fallbacks, whose base64 can exceed a
|
||||
/// pipe buffer long before the child exits.
|
||||
/// How a bounded helper run failed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ToolError {
|
||||
/// The helper isn't installed.
|
||||
Missing,
|
||||
/// It couldn't be started or read, or overran its budget and was killed.
|
||||
Unavailable,
|
||||
/// It ran and exited non-zero.
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Run a helper to completion within `timeout` and return its stdout, drained
|
||||
/// concurrently. The latter matters for exiftool's full-size `JpgFromRaw`
|
||||
/// fallbacks and for ffmpeg's frames, either of which can exceed a pipe buffer
|
||||
/// long before the child exits.
|
||||
fn run_bounded(mut command: Command, timeout: Duration) -> Result<Vec<u8>, ToolError> {
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = command.spawn().map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
ToolError::Missing
|
||||
} else {
|
||||
ToolError::Unavailable
|
||||
}
|
||||
})?;
|
||||
let Some(mut stdout) = child.stdout.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(ToolError::Unavailable);
|
||||
};
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
stdout.read_to_end(&mut output).map(|_| output)
|
||||
});
|
||||
let started = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Some(status.success()),
|
||||
Ok(None) if started.elapsed() < timeout => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break None;
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = reader
|
||||
.join()
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.ok_or(ToolError::Unavailable)?;
|
||||
match status {
|
||||
Some(true) => Ok(output),
|
||||
Some(false) => Err(ToolError::Failed),
|
||||
None => Err(ToolError::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one bounded exiftool JSON query.
|
||||
fn exiftool_query(
|
||||
exiftool: &OsStr,
|
||||
path: &Path,
|
||||
@@ -280,58 +360,172 @@ fn exiftool_query(
|
||||
let mut command = Command::new(exiftool);
|
||||
command.args(["-j", "-b", "-n"]);
|
||||
command.args(tags);
|
||||
command
|
||||
.arg(path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||||
warn!(
|
||||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||||
(Debian/Ubuntu: libimage-exiftool-perl)"
|
||||
);
|
||||
});
|
||||
}
|
||||
return Err(RawPreviewUnavailable);
|
||||
command.arg(path);
|
||||
let output = run_bounded(command, timeout).map_err(|error| {
|
||||
if error == ToolError::Missing {
|
||||
EXIFTOOL_MISSING_WARNING.call_once(|| {
|
||||
warn!(
|
||||
"exiftool is not installed; camera RAW thumbnails are unavailable \
|
||||
(Debian/Ubuntu: libimage-exiftool-perl)"
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
let Some(mut stdout) = child.stdout.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(RawPreviewUnavailable);
|
||||
};
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
stdout.read_to_end(&mut output).map(|_| output)
|
||||
});
|
||||
let started = Instant::now();
|
||||
let success = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status.success(),
|
||||
Ok(None) if started.elapsed() < timeout => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break false;
|
||||
}
|
||||
}
|
||||
};
|
||||
let output = reader
|
||||
.join()
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.ok_or(RawPreviewUnavailable)?;
|
||||
if !success {
|
||||
return Err(RawPreviewUnavailable);
|
||||
}
|
||||
RawPreviewUnavailable
|
||||
})?;
|
||||
serde_json::from_slice(&output).map_err(|_| RawPreviewUnavailable)
|
||||
}
|
||||
|
||||
/// Whether `name` is a video, whose thumbnail is a frame ffmpeg grabs rather
|
||||
/// than a picture the image decoder can scale.
|
||||
fn is_video_name(name: &str) -> bool {
|
||||
PhotoKind::classify(Some(name), None) == PhotoKind::Video
|
||||
}
|
||||
|
||||
/// Whether ffmpeg can be run at all. Asked once, before a video is downloaded:
|
||||
/// without ffmpeg every scroll past a video would fetch it whole for nothing.
|
||||
fn ffmpeg_available() -> bool {
|
||||
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
||||
*AVAILABLE.get_or_init(|| {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command.arg("-version");
|
||||
let available = run_bounded(command, Duration::from_secs(5)).is_ok();
|
||||
if !available {
|
||||
warn_ffmpeg_missing();
|
||||
}
|
||||
available
|
||||
})
|
||||
}
|
||||
|
||||
fn warn_ffmpeg_missing() {
|
||||
FFMPEG_MISSING_WARNING.call_once(|| {
|
||||
warn!("ffmpeg is not installed; video thumbnails are unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
/// A thumbnail from one frame of a downloaded video, which ffmpeg reads from a
|
||||
/// staging copy. See [`video_file_thumbnail`].
|
||||
fn video_thumbnail(
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
staging_dir: &Path,
|
||||
ffmpeg: &OsStr,
|
||||
timeout: Duration,
|
||||
) -> ScaleAttempt {
|
||||
let Some(file) = RawTempFile::create_in(staging_dir, bytes, name) else {
|
||||
return ScaleAttempt::Unavailable;
|
||||
};
|
||||
video_file_thumbnail(&file.0, ffmpeg, timeout)
|
||||
}
|
||||
|
||||
/// A thumbnail from one frame of the video at `path`. A second in, past the
|
||||
/// black lead-in most clips open on, and the first frame for a clip shorter than
|
||||
/// that. ffmpeg applies the clip's rotation, so a portrait phone video comes out
|
||||
/// upright.
|
||||
///
|
||||
/// Missing or wedged ffmpeg is retryable; a file ffmpeg can take no frame from
|
||||
/// is the permanent verdict.
|
||||
fn video_file_thumbnail(path: &Path, ffmpeg: &OsStr, timeout: Duration) -> ScaleAttempt {
|
||||
for seek in ["1", "0"] {
|
||||
let mut command = Command::new(ffmpeg);
|
||||
command.args(["-nostdin", "-loglevel", "error", "-ss", seek, "-i"]);
|
||||
command.arg(path);
|
||||
command.args(["-frames:v", "1", "-f", "image2pipe", "-c:v", "mjpeg", "-"]);
|
||||
match run_bounded(command, timeout) {
|
||||
Ok(frame) if !frame.is_empty() => {
|
||||
return match image::load_from_memory(&frame) {
|
||||
Ok(image) => encode_thumb(&image),
|
||||
Err(_) => ScaleAttempt::Undecodable,
|
||||
};
|
||||
}
|
||||
// No frame that far in: try from the start.
|
||||
Ok(_) | Err(ToolError::Failed) => continue,
|
||||
Err(ToolError::Missing) => {
|
||||
warn_ffmpeg_missing();
|
||||
return ScaleAttempt::Unavailable;
|
||||
}
|
||||
Err(ToolError::Unavailable) => return ScaleAttempt::Unavailable,
|
||||
}
|
||||
}
|
||||
ScaleAttempt::Undecodable
|
||||
}
|
||||
|
||||
/// The thumbnail to attach to an upload of the local file at `path`, which is
|
||||
/// named `name` in Drive. Without one, Drive's own apps show a generic icon and
|
||||
/// the gallery has to download the whole file to make its own.
|
||||
///
|
||||
/// Best effort: a file that isn't an image or a video, is too large to read, or
|
||||
/// can't be decoded uploads without a thumbnail, as it always did. Blocking and
|
||||
/// CPU-bound, so async callers run it on the blocking pool.
|
||||
pub(crate) fn upload_thumbnails(path: &Path, name: &str, staging_dir: &Path) -> Vec<Thumbnail> {
|
||||
if is_video_name(name) && !ffmpeg_available() {
|
||||
return Vec::new();
|
||||
}
|
||||
upload_thumbnails_with(path, name, staging_dir, OsStr::new("ffmpeg"))
|
||||
}
|
||||
|
||||
fn upload_thumbnails_with(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
staging_dir: &Path,
|
||||
ffmpeg: &OsStr,
|
||||
) -> Vec<Thumbnail> {
|
||||
let attempt = if is_video_name(name) {
|
||||
if !is_video_file(path, name) {
|
||||
return Vec::new();
|
||||
}
|
||||
video_file_thumbnail(path, ffmpeg, FFMPEG_TIMEOUT)
|
||||
} else if is_thumbnail_image_name(name) {
|
||||
let fits = std::fs::metadata(path).is_ok_and(|meta| meta.len() <= UPLOAD_IMAGE_MAX_BYTES);
|
||||
let Some(bytes) = fits.then(|| std::fs::read(path).ok()).flatten() else {
|
||||
return Vec::new();
|
||||
};
|
||||
scale_thumbnail(&bytes, name, staging_dir)
|
||||
} else {
|
||||
return Vec::new();
|
||||
};
|
||||
let ScaleAttempt::Made(thumb) = attempt else {
|
||||
debug!(name, "uploading without a thumbnail; none could be made");
|
||||
return Vec::new();
|
||||
};
|
||||
fit_upload_thumb(thumb.bytes)
|
||||
.and_then(|bytes| Thumbnail::new(ThumbnailType::Thumbnail, bytes).ok())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `.ts` is TypeScript as often as it is an MPEG transport stream, so a source
|
||||
/// tree must not cost an ffmpeg run per file. A transport stream opens on its
|
||||
/// 0x47 sync byte; every other video extension is taken at its word.
|
||||
fn is_video_file(path: &Path, name: &str) -> bool {
|
||||
if !name.to_ascii_lowercase().ends_with(".ts") {
|
||||
return true;
|
||||
}
|
||||
let mut first = [0u8; 1];
|
||||
std::fs::File::open(path)
|
||||
.and_then(|mut file| file.read_exact(&mut first))
|
||||
.is_ok_and(|()| first[0] == 0x47)
|
||||
}
|
||||
|
||||
/// Re-encode a thumbnail at falling quality until it fits
|
||||
/// [`UPLOAD_THUMB_MAX_BYTES`], and at half the size if even the lowest quality
|
||||
/// doesn't. `None` if nothing fits.
|
||||
fn fit_upload_thumb(bytes: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if bytes.len() <= UPLOAD_THUMB_MAX_BYTES {
|
||||
return Some(bytes);
|
||||
}
|
||||
let image = image::load_from_memory(&bytes).ok()?;
|
||||
let half = image.thumbnail(image.width() / 2, image.height() / 2);
|
||||
[image.to_rgb8(), half.to_rgb8()].iter().find_map(|image| {
|
||||
UPLOAD_THUMB_QUALITIES.iter().find_map(|&quality| {
|
||||
let mut smaller = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut smaller, quality)
|
||||
.encode_image(image)
|
||||
.ok()?;
|
||||
(smaller.len() <= UPLOAD_THUMB_MAX_BYTES).then_some(smaller)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn exiftool_object(
|
||||
value: &serde_json::Value,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
@@ -418,6 +612,24 @@ struct PhotoMeta {
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// [`upload_thumbnails`] for a file about to upload, staging any RAW copy
|
||||
/// under this cache. Blocking.
|
||||
pub(crate) fn upload_thumbnails(&self, path: &Path, name: &str) -> Vec<Thumbnail> {
|
||||
upload_thumbnails(path, name, self.cache.raw_thumbnail_staging_dir())
|
||||
}
|
||||
|
||||
/// [`Core::upload_thumbnails`] on the blocking pool, for async callers.
|
||||
pub(crate) async fn upload_thumbnails_async(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
) -> Vec<Thumbnail> {
|
||||
let staging_dir = self.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || upload_thumbnails(&path, &name, &staging_dir))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn photos_timeline(
|
||||
&self,
|
||||
offset: usize,
|
||||
@@ -501,7 +713,7 @@ impl Core {
|
||||
.map_err(|e| CoreError::from_api(&e, "update photo tags"))?;
|
||||
// The photo event feed replays this write back at us as a bare
|
||||
// `NodeUpdated`, which is indistinguishable from the same photo being
|
||||
// favourited on a phone. Claim the echo, or the gallery pays for a
|
||||
// favorited on a phone. Claim the echo, or the gallery pays for a
|
||||
// resolve of a node whose one changed field is already stored below.
|
||||
self.note_self_change(uid);
|
||||
self.db
|
||||
@@ -973,6 +1185,10 @@ impl Core {
|
||||
if !core.thumb_job_current(job) {
|
||||
return (uid, ThumbAttempt::Cancelled);
|
||||
}
|
||||
let video = is_video_name(&name);
|
||||
if video && !ffmpeg_available() {
|
||||
return (uid, ThumbAttempt::Unavailable);
|
||||
}
|
||||
|
||||
// One shared permit pool bounds all thumbnail batches,
|
||||
// rather than multiplying the limit by every control
|
||||
@@ -1019,6 +1235,19 @@ impl Core {
|
||||
return (uid, ThumbAttempt::Cancelled);
|
||||
}
|
||||
|
||||
// A video is downloaded whole for one frame, so a long
|
||||
// recording is not worth it — and would not fit in memory.
|
||||
if video {
|
||||
match core.photos().get_node(&uid).await {
|
||||
Ok(Some(node)) if node_size(&node) > VIDEO_THUMB_MAX_BYTES => {
|
||||
info!(%uid, size = node_size(&node), "video too large for a local thumbnail");
|
||||
return (uid, ThumbAttempt::Undecodable);
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) | Err(_) => return (uid, ThumbAttempt::Unavailable),
|
||||
}
|
||||
}
|
||||
|
||||
let download_uid = uid.clone();
|
||||
let download = async move { client.download_file(&download_uid).await };
|
||||
tokio::pin!(download);
|
||||
@@ -1072,7 +1301,17 @@ impl Core {
|
||||
// stall the runtime's worker; hand it to the blocking pool.
|
||||
let staging_dir = core.cache.raw_thumbnail_staging_dir().to_path_buf();
|
||||
let made = tokio::task::spawn_blocking(move || {
|
||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
||||
if video {
|
||||
video_thumbnail(
|
||||
&bytes,
|
||||
&name,
|
||||
&staging_dir,
|
||||
OsStr::new("ffmpeg"),
|
||||
FFMPEG_TIMEOUT,
|
||||
)
|
||||
} else {
|
||||
scale_thumbnail(&bytes, &name, &staging_dir)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or(ScaleAttempt::Unavailable);
|
||||
@@ -1450,7 +1689,18 @@ impl Core {
|
||||
// so the relation is recorded from whichever end resolves — the two ends
|
||||
// can land in different chunks, and one of them may not resolve at all.
|
||||
let mut main_of: HashMap<String, String> = HashMap::new();
|
||||
let mut stopped = false;
|
||||
for chunk in uids.chunks(TIMELINE_ENRICH_CHUNK) {
|
||||
// Teardown cancels the runtime under this loop, and every chunk
|
||||
// still in flight then fails its decrypt task — a burst of
|
||||
// "skipping undecryptable photo ... task was cancelled" warnings
|
||||
// that read like corruption and are only a shutdown. Stop asking
|
||||
// for chunks instead. What resolved so far is kept below; the rest
|
||||
// stay unresolved and are read by the next refresh.
|
||||
if self.shutdown.is_stopping() {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
match photos.enumerate_nodes(chunk).await {
|
||||
Ok(nodes) => {
|
||||
for node in nodes {
|
||||
@@ -1458,7 +1708,7 @@ impl Core {
|
||||
NodeKind::File { media_type, .. } => Some(media_type.clone()),
|
||||
NodeKind::Folder => None,
|
||||
};
|
||||
// The favourite state is a tag on the photo node, so it
|
||||
// The favorite state is a tag on the photo node, so it
|
||||
// rides along with the metadata resolve rather than
|
||||
// costing a listing of its own.
|
||||
let favorite = node
|
||||
@@ -1496,7 +1746,7 @@ impl Core {
|
||||
let key = it.uid.to_string();
|
||||
let main_uid = main_of.get(&key).cloned();
|
||||
// An unresolved photo keeps whatever was learned before, for the
|
||||
// favourite flag as much as for the name and media type.
|
||||
// favorite flag as much as for the name and media type.
|
||||
match meta.get(&key).cloned() {
|
||||
Some(m) => db::TimelineRow {
|
||||
name: m.name,
|
||||
@@ -1521,10 +1771,14 @@ impl Core {
|
||||
// like it never happened, and the chunk count is what it scales with.
|
||||
info!(
|
||||
photos = rows.len(),
|
||||
resolved = uids.len(),
|
||||
// `resolved` is what came back, which is short of `wanted` when
|
||||
// shutdown cut the pass off or a chunk failed.
|
||||
resolved = meta.len(),
|
||||
wanted = uids.len(),
|
||||
skipped = rows.len().saturating_sub(uids.len()),
|
||||
chunks = uids.len().div_ceil(TIMELINE_ENRICH_CHUNK),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
stopped,
|
||||
"photos timeline refreshed"
|
||||
);
|
||||
Ok(true)
|
||||
@@ -1577,10 +1831,12 @@ impl Core {
|
||||
#[cfg(test)]
|
||||
mod thumb_tests {
|
||||
use super::{
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, exiftool_binary, ratio_of, scale_thumbnail,
|
||||
scale_thumbnail_with_exiftool, thumbnail_build_may_start,
|
||||
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
|
||||
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
|
||||
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
|
||||
};
|
||||
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
|
||||
use proton_drive_rs::ThumbnailType;
|
||||
use std::ffi::OsStr;
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::time::Duration;
|
||||
@@ -1743,6 +1999,103 @@ mod thumb_tests {
|
||||
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||||
}
|
||||
|
||||
/// An executable shell script standing in for ffmpeg, in its own directory
|
||||
/// under `tmp` so parallel tests never share one.
|
||||
fn fake_ffmpeg(tmp: &std::path::Path, body: &str) -> std::path::PathBuf {
|
||||
std::fs::create_dir_all(tmp).unwrap();
|
||||
let path = tmp.join("ffmpeg");
|
||||
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn scratch(test: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("pdfs-{test}-{}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_frame_becomes_a_thumbnail() {
|
||||
let tmp = scratch("video-frame");
|
||||
let frame = tmp.join("frame.jpg");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||||
|
||||
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
||||
b"video bytes",
|
||||
"clip.mp4",
|
||||
&tmp,
|
||||
ffmpeg.as_os_str(),
|
||||
Duration::from_secs(10),
|
||||
) else {
|
||||
panic!("a frame scales");
|
||||
};
|
||||
assert!((thumb.ratio - 1080.0 / 1920.0).abs() < 1e-6);
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_ffmpeg_cannot_read_is_a_permanent_miss() {
|
||||
let tmp = scratch("video-broken");
|
||||
let ffmpeg = fake_ffmpeg(&tmp, "exit 1");
|
||||
let attempt = video_thumbnail(
|
||||
b"not a video",
|
||||
"clip.mp4",
|
||||
&tmp,
|
||||
ffmpeg.as_os_str(),
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
assert!(matches!(attempt, ScaleAttempt::Undecodable));
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_ffmpeg_is_retryable() {
|
||||
let attempt = video_thumbnail(
|
||||
b"video bytes",
|
||||
"clip.mp4",
|
||||
&std::env::temp_dir(),
|
||||
OsStr::new("pdfs-ffmpeg-deliberately-absent"),
|
||||
Duration::from_millis(10),
|
||||
);
|
||||
assert!(matches!(attempt, ScaleAttempt::Unavailable));
|
||||
}
|
||||
|
||||
/// Optional real-ffmpeg check, like the RAW fixture harness above.
|
||||
#[test]
|
||||
#[ignore = "set PDFS_VIDEO_FIXTURE to a video file; needs ffmpeg"]
|
||||
fn real_video_fixture_yields_a_frame() {
|
||||
let path = std::env::var_os("PDFS_VIDEO_FIXTURE").expect("PDFS_VIDEO_FIXTURE");
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let ScaleAttempt::Made(thumb) = video_thumbnail(
|
||||
&bytes,
|
||||
&name,
|
||||
&std::env::temp_dir(),
|
||||
OsStr::new("ffmpeg"),
|
||||
Duration::from_secs(30),
|
||||
) else {
|
||||
panic!("a frame from the video");
|
||||
};
|
||||
assert!(thumb.ratio > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn videos_are_told_apart_by_name() {
|
||||
for name in [
|
||||
"VID-20260804-WA0023.mp4",
|
||||
"PXL_1.TS.mp4",
|
||||
"clip.MOV",
|
||||
"a.webm",
|
||||
] {
|
||||
assert!(is_video_name(name), "{name}");
|
||||
}
|
||||
for name in ["photo.jpg", "shot.NEF", "notes.txt", "mp4"] {
|
||||
assert!(!is_video_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_build_only_accepts_the_same_root() {
|
||||
let status = ThumbnailBuildStatus {
|
||||
@@ -1756,4 +2109,92 @@ mod thumb_tests {
|
||||
let idle = ThumbnailBuildStatus::default();
|
||||
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uploaded_photo_carries_its_thumbnail() {
|
||||
let tmp = scratch("upload-photo");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let photo = tmp.join("staged-blob");
|
||||
std::fs::write(&photo, jpeg(4000, 3000)).unwrap();
|
||||
|
||||
let thumbnails = upload_thumbnails_with(&photo, "IMG_0001.jpg", &tmp, OsStr::new("ffmpeg"));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let [thumbnail] = thumbnails.as_slice() else {
|
||||
panic!("one thumbnail, got {}", thumbnails.len());
|
||||
};
|
||||
assert!(matches!(thumbnail.thumbnail_type, ThumbnailType::Thumbnail));
|
||||
assert!(thumbnail.content.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||||
assert_eq!(ratio_of(&thumbnail.content), Some(4.0 / 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uploaded_video_carries_a_frame() {
|
||||
let tmp = scratch("upload-video");
|
||||
let frame = tmp.join("frame.jpg");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(&frame, jpeg(1080, 1920)).unwrap();
|
||||
std::fs::write(tmp.join("clip"), b"video bytes").unwrap();
|
||||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", frame.display()));
|
||||
|
||||
let thumbnails = upload_thumbnails_with(
|
||||
&tmp.join("clip"),
|
||||
"VID-WA0001.mp4",
|
||||
&tmp,
|
||||
ffmpeg.as_os_str(),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
assert_eq!(thumbnails.len(), 1);
|
||||
assert!(ratio_of(&thumbnails[0].content).is_some_and(|ratio| ratio < 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_upload_without_a_thumbnail() {
|
||||
let tmp = scratch("upload-other");
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("file"), b"export const answer = 42;\n").unwrap();
|
||||
// An ffmpeg that would hand back a frame, to prove it is never asked.
|
||||
std::fs::write(tmp.join("frame.jpg"), jpeg(64, 64)).unwrap();
|
||||
let ffmpeg = fake_ffmpeg(&tmp, &format!("cat '{}'", tmp.join("frame.jpg").display()));
|
||||
|
||||
for name in ["notes.txt", "answer.ts", "broken.jpg"] {
|
||||
assert!(
|
||||
upload_thumbnails_with(&tmp.join("file"), name, &tmp, ffmpeg.as_os_str())
|
||||
.is_empty(),
|
||||
"{name} uploads bare"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_thumbnail_is_squeezed_under_the_upload_limit() {
|
||||
// Noise is the worst case for JPEG: at the gallery's quality a 512 px
|
||||
// square of it lands far over the limit.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let noise = image::RgbImage::from_fn(THUMB_EDGE, THUMB_EDGE, |_, _| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let [r, g, b, _] = seed.to_le_bytes();
|
||||
image::Rgb([r, g, b])
|
||||
});
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, super::THUMB_QUALITY)
|
||||
.encode_image(&noise)
|
||||
.unwrap();
|
||||
assert!(
|
||||
bytes.len() > UPLOAD_THUMB_MAX_BYTES,
|
||||
"the fixture is oversized"
|
||||
);
|
||||
|
||||
let fitted = fit_upload_thumb(bytes).expect("noise fits at half size");
|
||||
assert!(fitted.len() <= UPLOAD_THUMB_MAX_BYTES);
|
||||
assert_eq!(ratio_of(&fitted), Some(1.0), "shrinking keeps the shape");
|
||||
let small = jpeg(64, 64);
|
||||
assert_eq!(
|
||||
fit_upload_thumb(small.clone()),
|
||||
Some(small),
|
||||
"a small one is untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! 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 favourites the old
|
||||
//! 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.
|
||||
//!
|
||||
@@ -213,7 +213,7 @@ impl Core {
|
||||
drop(guard);
|
||||
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||
|
||||
// Favourite and albums before the trash: a failure here leaves both
|
||||
// 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 {
|
||||
@@ -227,7 +227,7 @@ impl Core {
|
||||
// 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-favourite re-dated photo")
|
||||
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,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.
|
||||
@@ -657,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.
|
||||
@@ -671,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +261,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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -35,3 +35,5 @@ adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||
async-channel = "2"
|
||||
exif = { version = "0.6", package = "kamadak-exif" }
|
||||
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,10 +24,6 @@ fn main() {
|
||||
}
|
||||
|
||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||
println!(
|
||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -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 |
@@ -6,5 +6,14 @@
|
||||
<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-offline-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.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,85 @@
|
||||
/* 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; }
|
||||
.brand-icon { color: #6d4aff; }
|
||||
|
||||
/* 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: 4px 2px 8px 2px; }
|
||||
.photo-tile { padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 6px; 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-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); }
|
||||
.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); }
|
||||
@@ -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,34 @@ 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);
|
||||
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 +56,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 +91,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,22 +180,393 @@ 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,
|
||||
&[],
|
||||
),
|
||||
);
|
||||
}
|
||||
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, &[])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,10 +593,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 +620,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 +651,30 @@ 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);
|
||||
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 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);
|
||||
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,
|
||||
&[],
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -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”"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -64,6 +70,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 +90,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 +122,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 +152,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 +162,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 +174,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 +284,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 +296,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 +331,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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -161,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,
|
||||
);
|
||||
|
||||
@@ -214,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();
|
||||
@@ -234,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);
|
||||
}
|
||||
_ => {
|
||||
@@ -245,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(..) {
|
||||
@@ -255,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()));
|
||||
}
|
||||
@@ -294,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)
|
||||
@@ -306,8 +332,8 @@ 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
|
||||
@@ -317,10 +343,14 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
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(UNVERIFIED_INVITER_TOOLTIP));
|
||||
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"));
|
||||
}
|
||||
let ui_act = ui.clone();
|
||||
let uid = entry.uid.clone();
|
||||
let name = entry.name.clone();
|
||||
@@ -340,35 +370,71 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
||||
row
|
||||
}
|
||||
|
||||
/// 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 = "The invitation's signature doesn't match the sender's keys. \
|
||||
It may not really be from them.";
|
||||
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), 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.
|
||||
/// 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() {
|
||||
let date = if entry.shared_at > 0 {
|
||||
format_modified(entry.shared_at)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
parts.push(if date.is_empty() {
|
||||
format!("Shared by {}", entry.shared_by)
|
||||
} else {
|
||||
format!("Shared by {} on {date}", entry.shared_by)
|
||||
});
|
||||
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(" · ")
|
||||
}
|
||||
|
||||
@@ -376,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
|
||||
@@ -404,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 {
|
||||
@@ -423,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."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -441,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;
|
||||
@@ -450,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();
|
||||
@@ -474,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;
|
||||
@@ -600,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");
|
||||
@@ -628,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"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -640,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");
|
||||
@@ -657,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"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -669,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");
|
||||
@@ -696,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();
|
||||
@@ -704,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();
|
||||
@@ -730,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.")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -761,13 +865,10 @@ mod tests {
|
||||
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), "Shared by alice@proton.me");
|
||||
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("Shared by alice@proton.me on "),
|
||||
"{subtitle}"
|
||||
);
|
||||
assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -783,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]
|
||||
@@ -799,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,
|
||||
@@ -118,8 +109,8 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
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();
|
||||
@@ -137,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(_) => {
|
||||
@@ -162,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;
|
||||
@@ -171,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();
|
||||
@@ -192,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;
|
||||
@@ -208,43 +199,31 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
||||
.build();
|
||||
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();
|
||||
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());
|
||||
@@ -252,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(" · ")
|
||||
}
|
||||
|
||||
@@ -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,7 +130,9 @@ 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| {
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -195,11 +211,17 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
}
|
||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||
let kind = if entry.is_dir {
|
||||
"Folder".to_string()
|
||||
gettext("Folder")
|
||||
} else {
|
||||
human_bytes(entry.size)
|
||||
};
|
||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -207,6 +229,49 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
||||
|
||||
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
|
||||
@@ -224,11 +289,12 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
// 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,
|
||||
);
|
||||
|
||||
@@ -243,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),
|
||||
@@ -266,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;
|
||||
@@ -275,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();
|
||||
@@ -294,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));
|
||||
@@ -327,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");
|
||||
@@ -360,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"),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -376,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");
|
||||
@@ -393,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 "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use crate::*;
|
||||
|
||||
/// An action run by an [`ActionMenu`] item.
|
||||
pub(crate) type MenuAction = Box<dyn Fn()>;
|
||||
|
||||
/// A `gio::Menu` whose items run closures, for menus whose contents depend on
|
||||
/// what they were opened for (a row, a selection, a photo). Each item gets its
|
||||
/// own action in a private `menu` group, installed on whatever hosts the
|
||||
/// popover, so the result is a real [`gtk4::PopoverMenu`]: arrow keys, Enter,
|
||||
/// Escape and mnemonics work, and it reads as a menu to assistive technology.
|
||||
pub(crate) struct ActionMenu {
|
||||
menu: gio::Menu,
|
||||
section: gio::Menu,
|
||||
group: gio::SimpleActionGroup,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl ActionMenu {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
menu: gio::Menu::new(),
|
||||
section: gio::Menu::new(),
|
||||
group: gio::SimpleActionGroup::new(),
|
||||
count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_action(&mut self) -> String {
|
||||
self.count += 1;
|
||||
format!("a{}", self.count)
|
||||
}
|
||||
|
||||
/// A plain item.
|
||||
pub(crate) fn item(&mut self, label: &str, run: impl Fn() + 'static) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new(&name, None);
|
||||
action.connect_activate(move |_, _| run());
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// A check item showing `active`; `run` gets the state the user asked for.
|
||||
pub(crate) fn toggle(
|
||||
&mut self,
|
||||
label: &str,
|
||||
active: bool,
|
||||
run: impl Fn(bool) + 'static,
|
||||
) -> &mut Self {
|
||||
let name = self.next_action();
|
||||
let action = gio::SimpleAction::new_stateful(&name, None, &active.to_variant());
|
||||
action.connect_activate(move |action, _| {
|
||||
let next = !action
|
||||
.state()
|
||||
.and_then(|s| s.get::<bool>())
|
||||
.unwrap_or(false);
|
||||
action.set_state(&next.to_variant());
|
||||
run(next);
|
||||
});
|
||||
self.group.add_action(&action);
|
||||
self.section
|
||||
.append(Some(label), Some(&format!("menu.{name}")));
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section; the next item starts a new one below a
|
||||
/// separator. An empty section is skipped, so callers can end a group of
|
||||
/// conditional items unconditionally.
|
||||
pub(crate) fn section(&mut self) -> &mut Self {
|
||||
self.flush(None);
|
||||
self
|
||||
}
|
||||
|
||||
/// Close the current section under a heading.
|
||||
pub(crate) fn labelled_section(&mut self, heading: &str) -> &mut Self {
|
||||
self.flush(Some(heading));
|
||||
self
|
||||
}
|
||||
|
||||
fn flush(&mut self, heading: Option<&str>) {
|
||||
if self.section.n_items() > 0 {
|
||||
self.menu.append_section(heading, &self.section);
|
||||
self.section = gio::Menu::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self) -> (gio::Menu, gio::SimpleActionGroup) {
|
||||
self.flush(None);
|
||||
(self.menu, self.group)
|
||||
}
|
||||
|
||||
/// Pop the menu up over `anchor` at `(x, y)` in its coordinates. The
|
||||
/// popover removes itself once dismissed; it waits for an idle turn, so the
|
||||
/// item that closed it still finds its action.
|
||||
pub(crate) fn popup_at(self, anchor: &impl IsA<gtk4::Widget>, x: f64, y: f64) {
|
||||
let (menu, group) = self.finish();
|
||||
let anchor = anchor.as_ref();
|
||||
let host = popover_host(anchor);
|
||||
let (x, y) = anchor
|
||||
.compute_point(&host, >k4::graphene::Point::new(x as f32, y as f32))
|
||||
.map(|p| (p.x() as f64, p.y() as f64))
|
||||
.unwrap_or((x, y));
|
||||
let popover = gtk4::PopoverMenu::from_model(Some(&menu));
|
||||
popover.set_has_arrow(false);
|
||||
popover.set_position(gtk4::PositionType::Bottom);
|
||||
popover.set_halign(gtk4::Align::Start);
|
||||
popover.set_pointing_to(Some(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
||||
popover.insert_action_group("menu", Some(&group));
|
||||
popover.set_parent(&host);
|
||||
popover.connect_closed(|p| {
|
||||
let p = p.clone();
|
||||
glib::idle_add_local_once(move || p.unparent());
|
||||
});
|
||||
popover.popup();
|
||||
}
|
||||
|
||||
/// A flat ⋮ button that opens this menu.
|
||||
pub(crate) fn button(self) -> gtk4::MenuButton {
|
||||
let (menu, group) = self.finish();
|
||||
let button = gtk4::MenuButton::builder()
|
||||
.icon_name("view-more-symbolic")
|
||||
// Translators: tooltip of a button that opens a menu of further actions.
|
||||
.tooltip_text(gettext("More"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.menu_model(&menu)
|
||||
.build();
|
||||
button.insert_action_group("menu", Some(&group));
|
||||
button.add_css_class("flat");
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a menu opened over `anchor` should live in the widget tree: the
|
||||
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
|
||||
/// selectors reach through a popover to its parent, and themes style the
|
||||
/// insides of list items (`gridview > child box { margin: 12px }` in
|
||||
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
|
||||
/// margin around every section. Under the view itself it is no item's child.
|
||||
fn popover_host(anchor: >k4::Widget) -> gtk4::Widget {
|
||||
let mut host = anchor.clone();
|
||||
let mut widget = anchor.parent();
|
||||
while let Some(w) = widget {
|
||||
widget = w.parent();
|
||||
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
|
||||
host = w;
|
||||
}
|
||||
}
|
||||
host
|
||||
}
|
||||
|
||||
/// A flat ⋮ button whose menu lists `items` as (label, action).
|
||||
pub(crate) fn more_menu_button(items: Vec<(&str, MenuAction)>) -> gtk4::MenuButton {
|
||||
let mut menu = ActionMenu::new();
|
||||
for (label, run) in items {
|
||||
menu.item(label, run);
|
||||
}
|
||||
menu.button()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub(crate) mod details;
|
||||
pub(crate) mod menu;
|
||||
pub(crate) mod share_dialog;
|
||||
pub(crate) mod thumbnails;
|
||||
pub(crate) mod versions_dialog;
|
||||
@@ -1,7 +1,19 @@
|
||||
use crate::*;
|
||||
|
||||
/// The roles offered in the Share dialog's dropdowns, in index order.
|
||||
pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"];
|
||||
/// The role labels offered in the Share dialog's dropdowns, in index order.
|
||||
pub(crate) fn share_roles() -> [String; 3] {
|
||||
[
|
||||
pgettext("role", "Viewer"),
|
||||
pgettext("role", "Editor"),
|
||||
pgettext("role", "Admin"),
|
||||
]
|
||||
}
|
||||
|
||||
/// A dropdown model from translated labels.
|
||||
fn string_list(labels: &[String]) -> gtk4::StringList {
|
||||
let labels: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
gtk4::StringList::new(&labels)
|
||||
}
|
||||
|
||||
/// Map a role dropdown index to the wire role string.
|
||||
pub(crate) fn role_index_to_wire(idx: u32) -> &'static str {
|
||||
@@ -21,6 +33,100 @@ pub(crate) fn role_wire_to_index(role: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The expiry choices offered when creating a public link, in dropdown order,
|
||||
/// with their lifetime in days (`None` never expires).
|
||||
pub(crate) const LINK_EXPIRY: [(&str, Option<i64>); 4] = [
|
||||
// Translators: a public link that never expires.
|
||||
(gettext_noop("Never"), None),
|
||||
(gettext_noop("1 day"), Some(1)),
|
||||
(gettext_noop("7 days"), Some(7)),
|
||||
(gettext_noop("30 days"), Some(30)),
|
||||
];
|
||||
|
||||
/// The Unix expiry for a [`LINK_EXPIRY`] choice, counted from `now`.
|
||||
pub(crate) fn link_expiry_at(idx: u32, now: i64) -> Option<i64> {
|
||||
LINK_EXPIRY
|
||||
.get(idx as usize)
|
||||
.and_then(|(_, days)| *days)
|
||||
.map(|days| now + days * 86_400)
|
||||
}
|
||||
|
||||
/// A loose shape check for an email address: one `@` with something before
|
||||
/// it and a dotted domain after it. The server does the real validation; this
|
||||
/// only catches typos before the Invite button is pressed.
|
||||
pub(crate) fn looks_like_email(s: &str) -> bool {
|
||||
let Some((local, domain)) = s.split_once('@') else {
|
||||
return false;
|
||||
};
|
||||
!local.is_empty()
|
||||
&& !domain.contains('@')
|
||||
&& !s.chars().any(char::is_whitespace)
|
||||
&& domain
|
||||
.split_once('.')
|
||||
.is_some_and(|(host, rest)| !host.is_empty() && !rest.is_empty())
|
||||
&& !domain.ends_with('.')
|
||||
}
|
||||
|
||||
/// Split the invite field into addresses. Returns `Err` with the first entry
|
||||
/// that doesn't look like an email, and `Ok` with an empty list when the field
|
||||
/// is blank.
|
||||
pub(crate) fn parse_emails(raw: &str) -> Result<Vec<String>, String> {
|
||||
raw.split([',', ' ', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
if looks_like_email(s) {
|
||||
Ok(s.to_string())
|
||||
} else {
|
||||
Err(s.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A button showing a spinner while its request runs. It goes insensitive
|
||||
/// on [`Busy::start`] and gets its label or icon back when dropped, so moving
|
||||
/// the guard into the request's future ends the busy state on every path.
|
||||
pub(crate) struct Busy {
|
||||
button: gtk4::Button,
|
||||
label: Option<glib::GString>,
|
||||
icon: Option<glib::GString>,
|
||||
}
|
||||
|
||||
impl Busy {
|
||||
pub(crate) fn start(button: >k4::Button) -> Self {
|
||||
let label = button.label();
|
||||
let icon = button.icon_name();
|
||||
let spinner = gtk4::Spinner::builder().spinning(true).build();
|
||||
match &label {
|
||||
Some(text) => {
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
||||
content.append(&spinner);
|
||||
content.append(>k4::Label::new(Some(text)));
|
||||
button.set_child(Some(&content));
|
||||
}
|
||||
None => button.set_child(Some(&spinner)),
|
||||
}
|
||||
button.set_sensitive(false);
|
||||
Self {
|
||||
button: button.clone(),
|
||||
label,
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Busy {
|
||||
fn drop(&mut self) {
|
||||
if let Some(label) = &self.label {
|
||||
self.button.set_label(label);
|
||||
} else if let Some(icon) = &self.icon {
|
||||
self.button.set_icon_name(icon);
|
||||
}
|
||||
self.button.set_sensitive(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// How an open Share dialog addresses its node.
|
||||
///
|
||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||
@@ -94,19 +200,19 @@ impl ShareTarget {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_link(&self, role: String, password: Option<String>) -> Request {
|
||||
fn create_link(&self, role: String, password: Option<String>, expires: Option<i64>) -> Request {
|
||||
match self {
|
||||
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||
path: path.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
expires,
|
||||
},
|
||||
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||
uid: uid.clone(),
|
||||
role,
|
||||
password,
|
||||
expires: None,
|
||||
expires,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -142,7 +248,11 @@ pub(crate) struct ShareDialog {
|
||||
/// access and their roles, and create/copy/remove a public link.
|
||||
pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(ui, "Can't share", "Proton Drive isn't connected.");
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Can't share"),
|
||||
&gettext("Proton Drive isn't connected."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A node with no path is not a bug here: Shared and Shared-with-me list
|
||||
@@ -155,30 +265,40 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name)));
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new(
|
||||
&pgettext("verb", "Share"),
|
||||
&entry.name,
|
||||
)));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
// Invite section: emails + role + optional message.
|
||||
let invite_group = adw::PreferencesGroup::builder()
|
||||
.title("Invite people")
|
||||
.description("Proton and non-Proton email addresses, separated by spaces or commas.")
|
||||
.title(gettext("Invite people"))
|
||||
.description(gettext(
|
||||
"Proton and non-Proton email addresses, separated by spaces or commas.",
|
||||
))
|
||||
.build();
|
||||
let email_row = adw::EntryRow::builder().title("Email addresses").build();
|
||||
let role_model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let email_row = adw::EntryRow::builder()
|
||||
.title(gettext("Email addresses"))
|
||||
.build();
|
||||
let role_model = string_list(&share_roles());
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_wrap = adw::ActionRow::builder().title("Role").build();
|
||||
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
|
||||
role_wrap.add_suffix(&role_drop);
|
||||
let message_row = adw::EntryRow::builder().title("Message (optional)").build();
|
||||
let message_row = adw::EntryRow::builder()
|
||||
.title(gettext("Message (optional)"))
|
||||
.build();
|
||||
let invite_btn = gtk4::Button::builder()
|
||||
.label("Send Invitations")
|
||||
.label(gettext("Send Invitations"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
invite_btn.add_css_class("suggested-action");
|
||||
invite_btn.set_sensitive(false);
|
||||
let invite_wrap = adw::PreferencesRow::builder()
|
||||
.activatable(false)
|
||||
.child(&invite_btn)
|
||||
@@ -189,10 +309,10 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
invite_group.add(&invite_wrap);
|
||||
|
||||
let people = adw::PreferencesGroup::builder()
|
||||
.title("People with access")
|
||||
.title(gettext("People with access"))
|
||||
.build();
|
||||
let link_group = adw::PreferencesGroup::builder()
|
||||
.title("Public link")
|
||||
.title(gettext("Public link"))
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -212,7 +332,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
toolbar.set_content(Some(&scroll));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Share")
|
||||
.title(pgettext("verb", "Share"))
|
||||
.content_width(480)
|
||||
.content_height(560)
|
||||
.child(&toolbar)
|
||||
@@ -227,6 +347,29 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
link_rows: RefCell::new(Vec::new()),
|
||||
});
|
||||
|
||||
// Check the addresses as they are typed: a malformed one marks the row as
|
||||
// an error and keeps the Invite button off, so a typo never reaches the
|
||||
// server as a failed invitation.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_changed(move |row| {
|
||||
let parsed = parse_emails(&row.text());
|
||||
match &parsed {
|
||||
Err(bad) => {
|
||||
row.add_css_class("error");
|
||||
// Translators: {text} is what the user typed in place of an email address.
|
||||
row.set_tooltip_text(Some(&gettext_f(
|
||||
"“{text}” isn't an email address",
|
||||
&[("text", bad)],
|
||||
)));
|
||||
}
|
||||
Ok(_) => {
|
||||
row.remove_css_class("error");
|
||||
row.set_tooltip_text(None);
|
||||
}
|
||||
}
|
||||
btn.set_sensitive(parsed.is_ok_and(|emails| !emails.is_empty()));
|
||||
});
|
||||
|
||||
// Enter in either free-text field sends the invitations, no mouse needed.
|
||||
let btn = invite_btn.clone();
|
||||
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||
@@ -235,16 +378,15 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
// Invite button.
|
||||
let state_inv = state.clone();
|
||||
invite_btn.connect_clicked(move |_| {
|
||||
let raw = email_row.text();
|
||||
let emails: Vec<String> = raw
|
||||
.split([',', ' ', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
invite_btn.connect_clicked(move |btn| {
|
||||
// Enter in a field reaches here even while the button is off.
|
||||
if !btn.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
let Ok(emails) = parse_emails(&email_row.text()) else {
|
||||
return;
|
||||
};
|
||||
if emails.is_empty() {
|
||||
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
|
||||
return;
|
||||
}
|
||||
let role = role_index_to_wire(role_drop.selected()).to_string();
|
||||
@@ -255,8 +397,9 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
share_dialog_op(
|
||||
&state_inv,
|
||||
state_inv.target.invite(emails, role, message),
|
||||
"Invitations sent",
|
||||
"Couldn't send invitations",
|
||||
&gettext("Invitations sent"),
|
||||
&gettext("Couldn't send invitations"),
|
||||
Some(Busy::start(btn)),
|
||||
Some(Box::new(move || {
|
||||
email_clear.set_text("");
|
||||
msg_clear.set_text("");
|
||||
@@ -279,12 +422,12 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
|
||||
repaint_share_link(&state, link.as_ref());
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't load sharing", &message)
|
||||
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't load sharing",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't load sharing"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -297,7 +440,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
if entries.is_empty() {
|
||||
let row = dim_row("No one else has access yet.");
|
||||
let row = dim_row(&gettext("No one else has access yet."));
|
||||
state.people.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.people_rows.borrow_mut() = rows;
|
||||
@@ -305,9 +448,9 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
}
|
||||
for entry in entries {
|
||||
let subtitle = match entry.kind {
|
||||
ShareEntryKind::Member => "Member".to_string(),
|
||||
ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(),
|
||||
ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(),
|
||||
ShareEntryKind::Member => gettext("Member"),
|
||||
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
|
||||
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&entry.email)
|
||||
@@ -320,7 +463,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
entry.kind,
|
||||
ShareEntryKind::Member | ShareEntryKind::ProtonInvite
|
||||
) {
|
||||
let model = gtk4::StringList::new(&SHARE_ROLES);
|
||||
let model = string_list(&share_roles());
|
||||
let drop = gtk4::DropDown::builder()
|
||||
.model(&model)
|
||||
.selected(role_wire_to_index(&entry.role))
|
||||
@@ -337,15 +480,16 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
kind,
|
||||
role_index_to_wire(d.selected()).to_string(),
|
||||
),
|
||||
"Role updated",
|
||||
"Couldn't update the role",
|
||||
&gettext("Role updated"),
|
||||
&gettext("Couldn't update the role"),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
});
|
||||
row.add_suffix(&drop);
|
||||
} else {
|
||||
let label = gtk4::Label::builder()
|
||||
.label(capitalize(&entry.role))
|
||||
.label(role_label(&entry.role).unwrap_or_else(|| capitalize(&entry.role)))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
label.add_css_class("dim-label");
|
||||
@@ -354,20 +498,37 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove access")
|
||||
.tooltip_text(gettext("Remove access"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let id = entry.id.clone();
|
||||
let kind = entry.kind;
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
state_rm.target.remove_entry(id.clone(), kind),
|
||||
"Access removed",
|
||||
"Couldn't remove access",
|
||||
None,
|
||||
let who = entry.email.clone();
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
&gettext("Remove Access?"),
|
||||
// Translators: {email} is the address of the person losing access.
|
||||
&gettext_f(
|
||||
"{email} will no longer be able to open this item.",
|
||||
&[("email", &who)],
|
||||
),
|
||||
&gettext("Remove"),
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_entry(id.clone(), kind),
|
||||
&gettext("Access removed"),
|
||||
&gettext("Couldn't remove access"),
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
@@ -389,13 +550,23 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
match link {
|
||||
Some(link) => {
|
||||
let url = link.url.clone().unwrap_or_default();
|
||||
let subtitle = if link.has_password {
|
||||
format!("Anyone with the link ({}) · password-protected", link.role)
|
||||
} else {
|
||||
format!("Anyone with the link ({})", link.role)
|
||||
};
|
||||
let role = role_label(&link.role).unwrap_or_else(|| capitalize(&link.role));
|
||||
// Translators: {role} is the link's role, such as "Viewer".
|
||||
let mut subtitle = gettext_f("Anyone with the link ({role})", &[("role", &role)]);
|
||||
if link.has_password {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(&gettext("password-protected"));
|
||||
}
|
||||
if let Some(expires) = link.expires {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(&link_expiry_label(expires, glib::real_time() / 1_000_000));
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(if url.is_empty() { "Public link" } else { &url })
|
||||
.title(if url.is_empty() {
|
||||
gettext("Public link")
|
||||
} else {
|
||||
url.clone()
|
||||
})
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
@@ -403,7 +574,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
if !url.is_empty() {
|
||||
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");
|
||||
@@ -411,26 +582,38 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
let url_copy = url.clone();
|
||||
copy.connect_clicked(move |btn| {
|
||||
btn.clipboard().set_text(&url_copy);
|
||||
toast(&state_copy.ui, "Link copied");
|
||||
toast(&state_copy.ui, &gettext("Link copied"));
|
||||
});
|
||||
row.add_suffix(©);
|
||||
}
|
||||
|
||||
let remove = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Remove link")
|
||||
.tooltip_text(gettext("Remove link"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
remove.add_css_class("flat");
|
||||
let state_rm = state.clone();
|
||||
let id = link.id.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
share_dialog_op(
|
||||
&state_rm,
|
||||
state_rm.target.remove_link(id.clone()),
|
||||
"Public link removed",
|
||||
"Couldn't remove the link",
|
||||
None,
|
||||
remove.connect_clicked(move |btn| {
|
||||
let state = state_rm.clone();
|
||||
let id = id.clone();
|
||||
let button = btn.clone();
|
||||
confirm_destructive(
|
||||
btn,
|
||||
&gettext("Remove Public Link?"),
|
||||
&gettext("The link stops working for everyone who has it. A new link will have a different address."),
|
||||
&gettext("Remove Link"),
|
||||
move || {
|
||||
share_dialog_op(
|
||||
&state,
|
||||
state.target.remove_link(id.clone()),
|
||||
&gettext("Public link removed"),
|
||||
&gettext("Couldn't remove the link"),
|
||||
Some(Busy::start(&button)),
|
||||
None,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
row.add_suffix(&remove);
|
||||
@@ -439,19 +622,30 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
rows.push(row.upcast());
|
||||
}
|
||||
None => {
|
||||
let role_model = gtk4::StringList::new(&["Viewer", "Editor"]);
|
||||
let role_model = string_list(&[pgettext("role", "Viewer"), pgettext("role", "Editor")]);
|
||||
let role_drop = gtk4::DropDown::builder()
|
||||
.model(&role_model)
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let role_row = adw::ActionRow::builder().title("Link role").build();
|
||||
let role_row = adw::ActionRow::builder()
|
||||
.title(gettext("Link role"))
|
||||
.build();
|
||||
role_row.add_suffix(&role_drop);
|
||||
let pw_row = adw::PasswordEntryRow::builder()
|
||||
.title("Password (optional)")
|
||||
.title(gettext("Password (optional)"))
|
||||
.build();
|
||||
let expiry_names: Vec<String> =
|
||||
LINK_EXPIRY.iter().map(|(name, _)| gettext(name)).collect();
|
||||
let expiry_drop = gtk4::DropDown::builder()
|
||||
.model(&string_list(&expiry_names))
|
||||
.selected(0)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
|
||||
expiry_row.add_suffix(&expiry_drop);
|
||||
let create = gtk4::Button::builder()
|
||||
.label("Create Public Link")
|
||||
.label(gettext("Create Public Link"))
|
||||
.halign(gtk4::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
@@ -467,7 +661,10 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
|
||||
let state_c = state.clone();
|
||||
let pw_for = pw_row.clone();
|
||||
create.connect_clicked(move |_| {
|
||||
create.connect_clicked(move |btn| {
|
||||
if !btn.is_sensitive() {
|
||||
return;
|
||||
}
|
||||
let role = if role_drop.selected() == 1 {
|
||||
"editor"
|
||||
} else {
|
||||
@@ -476,14 +673,17 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
||||
.to_string();
|
||||
let pw = pw_for.text().to_string();
|
||||
let password = if pw.is_empty() { None } else { Some(pw) };
|
||||
share_dialog_create_link(&state_c, role, password);
|
||||
let expires = link_expiry_at(expiry_drop.selected(), glib::real_time() / 1_000_000);
|
||||
share_dialog_create_link(&state_c, role, password, expires, Busy::start(btn));
|
||||
});
|
||||
|
||||
state.link_group.add(&role_row);
|
||||
state.link_group.add(&pw_row);
|
||||
state.link_group.add(&expiry_row);
|
||||
state.link_group.add(&create_wrap);
|
||||
rows.push(role_row.upcast());
|
||||
rows.push(pw_row.upcast());
|
||||
rows.push(expiry_row.upcast());
|
||||
rows.push(create_wrap.upcast());
|
||||
}
|
||||
}
|
||||
@@ -496,57 +696,132 @@ pub(crate) fn share_dialog_create_link(
|
||||
state: &Rc<ShareDialog>,
|
||||
role: String,
|
||||
password: Option<String>,
|
||||
expires: Option<i64>,
|
||||
busy: Busy,
|
||||
) {
|
||||
let rx = spawn_request(
|
||||
state.ui.dirs.control_socket(),
|
||||
state.target.create_link(role, password),
|
||||
state.target.create_link(role, password, expires),
|
||||
);
|
||||
let state = state.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
let reply = rx.recv().await;
|
||||
drop(busy);
|
||||
match reply {
|
||||
Ok(Ok(Response::PublicLink { .. })) => {
|
||||
toast(&state.ui, "Public link created");
|
||||
toast(&state.ui, &gettext("Public link created"));
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't create the link", &message)
|
||||
toast_error(&state.ui, &gettext("Couldn't create the link"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't create the link",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't create the link"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
|
||||
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload.
|
||||
/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload;
|
||||
/// `busy` keeps the button that started it spinning until the reply arrives.
|
||||
pub(crate) fn share_dialog_op(
|
||||
state: &Rc<ShareDialog>,
|
||||
req: Request,
|
||||
done: &'static str,
|
||||
failed: &'static str,
|
||||
done: &str,
|
||||
failed: &str,
|
||||
busy: Option<Busy>,
|
||||
on_success: Option<Box<dyn Fn()>>,
|
||||
) {
|
||||
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
||||
let state = state.clone();
|
||||
let (done, failed) = (done.to_string(), failed.to_string());
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
let reply = rx.recv().await;
|
||||
drop(busy);
|
||||
match reply {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
if let Some(cb) = on_success {
|
||||
cb();
|
||||
}
|
||||
toast(&state.ui, done);
|
||||
toast(&state.ui, &done);
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message),
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
|
||||
_ => {
|
||||
// A role dropdown that failed is now out of sync with the server;
|
||||
// reload to snap it back.
|
||||
toast_error(&state.ui, failed, "The mount service didn't respond.");
|
||||
toast_error(
|
||||
&state.ui,
|
||||
&failed,
|
||||
&gettext("The mount service didn't respond."),
|
||||
);
|
||||
share_dialog_reload(&state);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// How an existing link's expiry reads in its subtitle: the date it stops
|
||||
/// working, or that it already has.
|
||||
pub(crate) fn link_expiry_label(expires: i64, now: i64) -> String {
|
||||
if expires <= now {
|
||||
// Translators: a public link past its expiry date.
|
||||
return gettext("expired");
|
||||
}
|
||||
// Translators: strftime format for a link's expiry date; "%x" is the locale's date, such as "09/23/26".
|
||||
let format = gettext("%x");
|
||||
let date = glib::DateTime::from_unix_local(expires)
|
||||
.ok()
|
||||
.and_then(|at| at.format(&format).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
// Translators: {date} is the date a public link stops working.
|
||||
gettext_f("expires {date}", &[("date", &date)])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn email_check_accepts_plain_addresses() {
|
||||
assert!(looks_like_email("a@b.co"));
|
||||
assert!(looks_like_email("first.last+tag@mail.example.org"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_check_rejects_typos() {
|
||||
for bad in [
|
||||
"a", "a@", "@b.co", "a@b", "a@b.", "a@.co", "a@@b.co", "a@b@c.co",
|
||||
] {
|
||||
assert!(!looks_like_email(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_emails_splits_and_reports_first_bad_entry() {
|
||||
assert_eq!(
|
||||
parse_emails("a@b.co, c@d.org;e@f.net"),
|
||||
Ok(vec!["a@b.co".into(), "c@d.org".into(), "e@f.net".into()])
|
||||
);
|
||||
assert_eq!(parse_emails(" , "), Ok(vec![]));
|
||||
assert_eq!(parse_emails("a@b.co oops"), Err("oops".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_expiry_counts_days_from_now() {
|
||||
assert_eq!(link_expiry_at(0, 1_000), None);
|
||||
assert_eq!(link_expiry_at(1, 1_000), Some(1_000 + 86_400));
|
||||
assert_eq!(link_expiry_at(3, 0), Some(30 * 86_400));
|
||||
assert_eq!(link_expiry_at(99, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_expiry_reads_expired() {
|
||||
assert_eq!(link_expiry_label(10, 20), "expired");
|
||||
assert!(link_expiry_label(2_000_000_000, 0).starts_with("expires "));
|
||||
}
|
||||
}
|
||||
@@ -85,11 +85,19 @@ pub(crate) struct VersionsDialog {
|
||||
/// Open the Versions dialog for a file.
|
||||
pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
if !*ui.mounted.borrow() {
|
||||
toast_error(ui, "Can't load versions", "Proton Drive isn't connected.");
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("Can't load versions"),
|
||||
&gettext("Proton Drive isn't connected."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if entry.is_dir {
|
||||
toast_error(ui, "No versions", "Folders don't have versions.");
|
||||
toast_error(
|
||||
ui,
|
||||
&gettext("No versions"),
|
||||
&gettext("Folders don't have versions."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let target = if entry.path.is_empty() {
|
||||
@@ -100,12 +108,17 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new("Versions", &entry.name)));
|
||||
header.set_title_widget(Some(&adw::WindowTitle::new(
|
||||
&gettext("Versions"),
|
||||
&entry.name,
|
||||
)));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Version history")
|
||||
.description("Proton Drive keeps earlier versions of a file until you delete them.")
|
||||
.title(gettext("Version history"))
|
||||
.description(gettext(
|
||||
"Proton Drive keeps earlier versions of a file until you delete them.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
@@ -123,7 +136,7 @@ pub(crate) fn open_versions_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||
toolbar.set_content(Some(&scroll));
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Versions")
|
||||
.title(gettext("Versions"))
|
||||
.content_width(520)
|
||||
.content_height(520)
|
||||
.child(&toolbar)
|
||||
@@ -149,12 +162,12 @@ pub(crate) fn versions_dialog_reload(state: &Rc<VersionsDialog>) {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Revisions { items })) => repaint_versions(&state, &items),
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
toast_error(&state.ui, "Couldn't load versions", &message)
|
||||
toast_error(&state.ui, &gettext("Couldn't load versions"), &message)
|
||||
}
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
"Couldn't load versions",
|
||||
"The mount service didn't respond.",
|
||||
&gettext("Couldn't load versions"),
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
@@ -167,7 +180,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
}
|
||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||
if items.is_empty() {
|
||||
let row = dim_row("No earlier versions.");
|
||||
let row = dim_row(&gettext("No earlier versions."));
|
||||
state.group.add(&row);
|
||||
rows.push(row.upcast());
|
||||
*state.rows.borrow_mut() = rows;
|
||||
@@ -182,16 +195,27 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
.filter(|size| *size >= 0)
|
||||
.unwrap_or(item.size_on_storage)
|
||||
.max(0) as u64;
|
||||
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
||||
// A revision id means nothing to a person; name earlier versions by
|
||||
// when they were saved instead.
|
||||
let (title, mut subtitle) = if item.is_active {
|
||||
(
|
||||
gettext("Current version"),
|
||||
// Translators: {time} is when the version was saved, {size} its file size.
|
||||
gettext_f(
|
||||
"{time} · {size}",
|
||||
&[
|
||||
("time", &activity_time(item.created)),
|
||||
("size", &human_bytes(size)),
|
||||
],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(activity_time(item.created), human_bytes(size))
|
||||
};
|
||||
if let Some(email) = &item.signed_by {
|
||||
subtitle.push_str(" · ");
|
||||
subtitle.push_str(email);
|
||||
}
|
||||
let title = if item.is_active {
|
||||
"Current version".to_string()
|
||||
} else {
|
||||
format!("Version {}", item.id)
|
||||
};
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
@@ -199,7 +223,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
|
||||
let save = gtk4::Button::builder()
|
||||
.icon_name("document-save-symbolic")
|
||||
.tooltip_text("Save a copy…")
|
||||
.tooltip_text(gettext("Save a copy…"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
save.add_css_class("flat");
|
||||
@@ -211,7 +235,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
if !item.is_active {
|
||||
let restore = gtk4::Button::builder()
|
||||
.icon_name("edit-undo-symbolic")
|
||||
.tooltip_text("Restore this version")
|
||||
.tooltip_text(gettext("Restore this version"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
restore.add_css_class("flat");
|
||||
@@ -222,7 +246,7 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
|
||||
let delete = gtk4::Button::builder()
|
||||
.icon_name("user-trash-symbolic")
|
||||
.tooltip_text("Delete this version permanently")
|
||||
.tooltip_text(gettext("Delete this version permanently"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
delete.add_css_class("flat");
|
||||
@@ -242,14 +266,15 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
||||
/// every device on the account, not only this one.
|
||||
fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Restore version")
|
||||
.body(format!(
|
||||
"Make this version the current content of “{}”? The version it replaces stays in the history.",
|
||||
state.name
|
||||
.heading(gettext("Restore version"))
|
||||
// Translators: {name} is a file name.
|
||||
.body(gettext_f(
|
||||
"Make this version the current content of “{name}”? The version it replaces stays in the history.",
|
||||
&[("name", &state.name)],
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("restore", "Restore");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("restore", &pgettext("verb", "Restore"));
|
||||
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -266,8 +291,8 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
state.target.restore(revision_id.clone()),
|
||||
// The server applies the swap in the background, so the wording
|
||||
// promises the request, not the result.
|
||||
"Restoring that version",
|
||||
"Couldn't restore that version",
|
||||
&gettext("Restoring that version"),
|
||||
&gettext("Couldn't restore that version"),
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
@@ -276,11 +301,13 @@ fn prompt_restore_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
/// Confirm, then permanently delete one revision.
|
||||
fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Delete version")
|
||||
.body("Delete this version permanently? Its content can't be recovered.")
|
||||
.heading(gettext("Delete version"))
|
||||
.body(gettext(
|
||||
"Delete this version permanently? Its content can't be recovered.",
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("delete", "Delete");
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("delete", &pgettext("verb", "Delete"));
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
@@ -295,8 +322,8 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.delete(revision_id.clone()),
|
||||
"Version deleted",
|
||||
"Couldn't delete that version",
|
||||
&gettext("Version deleted"),
|
||||
&gettext("Couldn't delete that version"),
|
||||
);
|
||||
});
|
||||
dialog.present(window.as_ref());
|
||||
@@ -310,7 +337,7 @@ fn prompt_delete_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let window = ui_window(&state.ui);
|
||||
let dialog = gtk4::FileDialog::builder()
|
||||
.title("Save version")
|
||||
.title(gettext("Save version"))
|
||||
.initial_name(&state.name)
|
||||
.build();
|
||||
let state = state.clone();
|
||||
@@ -320,16 +347,16 @@ fn prompt_save_version(state: &Rc<VersionsDialog>, revision_id: &str) {
|
||||
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
||||
toast_error(
|
||||
&state.ui,
|
||||
"Couldn't save that version",
|
||||
"That location isn't a local file.",
|
||||
&gettext("Couldn't save that version"),
|
||||
&gettext("That location isn't a local file."),
|
||||
);
|
||||
return;
|
||||
};
|
||||
versions_dialog_op(
|
||||
&state,
|
||||
state.target.save_as(revision_id.clone(), dest),
|
||||
"Version saved",
|
||||
"Couldn't save that version",
|
||||
&gettext("Version saved"),
|
||||
&gettext("Couldn't save that version"),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -347,7 +374,11 @@ fn versions_dialog_op(state: &Rc<VersionsDialog>, request: Request, done: &str,
|
||||
versions_dialog_reload(&state);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, &failed, &message),
|
||||
_ => toast_error(&state.ui, &failed, "The mount service didn't respond."),
|
||||
_ => toast_error(
|
||||
&state.ui,
|
||||
&failed,
|
||||
&gettext("The mount service didn't respond."),
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,8 +16,8 @@ use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
use crate::Hit;
|
||||
use crate::query;
|
||||
use crate::{Hit, gettext, gettext_f};
|
||||
|
||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||
/// without it a scripted (non-interactive) menu could spin forever.
|
||||
@@ -90,8 +90,10 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||
/// shows which round you are in.
|
||||
fn title(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("Drive: {text} › "),
|
||||
None => "Search Drive › ".to_string(),
|
||||
// Translators: the launcher prompt while refining a search; {query} is the text searched for. Keep the trailing space.
|
||||
Some(text) => gettext_f("Drive: {query} › ", &[("query", text)]),
|
||||
// Translators: the launcher prompt; keep the trailing space, the input follows it directly.
|
||||
None => gettext("Search Drive › "),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,8 +109,10 @@ fn title(query: Option<&str>) -> String {
|
||||
/// wording is parenthesised and avoids words that read like a filename query.
|
||||
fn placeholder(query: Option<&str>) -> String {
|
||||
match query {
|
||||
Some(text) => format!("(no matches for “{text}”)"),
|
||||
None => "(no pinned files — type to search)".to_string(),
|
||||
// Translators: the only row of an empty launcher list; {query} is the text searched for. Keep the parentheses.
|
||||
Some(text) => gettext_f("(no matches for “{query}”)", &[("query", text)]),
|
||||
// Translators: the only row of an empty launcher list. Keep the parentheses, and avoid words that look like a file name.
|
||||
None => gettext("(no pinned files — type to search)"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +146,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
@@ -32,13 +32,14 @@ use pdfs_core::menu::{self, PromptConfig};
|
||||
use pdfs_core::opener::OpenWith;
|
||||
|
||||
use crate::query;
|
||||
use crate::{Hit, SEARCH_DEBOUNCE};
|
||||
use crate::{Hit, SEARCH_DEBOUNCE, gettext, gettext_noop};
|
||||
|
||||
/// Separates the visible half of a feed line from its payload.
|
||||
const FIELD: char = '\t';
|
||||
|
||||
/// What the launcher prompt reads, matching the dmenu front end's wording.
|
||||
const PROMPT: &str = "Search Drive › ";
|
||||
// Translators: the launcher prompt; keep the trailing space, the input follows it directly.
|
||||
const PROMPT: &str = gettext_noop("Search Drive › ");
|
||||
|
||||
pub(crate) struct Options {
|
||||
/// Search for this immediately instead of opening on the pinned files.
|
||||
@@ -166,7 +167,8 @@ fn interactive(query: Option<String>) -> Result<(), String> {
|
||||
/// empty list with no explanation, so a failure becomes a visible row instead.
|
||||
pub(crate) fn feed(query: Option<&str>) {
|
||||
let Ok(dirs) = AppDirs::new() else {
|
||||
println!("(cannot resolve app dirs)");
|
||||
// Translators: shown as a row in the search list. Keep the parentheses.
|
||||
println!("{}", gettext("(cannot resolve app dirs)"));
|
||||
return;
|
||||
};
|
||||
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
|
||||
@@ -219,7 +221,7 @@ fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
|
||||
.iter()
|
||||
.map(|arg| (*arg).to_string())
|
||||
.collect();
|
||||
argv.push(format!("--prompt={PROMPT}"));
|
||||
argv.push(format!("--prompt={}", gettext(PROMPT)));
|
||||
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the
|
||||
// end of the argument, so an install path containing a bracket cannot
|
||||
// truncate the command fzf parses out of it.
|
||||
@@ -268,6 +270,7 @@ mod tests {
|
||||
size: 7,
|
||||
modified: 9,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid~1".into(),
|
||||
mounted_path: None,
|
||||
score: 42,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Translation layer shared by `pdfs-app`, `pdfs-tray` and `pdfs-prompt`.
|
||||
//!
|
||||
//! Strings go through GNU gettext in the `pdfs` text domain. [`init`] runs first
|
||||
//! in every binary: it applies the language picked in Preferences (if any) on
|
||||
//! top of the system locale, then binds the domain to the installed catalogs.
|
||||
//!
|
||||
//! Messages with values use named placeholders (`{name}`) filled in by
|
||||
//! [`gettext_f`] and [`ngettext_f`], so translators can reorder them. `po/`
|
||||
//! holds the template and the catalogs; see `docs/TRANSLATING.md`.
|
||||
|
||||
// Each binary includes this module and uses a different part of it.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use gettextrs::LocaleCategory;
|
||||
use pdfs_core::config::AppDirs;
|
||||
|
||||
/// The gettext text domain, matching `po/pdfs.pot` and the installed `.mo` name.
|
||||
pub(crate) const DOMAIN: &str = "pdfs";
|
||||
|
||||
/// Overrides the catalog directory at run time, for running from a source tree
|
||||
/// (`PDFS_LOCALEDIR=target/locale`).
|
||||
const LOCALEDIR_ENV: &str = "PDFS_LOCALEDIR";
|
||||
|
||||
/// Where packages install the catalogs unless the build sets `PDFS_LOCALEDIR`.
|
||||
const DEFAULT_LOCALEDIR: &str = "/usr/share/locale";
|
||||
|
||||
/// Languages the app ships a catalog for, as (gettext code, native name). The
|
||||
/// Preferences picker lists them after "System Default". US English is the
|
||||
/// source language and needs no catalog. Names stay untranslated so a user can
|
||||
/// always find their own language.
|
||||
pub(crate) const LANGUAGES: &[(&str, &str)] = &[
|
||||
("de", "Deutsch"),
|
||||
("en_GB", "English (UK)"),
|
||||
("en_US", "English (US)"),
|
||||
("es", "Español"),
|
||||
("fr", "Français"),
|
||||
("it", "Italiano"),
|
||||
("nl", "Nederlands"),
|
||||
("pl", "Polski"),
|
||||
("pt_BR", "Português (Brasil)"),
|
||||
];
|
||||
|
||||
/// Set up the locale and the text domain. Call first thing in `main`, before
|
||||
/// any thread starts: an explicit language is applied through the process
|
||||
/// environment, so that GTK's own strings follow it too.
|
||||
pub(crate) fn init() {
|
||||
let language = AppDirs::new()
|
||||
.ok()
|
||||
.and_then(|dirs| dirs.load_config().language)
|
||||
.filter(|code| LANGUAGES.iter().any(|(known, _)| known == code));
|
||||
gettextrs::setlocale(LocaleCategory::LcAll, "");
|
||||
if let Some(code) = language {
|
||||
apply_language(&code);
|
||||
}
|
||||
let dir = std::env::var(LOCALEDIR_ENV)
|
||||
.ok()
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
option_env!("PDFS_LOCALEDIR")
|
||||
.unwrap_or(DEFAULT_LOCALEDIR)
|
||||
.to_string()
|
||||
});
|
||||
if let Err(e) = gettextrs::bindtextdomain(DOMAIN, dir) {
|
||||
tracing::warn!("bindtextdomain failed: {e}");
|
||||
}
|
||||
let _ = gettextrs::bind_textdomain_codeset(DOMAIN, "UTF-8");
|
||||
if let Err(e) = gettextrs::textdomain(DOMAIN) {
|
||||
tracing::warn!("textdomain failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `code` the message language. gettext reads `LANGUAGE` first, but
|
||||
/// ignores it while the message locale is "C" or "C.UTF-8", so a system without
|
||||
/// a configured locale also gets its message locale moved to a real one. Both
|
||||
/// go into the environment, because GTK calls `setlocale` again during its own
|
||||
/// start.
|
||||
fn apply_language(code: &str) {
|
||||
// SAFETY: `init` runs at the top of `main`, before any other thread exists.
|
||||
unsafe { std::env::set_var("LANGUAGE", code) };
|
||||
let current = gettextrs::setlocale(LocaleCategory::LcMessages, "")
|
||||
.map(|name| String::from_utf8_lossy(&name).into_owned())
|
||||
.unwrap_or_default();
|
||||
if !is_c_locale(¤t) {
|
||||
return;
|
||||
}
|
||||
for candidate in message_locales(code) {
|
||||
if gettextrs::setlocale(LocaleCategory::LcMessages, candidate.as_str()).is_some() {
|
||||
// SAFETY: as above, still single-threaded.
|
||||
unsafe { std::env::set_var("LC_MESSAGES", &candidate) };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_c_locale(name: &str) -> bool {
|
||||
let base = name.split('.').next().unwrap_or(name);
|
||||
matches!(base, "" | "C" | "POSIX")
|
||||
}
|
||||
|
||||
/// Locales to try, in order, as the message locale for `code`: the language's
|
||||
/// own locale, then US English, which any locale-enabled system has and which
|
||||
/// is enough for gettext to honour `LANGUAGE`.
|
||||
fn message_locales(code: &str) -> Vec<String> {
|
||||
let full = if code.contains('_') {
|
||||
code.to_string()
|
||||
} else {
|
||||
format!("{code}_{}", code.to_uppercase())
|
||||
};
|
||||
let mut locales = vec![format!("{full}.UTF-8")];
|
||||
if full != "en_US" {
|
||||
locales.push("en_US.UTF-8".to_string());
|
||||
}
|
||||
locales
|
||||
}
|
||||
|
||||
/// Mark `msgid` for extraction without translating it yet, for tables of
|
||||
/// labels built at compile time. Pass the value through [`gettext`] where it is
|
||||
/// shown.
|
||||
pub(crate) const fn gettext_noop(msgid: &'static str) -> &'static str {
|
||||
msgid
|
||||
}
|
||||
|
||||
/// Translate `msgid`.
|
||||
pub(crate) fn gettext(msgid: &str) -> String {
|
||||
gettextrs::gettext(msgid)
|
||||
}
|
||||
|
||||
/// Translate `msgid` in context `context`, for short strings whose meaning
|
||||
/// depends on where they appear ("Open" the verb against "Open" the state).
|
||||
pub(crate) fn pgettext(context: &str, msgid: &str) -> String {
|
||||
gettextrs::pgettext(context, msgid)
|
||||
}
|
||||
|
||||
/// Translate the singular or plural form for `n`.
|
||||
pub(crate) fn ngettext(singular: &str, plural: &str, n: u64) -> String {
|
||||
gettextrs::ngettext(singular, plural, plural_count(n))
|
||||
}
|
||||
|
||||
/// Translate `msgid`, then fill its `{name}` placeholders from `args`.
|
||||
pub(crate) fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String {
|
||||
fill(&gettext(msgid), args)
|
||||
}
|
||||
|
||||
/// Translate the form for `n`, then fill `{n}` with the count and the other
|
||||
/// placeholders from `args`.
|
||||
pub(crate) fn ngettext_f(singular: &str, plural: &str, n: u64, args: &[(&str, &str)]) -> String {
|
||||
let count = n.to_string();
|
||||
let filled = fill(&ngettext(singular, plural, n), args);
|
||||
fill(&filled, &[("n", &count)])
|
||||
}
|
||||
|
||||
/// The translated form of [`pdfs_core::control::pending_summary`]: what is
|
||||
/// still waiting to go up, or `None` when nothing is.
|
||||
pub(crate) fn pending_summary(uploads: u64, changes: u64) -> Option<String> {
|
||||
match (uploads, changes) {
|
||||
(0, 0) => None,
|
||||
(uploads, 0) => Some(ngettext_f(
|
||||
"{n} upload queued",
|
||||
"{n} uploads queued",
|
||||
uploads,
|
||||
&[],
|
||||
)),
|
||||
(0, changes) => Some(ngettext_f(
|
||||
"{n} change queued",
|
||||
"{n} changes queued",
|
||||
changes,
|
||||
&[],
|
||||
)),
|
||||
(uploads, changes) => {
|
||||
let uploads = ngettext_f("{n} upload", "{n} uploads", uploads, &[]);
|
||||
let changes = ngettext_f("{n} change", "{n} changes", changes, &[]);
|
||||
// Translators: {uploads} is "3 uploads", {changes} is "2 changes".
|
||||
Some(gettext_f(
|
||||
"{uploads}, {changes} queued",
|
||||
&[("uploads", &uploads), ("changes", &changes)],
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// gettext takes the count as `u32`. Counts past that pick the same form as
|
||||
/// the largest one, which is the plural in every shipped language.
|
||||
fn plural_count(n: u64) -> u32 {
|
||||
u32::try_from(n).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// Replace each `{key}` in `template` with its value. Unknown placeholders are
|
||||
/// left as they are, so a translation mistake shows up rather than vanishing.
|
||||
fn fill(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut out = template.to_string();
|
||||
for (key, value) in args {
|
||||
out = out.replace(&format!("{{{key}}}"), value);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fill_replaces_named_placeholders_in_any_order() {
|
||||
assert_eq!(
|
||||
fill("{b} before {a}", &[("a", "x"), ("b", "y")]),
|
||||
"y before x"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_keeps_unknown_placeholders() {
|
||||
assert_eq!(fill("{missing} {a}", &[("a", "1")]), "{missing} 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ngettext_f_fills_the_count() {
|
||||
assert_eq!(ngettext_f("{n} file", "{n} files", 1, &[]), "1 file");
|
||||
assert_eq!(
|
||||
ngettext_f(
|
||||
"{n} file in {dir}",
|
||||
"{n} files in {dir}",
|
||||
3,
|
||||
&[("dir", "A")]
|
||||
),
|
||||
"3 files in A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_summary_matches_the_core_wording() {
|
||||
for (uploads, changes) in [(0, 0), (1, 0), (3, 0), (0, 1), (2, 4)] {
|
||||
assert_eq!(
|
||||
pending_summary(uploads, changes),
|
||||
pdfs_core::control::pending_summary(uploads, changes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plural_count_saturates() {
|
||||
assert_eq!(plural_count(7), 7);
|
||||
assert_eq!(plural_count(u64::MAX), u32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_codes_are_unique_and_sorted() {
|
||||
let codes: Vec<&str> = LANGUAGES.iter().map(|(code, _)| *code).collect();
|
||||
let mut sorted = codes.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(codes, sorted);
|
||||
}
|
||||
|
||||
fn repo_file(path: &str) -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.join(path)
|
||||
}
|
||||
|
||||
fn linguas() -> Vec<String> {
|
||||
std::fs::read_to_string(repo_file("po/LINGUAS"))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_offered_language_has_a_catalog() {
|
||||
let linguas = linguas();
|
||||
for (code, _) in LANGUAGES {
|
||||
// US English is the source language.
|
||||
if *code != "en_US" {
|
||||
assert!(
|
||||
linguas.iter().any(|lang| lang == code),
|
||||
"{code} is not in po/LINGUAS"
|
||||
);
|
||||
}
|
||||
}
|
||||
for lang in &linguas {
|
||||
assert!(
|
||||
repo_file(&format!("po/{lang}.po")).exists(),
|
||||
"po/{lang}.po is missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn potfiles_lists_every_source_that_translates() {
|
||||
let listed = std::fs::read_to_string(repo_file("po/POTFILES.in")).unwrap();
|
||||
let listed: Vec<&str> = listed.lines().map(str::trim).collect();
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut stack = vec![src];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|ext| ext != "rs") {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
if !text.contains("gettext") {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(env!("CARGO_MANIFEST_DIR")).unwrap();
|
||||
let name = format!("crates/pdfs-gui/{}", relative.display());
|
||||
assert!(
|
||||
listed.contains(&name.as_str()),
|
||||
"{name} is not in po/POTFILES.in"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `msgfmt --check` rejects a translation whose `{placeholders}` differ from
|
||||
/// the English, besides syntax errors. Skipped where gettext is not installed.
|
||||
#[test]
|
||||
fn catalogs_compile_with_matching_placeholders() {
|
||||
let msgfmt = std::process::Command::new("msgfmt")
|
||||
.arg("--version")
|
||||
.output();
|
||||
if msgfmt.is_err() {
|
||||
eprintln!("msgfmt not installed; skipping the catalog check");
|
||||
return;
|
||||
}
|
||||
for lang in linguas() {
|
||||
let output = std::process::Command::new("msgfmt")
|
||||
.args(["--check", "--output-file=/dev/null"])
|
||||
.arg(repo_file(&format!("po/{lang}.po")))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"po/{lang}.po: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_locales_are_detected() {
|
||||
assert!(is_c_locale("C"));
|
||||
assert!(is_c_locale("POSIX"));
|
||||
assert!(is_c_locale("C.UTF-8"));
|
||||
assert!(is_c_locale("C.utf8"));
|
||||
assert!(!is_c_locale("de_DE.UTF-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_locales_start_with_the_language_region() {
|
||||
assert_eq!(message_locales("de"), ["de_DE.UTF-8", "en_US.UTF-8"]);
|
||||
assert_eq!(message_locales("pt_BR"), ["pt_BR.UTF-8", "en_US.UTF-8"]);
|
||||
assert_eq!(message_locales("en_US"), ["en_US.UTF-8"]);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client.
|
||||
//!
|
||||
//! Slice 1 of the GUI phase: a StatusNotifierItem (SNI) tray icon with a menu
|
||||
//! showing login/mount status and offering mount, unmount, open-folder and quit.
|
||||
//! It talks to the running mount daemon over the same control socket the CLI
|
||||
//! uses, so the tray is a thin presentation layer over [`pdfs_core::control`].
|
||||
//! A StatusNotifierItem (SNI) tray icon whose icon and tooltip say what the
|
||||
//! drive is doing (synced, syncing, paused, offline, needs attention), with a
|
||||
//! menu to open the app or the folder, pause sync, connect, hide the icon and
|
||||
//! stop Proton Drive. It talks to the running mount daemon over the same
|
||||
//! control socket the CLI uses, so the tray is a thin presentation layer over
|
||||
//! [`pdfs_core::control`]. Anything that needs a dialog opens `pdfs-app`.
|
||||
//!
|
||||
//! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an
|
||||
//! SNI item via the `ksni` crate; the GTK4/libadwaita settings window arrives in
|
||||
//! a later slice.
|
||||
//! SNI item via the `ksni` crate.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
@@ -16,22 +18,57 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
use ksni::blocking::TrayMethods;
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{MenuItem, Tray};
|
||||
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||
use pdfs_core::auth;
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{
|
||||
JobItem, Request, Response, TransferDirection, TransferItem, pending_summary, send,
|
||||
};
|
||||
use pdfs_core::control::{JobItem, Request, Response, TransferDirection, TransferItem, send};
|
||||
use pdfs_core::service;
|
||||
|
||||
mod i18n;
|
||||
use i18n::{gettext, gettext_f, ngettext_f};
|
||||
|
||||
/// How often the tray re-polls the daemon to refresh its menu.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The one word the icon says about the drive, derived from each poll.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Mounted, online, nothing queued.
|
||||
Synced,
|
||||
/// Mounted with changes queued or bytes moving.
|
||||
Syncing,
|
||||
Paused,
|
||||
/// Mounted but the API is unreachable: cached files only.
|
||||
Offline,
|
||||
/// Changes keep failing; the user has to look.
|
||||
Attention,
|
||||
/// Signed in, but no mount daemon is running.
|
||||
Disconnected,
|
||||
SignedOut,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
/// A stock symbolic icon for the phase. Stock names follow the panel's
|
||||
/// theme and colour, and need nothing installed beside the binary.
|
||||
fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Phase::Synced => "folder-remote-symbolic",
|
||||
Phase::Syncing => "emblem-synchronizing-symbolic",
|
||||
Phase::Paused => "media-playback-pause-symbolic",
|
||||
Phase::Offline => "network-offline-symbolic",
|
||||
Phase::Attention => "dialog-warning-symbolic",
|
||||
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of what the tray knows about the daemon, recomputed each poll.
|
||||
#[derive(Clone)]
|
||||
struct DriveState {
|
||||
/// Human-readable first menu line ("Mounted at …", "Not running", …).
|
||||
/// Human-readable first menu line ("Mounted at …", "Not running", …), also
|
||||
/// the tooltip.
|
||||
line: String,
|
||||
phase: Phase,
|
||||
/// Whether a mount daemon is currently serving the control socket.
|
||||
mounted: bool,
|
||||
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
||||
@@ -39,6 +76,10 @@ struct DriveState {
|
||||
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
|
||||
/// disabled menu line under the status line.
|
||||
sync: String,
|
||||
/// Whether the user has sync paused; decides Pause vs Resume.
|
||||
paused: bool,
|
||||
/// Queued changes that keep failing, for the "N issues" item.
|
||||
failing: u64,
|
||||
}
|
||||
|
||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||
@@ -49,7 +90,14 @@ struct DriveState {
|
||||
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
match items {
|
||||
[] => match jobs.first() {
|
||||
Some(j) if j.total > 0 => format!("{} ({} of {})", j.title, j.done, j.total),
|
||||
Some(j) if j.total > 0 => {
|
||||
let (done, total) = (j.done.to_string(), j.total.to_string());
|
||||
// Translators: {title} names a running job such as a scan; {done} and {total} are counts.
|
||||
gettext_f(
|
||||
"{title} ({done} of {total})",
|
||||
&[("title", &j.title), ("done", &done), ("total", &total)],
|
||||
)
|
||||
}
|
||||
Some(j) => format!("{}…", j.title),
|
||||
None => String::new(),
|
||||
},
|
||||
@@ -72,8 +120,10 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
.count();
|
||||
let up = items.len() - down;
|
||||
match (down, up) {
|
||||
(d, 0) => format!("↓ {d} downloading"),
|
||||
(0, u) => format!("↑ {u} uploading"),
|
||||
// Translators: {n} files are downloading; keep the arrow.
|
||||
(d, 0) => ngettext_f("↓ {n} downloading", "↓ {n} downloading", d as u64, &[]),
|
||||
// Translators: {n} files are uploading; keep the arrow.
|
||||
(0, u) => ngettext_f("↑ {n} uploading", "↑ {n} uploading", u as u64, &[]),
|
||||
(d, u) => format!("↓ {d} · ↑ {u}"),
|
||||
}
|
||||
}
|
||||
@@ -82,6 +132,7 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
|
||||
struct DriveTray {
|
||||
state: DriveState,
|
||||
socket: PathBuf,
|
||||
}
|
||||
|
||||
/// Ask the daemon for its status, falling back to the stored session so the
|
||||
@@ -94,64 +145,157 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
..
|
||||
}) => DriveState {
|
||||
line: match (online, pending_summary(pending_uploads, pending_changes)) {
|
||||
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
|
||||
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
},
|
||||
mounted: true,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
}) => {
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
sync: match send(socket, &Request::GetQueueStatus) {
|
||||
let sync = match send(socket, &Request::GetQueueStatus) {
|
||||
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||
_ => String::new(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let queued = i18n::pending_summary(pending_uploads, pending_changes);
|
||||
let phase = phase_of(
|
||||
paused,
|
||||
failing_ops,
|
||||
online,
|
||||
queued.is_some() || !sync.is_empty(),
|
||||
);
|
||||
DriveState {
|
||||
line: match (online, queued) {
|
||||
_ if paused => gettext("Sync paused"),
|
||||
_ if failing_ops > 0 => ngettext_f(
|
||||
"{n} change needs attention",
|
||||
"{n} changes need attention",
|
||||
failing_ops,
|
||||
&[],
|
||||
),
|
||||
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
|
||||
(true, None) => ngettext_f(
|
||||
"Mounted at {mountpoint} ({n} pinned)",
|
||||
"Mounted at {mountpoint} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("mountpoint", &mountpoint)],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
|
||||
(true, Some(q)) => ngettext_f(
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
"Syncing — {queued} ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[("queued", &q)],
|
||||
),
|
||||
// Translators: {n} is the number of pinned items.
|
||||
(false, None) => ngettext_f(
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
"Offline — cached files only ({n} pinned)",
|
||||
pinned as u64,
|
||||
&[],
|
||||
),
|
||||
// Translators: {queued} is a queue summary such as "3 uploads queued".
|
||||
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
|
||||
},
|
||||
phase,
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
sync,
|
||||
failing: failing_ops,
|
||||
}
|
||||
}
|
||||
// Socket answered but with something unexpected — treat as up but odd.
|
||||
Ok(_) => DriveState {
|
||||
line: "Mount: unexpected daemon response".into(),
|
||||
line: gettext("Mount: unexpected daemon response"),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
},
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
let line = match auth::load() {
|
||||
Ok(s) => format!("Logged in as {} — not mounted", s.username),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(),
|
||||
Err(e) => format!("Error: {e}"),
|
||||
let (line, phase) = match auth::load() {
|
||||
Ok(s) => (
|
||||
// Translators: {username} is the Proton account name.
|
||||
gettext_f(
|
||||
"Logged in as {username} — not mounted",
|
||||
&[("username", &s.username)],
|
||||
),
|
||||
Phase::Disconnected,
|
||||
),
|
||||
Err(pdfs_core::Error::NotLoggedIn) => (gettext("Not signed in"), Phase::SignedOut),
|
||||
Err(e) => (
|
||||
// Translators: {error} is an error message, in English.
|
||||
gettext_f("Error: {error}", &[("error", &e.to_string())]),
|
||||
Phase::Attention,
|
||||
),
|
||||
};
|
||||
DriveState {
|
||||
line,
|
||||
phase,
|
||||
mounted: false,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The phase a running mount is in. A pause is the user's own doing and says
|
||||
/// so first; failures outrank everything else the daemon reports.
|
||||
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
|
||||
if paused {
|
||||
Phase::Paused
|
||||
} else if failing > 0 {
|
||||
Phase::Attention
|
||||
} else if !online {
|
||||
Phase::Offline
|
||||
} else if busy {
|
||||
Phase::Syncing
|
||||
} else {
|
||||
Phase::Synced
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 issues — View": the menu item that opens the Sync page's queue.
|
||||
fn issues_label(failing: u64) -> String {
|
||||
// Translators: a menu item that opens the list of failing changes.
|
||||
ngettext_f("{n} issue — View", "{n} issues — View", failing, &[])
|
||||
}
|
||||
|
||||
fn open_folder(mountpoint: &Path) {
|
||||
pdfs_core::opener::open_default(mountpoint, true);
|
||||
}
|
||||
|
||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
||||
/// window. The app binary lives next to the tray binary; fall back to the bare
|
||||
/// Launch (or, since it's `SingleMainWindow`, raise) the desktop app with
|
||||
/// `args`. The app binary lives next to the tray binary; fall back to the bare
|
||||
/// name so a PATH install still works.
|
||||
fn open_manager() {
|
||||
fn open_manager(args: &[&str]) {
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
||||
.filter(|p| p.exists())
|
||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
|
||||
if let Err(e) = Command::new(&exe).spawn() {
|
||||
if let Err(e) = Command::new(&exe).args(args).spawn() {
|
||||
tracing::error!("failed to launch {}: {e}", exe.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember that the user hid the icon, so the next login's autostart and the
|
||||
/// app both leave it away, then exit.
|
||||
fn hide_tray() {
|
||||
if let Ok(dirs) = AppDirs::new() {
|
||||
let mut config = dirs.load_config();
|
||||
config.tray_hidden = true;
|
||||
if let Err(e) = dirs.save_config(&config) {
|
||||
tracing::warn!("hide tray icon: {e}");
|
||||
}
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
impl Tray for DriveTray {
|
||||
fn id(&self) -> String {
|
||||
"io.narl.proton-drive-linux".into()
|
||||
@@ -162,91 +306,125 @@ impl Tray for DriveTray {
|
||||
}
|
||||
|
||||
fn icon_name(&self) -> String {
|
||||
"folder-remote".into()
|
||||
self.state.phase.icon().into()
|
||||
}
|
||||
|
||||
fn status(&self) -> Status {
|
||||
match self.state.phase {
|
||||
Phase::Attention => Status::NeedsAttention,
|
||||
_ => Status::Active,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ToolTip {
|
||||
let mut description = self.state.line.clone();
|
||||
if !self.state.sync.is_empty() {
|
||||
description = format!("{description}\n{}", self.state.sync);
|
||||
}
|
||||
ToolTip {
|
||||
title: "Proton Drive".into(),
|
||||
description,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A left click opens the app, as it does for most tray icons.
|
||||
fn activate(&mut self, _x: i32, _y: i32) {
|
||||
open_manager(&[]);
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||
// Value captured into the action closure (which only gets `&mut Self`).
|
||||
let open_mp = self.state.mountpoint.clone();
|
||||
|
||||
let mut items: Vec<MenuItem<Self>> = vec![
|
||||
let label = |text: &str| -> MenuItem<Self> {
|
||||
StandardItem {
|
||||
label: self.state.line.clone(),
|
||||
label: text.to_string(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
];
|
||||
|
||||
// Live sync status, shown only while something is transferring.
|
||||
if !self.state.sync.is_empty() {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: self.state.sync.clone(),
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
items.extend([
|
||||
MenuItem::Separator,
|
||||
.into()
|
||||
};
|
||||
let item = |text: &str, run: fn(&mut Self)| -> MenuItem<Self> {
|
||||
StandardItem {
|
||||
label: "Open Manager".into(),
|
||||
activate: Box::new(|_: &mut Self| open_manager()),
|
||||
label: text.to_string(),
|
||||
activate: Box::new(run),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]);
|
||||
.into()
|
||||
};
|
||||
|
||||
let mut items: Vec<MenuItem<Self>> = vec![label(&self.state.line)];
|
||||
// Live sync status, shown only while something is transferring.
|
||||
if !self.state.sync.is_empty() {
|
||||
items.push(label(&self.state.sync));
|
||||
}
|
||||
if self.state.failing > 0 {
|
||||
items.push(item(&issues_label(self.state.failing), |_| {
|
||||
open_manager(&["--page", "locations"])
|
||||
}));
|
||||
}
|
||||
items.push(MenuItem::Separator);
|
||||
|
||||
match self.state.phase {
|
||||
// Nothing to mount without a session; the app's sign-in page is the
|
||||
// way in.
|
||||
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
|
||||
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
|
||||
}
|
||||
|
||||
if self.state.mounted {
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Open folder".into(),
|
||||
label: gettext("Open Folder"),
|
||||
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
||||
// login or reboot brings it back; this is a deliberate disconnect.
|
||||
// Pause holds uploads back but leaves the mount readable, so it
|
||||
// sits apart from Stop, which takes the whole mount away.
|
||||
let paused = self.state.paused;
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Disconnect".into(),
|
||||
label: if paused {
|
||||
gettext("Resume Sync")
|
||||
} else {
|
||||
gettext("Pause Sync")
|
||||
},
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
service::stop();
|
||||
this.state.mounted = false;
|
||||
this.state.line = "Disconnecting…".into();
|
||||
let request = Request::SetSyncPaused {
|
||||
paused: !paused,
|
||||
until: None,
|
||||
};
|
||||
match send(&this.socket, &request) {
|
||||
Ok(Response::Ok { .. }) => this.state.paused = !paused,
|
||||
Ok(other) => tracing::warn!("pause sync: {other:?}"),
|
||||
Err(e) => tracing::warn!("pause sync: {e}"),
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
} else if self.state.phase == Phase::Disconnected {
|
||||
// Enable+start the service so it mounts now and on future logins.
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Connect".into(),
|
||||
activate: Box::new(|this: &mut Self| {
|
||||
service::enable_start();
|
||||
this.state.line = "Connecting…".into();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
items.push(item(&gettext("Connect"), |this| {
|
||||
service::enable_start();
|
||||
this.state.line = gettext("Connecting…");
|
||||
}));
|
||||
}
|
||||
|
||||
items.push(MenuItem::Separator);
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: "Quit".into(),
|
||||
activate: Box::new(|_: &mut Self| std::process::exit(0)),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
items.push(item(&gettext("Hide Tray Icon"), |_| hide_tray()));
|
||||
if self.state.mounted {
|
||||
// Stopping unmounts the drive, so the app asks first; the tray has no
|
||||
// GTK of its own to ask with.
|
||||
items.push(item(&gettext("Stop Proton Drive…"), |_| {
|
||||
open_manager(&["--confirm-stop"])
|
||||
}));
|
||||
} else {
|
||||
items.push(item(&gettext("Quit"), |_| std::process::exit(0)));
|
||||
}
|
||||
items
|
||||
}
|
||||
}
|
||||
@@ -257,8 +435,15 @@ fn main() -> Result<()> {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
i18n::init();
|
||||
|
||||
let dirs = AppDirs::new().context("resolve app dirs")?;
|
||||
// Hidden from its own menu: the session's autostart still runs the tray, and
|
||||
// this is where that choice is kept.
|
||||
if dirs.load_config().tray_hidden {
|
||||
tracing::info!("tray icon is hidden in the config; exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ensure only one instance of the tray runs.
|
||||
let tray_sock = dirs.tray_socket();
|
||||
@@ -267,7 +452,7 @@ fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&tray_sock);
|
||||
let _lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||
let lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
|
||||
.context("failed to bind tray single-instance socket")?;
|
||||
// Owner-only like the control socket (B6). This one only guards single
|
||||
// instancing, so a failure is logged rather than fatal — but there is no
|
||||
@@ -276,11 +461,25 @@ fn main() -> Result<()> {
|
||||
tracing::warn!(error = %e, "could not restrict tray socket permissions");
|
||||
}
|
||||
|
||||
// The app asks the tray to leave through its socket, when the user hides
|
||||
// the icon from Preferences or stops Proton Drive.
|
||||
std::thread::spawn(move || {
|
||||
for stream in lock_socket.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
if BufReader::new(stream).read_line(&mut line).is_ok()
|
||||
&& line.trim() == pdfs_core::tray::QUIT
|
||||
{
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let socket = dirs.control_socket();
|
||||
let default_mountpoint = dirs.default_mountpoint();
|
||||
|
||||
let tray = DriveTray {
|
||||
state: poll_state(&socket, &default_mountpoint),
|
||||
socket: socket.clone(),
|
||||
};
|
||||
|
||||
// The tray autostarts with the session and can beat the desktop's SNI
|
||||
@@ -299,3 +498,23 @@ fn main() -> Result<()> {
|
||||
handle.update(move |t: &mut DriveTray| t.state = st);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pause_outranks_failures_and_failures_outrank_the_rest() {
|
||||
assert_eq!(phase_of(true, 3, false, true), Phase::Paused);
|
||||
assert_eq!(phase_of(false, 3, false, true), Phase::Attention);
|
||||
assert_eq!(phase_of(false, 0, false, true), Phase::Offline);
|
||||
assert_eq!(phase_of(false, 0, true, true), Phase::Syncing);
|
||||
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_issues_item_counts_in_the_singular_and_plural() {
|
||||
assert_eq!(issues_label(1), "1 issue — View");
|
||||
assert_eq!(issues_label(4), "4 issues — View");
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ use pdfs_core::menu::PromptMode;
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
mod activation;
|
||||
mod i18n;
|
||||
use i18n::{gettext, gettext_f, gettext_noop, ngettext_f, pgettext};
|
||||
mod dmenu;
|
||||
mod fzf;
|
||||
mod query;
|
||||
@@ -51,11 +53,13 @@ enum Filter {
|
||||
|
||||
/// The chip row, in Tab-cycle order.
|
||||
const FILTERS: [(Filter, &str); 5] = [
|
||||
(Filter::All, "All"),
|
||||
(Filter::Folders, "Folders"),
|
||||
(Filter::Documents, "Documents"),
|
||||
(Filter::Images, "Images"),
|
||||
(Filter::Media, "Media"),
|
||||
// Translators: a filter chip that shows every kind of result.
|
||||
(Filter::All, gettext_noop("All")),
|
||||
(Filter::Folders, gettext_noop("Folders")),
|
||||
(Filter::Documents, gettext_noop("Documents")),
|
||||
(Filter::Images, gettext_noop("Images")),
|
||||
// Translators: a filter chip for audio and video files.
|
||||
(Filter::Media, gettext_noop("Media")),
|
||||
];
|
||||
|
||||
impl Filter {
|
||||
@@ -162,9 +166,13 @@ impl Hit {
|
||||
None => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
gettext("My files")
|
||||
} else {
|
||||
format!("My files / {}", parent.replace('/', " / "))
|
||||
// Translators: {path} is a folder path inside the user's Drive, such as "Work / Taxes".
|
||||
gettext_f(
|
||||
"My files / {path}",
|
||||
&[("path", &parent.replace('/', " / "))],
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -425,6 +433,7 @@ fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
i18n::init();
|
||||
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if raw.iter().any(|arg| arg == "-h" || arg == "--help") {
|
||||
@@ -506,7 +515,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Search Proton Drive")
|
||||
.title(gettext("Search Proton Drive"))
|
||||
.default_width(720)
|
||||
.default_height(540)
|
||||
.resizable(false)
|
||||
@@ -527,7 +536,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
search_bar.append(&search_icon);
|
||||
|
||||
let entry = gtk4::Entry::builder()
|
||||
.placeholder_text("Search in Drive and on this computer")
|
||||
.placeholder_text(gettext("Search in Drive and on this computer"))
|
||||
.hexpand(true)
|
||||
.build();
|
||||
entry.add_css_class("search-entry");
|
||||
@@ -537,7 +546,8 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
spinner.set_visible(false);
|
||||
search_bar.append(&spinner);
|
||||
|
||||
let esc = gtk4::Label::new(Some("Esc"));
|
||||
// Translators: the Escape key, shown as a hint that it closes the window.
|
||||
let esc = gtk4::Label::new(Some(&gettext("Esc")));
|
||||
esc.add_css_class("key-hint");
|
||||
search_bar.append(&esc);
|
||||
card.append(&search_bar);
|
||||
@@ -551,9 +561,9 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
results.add_css_class("results");
|
||||
|
||||
let search_section = Section::new("Best matches", "system-search-symbolic");
|
||||
let search_section = Section::new(&gettext("Best matches"), "system-search-symbolic");
|
||||
let drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
|
||||
let local_section = Section::new("This computer", "drive-harddisk-symbolic");
|
||||
let local_section = Section::new(&gettext("This computer"), "drive-harddisk-symbolic");
|
||||
search_section.attach(&results);
|
||||
drive_section.attach(&results);
|
||||
local_section.attach(&results);
|
||||
@@ -567,7 +577,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
|
||||
let placeholder = adw::StatusPage::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.title("No results")
|
||||
.title(gettext("No results"))
|
||||
.build();
|
||||
placeholder.add_css_class("compact");
|
||||
|
||||
@@ -575,12 +585,12 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
// is still usable for nothing else, so it owns the whole view.
|
||||
let offline = adw::StatusPage::builder()
|
||||
.icon_name("network-offline-symbolic")
|
||||
.title("Proton Drive is not running")
|
||||
.description("Start the mount daemon to search your Drive.")
|
||||
.title(gettext("Proton Drive is not running"))
|
||||
.description(gettext("Start the mount daemon to search your Drive."))
|
||||
.build();
|
||||
offline.add_css_class("compact");
|
||||
let retry = gtk4::Button::builder()
|
||||
.label("Retry")
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("pill");
|
||||
@@ -601,14 +611,15 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
footer.add_css_class("footer");
|
||||
let hint = gtk4::Label::builder()
|
||||
.label("Connecting…")
|
||||
.label(gettext("Connecting…"))
|
||||
.xalign(0.0)
|
||||
.hexpand(true)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
hint.add_css_class("footer-text");
|
||||
footer.append(&hint);
|
||||
let keys = gtk4::Label::new(Some("↑↓ navigate · ↵ open · Tab filter"));
|
||||
// Translators: keyboard hints in the footer; keep the arrow and Enter symbols.
|
||||
let keys = gtk4::Label::new(Some(&gettext("↑↓ navigate · ↵ open · Tab filter")));
|
||||
keys.add_css_class("footer-text");
|
||||
footer.append(&keys);
|
||||
card.append(&footer);
|
||||
@@ -646,7 +657,7 @@ fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
||||
|
||||
for (filter, label) in FILTERS {
|
||||
let chip = gtk4::ToggleButton::builder()
|
||||
.label(label)
|
||||
.label(gettext(label))
|
||||
.active(filter == Filter::All)
|
||||
.build();
|
||||
chip.add_css_class("chip");
|
||||
@@ -852,7 +863,7 @@ impl Ui {
|
||||
/// Ask the daemon for status and, if it answers, load the suggested (pinned)
|
||||
/// files. Runs off-thread: a dead daemon must not freeze the first frame.
|
||||
fn connect(self: &Rc<Self>) {
|
||||
self.hint.set_label("Connecting…");
|
||||
self.hint.set_label(&gettext("Connecting…"));
|
||||
let rx = spawn_request(self.socket.clone(), Request::Status);
|
||||
let ui = self.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
@@ -865,7 +876,7 @@ impl Ui {
|
||||
}
|
||||
_ => {
|
||||
ui.entry.set_sensitive(false);
|
||||
ui.hint.set_label("Daemon offline");
|
||||
ui.hint.set_label(&gettext("Daemon offline"));
|
||||
ui.stack.set_visible_child_name("offline");
|
||||
}
|
||||
}
|
||||
@@ -879,7 +890,7 @@ impl Ui {
|
||||
self.spinner.stop();
|
||||
self.spinner.set_visible(false);
|
||||
self.entry.set_sensitive(false);
|
||||
self.hint.set_label("Daemon offline");
|
||||
self.hint.set_label(&gettext("Daemon offline"));
|
||||
self.stack.set_visible_child_name("offline");
|
||||
}
|
||||
|
||||
@@ -908,6 +919,7 @@ impl Ui {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
@@ -1071,7 +1083,8 @@ impl Ui {
|
||||
visible.extend(local);
|
||||
if searching {
|
||||
rank_hits(&mut visible);
|
||||
self.search_section.set_rows(&visible, Some("Best matches"));
|
||||
self.search_section
|
||||
.set_rows(&visible, Some(&gettext("Best matches")));
|
||||
self.drive_section.set_rows(&[], None);
|
||||
self.local_section.set_rows(&[], None);
|
||||
} else {
|
||||
@@ -1080,8 +1093,10 @@ impl Ui {
|
||||
.iter()
|
||||
.take_while(|hit| matches!(hit, Hit::Drive(_)))
|
||||
.count();
|
||||
self.drive_section
|
||||
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive"));
|
||||
self.drive_section.set_rows(
|
||||
&visible[..drive_count],
|
||||
Some(&gettext("Pinned in Proton Drive")),
|
||||
);
|
||||
self.local_section.set_rows(&visible[drive_count..], None);
|
||||
}
|
||||
|
||||
@@ -1092,18 +1107,20 @@ impl Ui {
|
||||
*self.rendered_query.borrow_mut() = query;
|
||||
|
||||
if total == 0 {
|
||||
self.placeholder.set_title(if searching {
|
||||
"No results"
|
||||
self.placeholder.set_title(&if searching {
|
||||
gettext("No results")
|
||||
} else {
|
||||
"Search your Drive"
|
||||
gettext("Search your Drive")
|
||||
});
|
||||
self.placeholder
|
||||
.set_description(Some(match (searching, self.indexing.get()) {
|
||||
(true, true) => {
|
||||
"Still indexing this computer — local results will fill in shortly."
|
||||
}
|
||||
(true, false) => "Try a different search, or another filter.",
|
||||
_ => "Start typing to search Proton Drive and the files on this computer.",
|
||||
.set_description(Some(&match (searching, self.indexing.get()) {
|
||||
(true, true) => gettext(
|
||||
"Still indexing this computer — local results will fill in shortly.",
|
||||
),
|
||||
(true, false) => gettext("Try a different search, or another filter."),
|
||||
_ => gettext(
|
||||
"Start typing to search Proton Drive and the files on this computer.",
|
||||
),
|
||||
}));
|
||||
self.stack.set_visible_child_name("empty");
|
||||
self.cursor.set(None);
|
||||
@@ -1128,18 +1145,23 @@ impl Ui {
|
||||
fn status_text(&self, total: usize, searching: bool) -> String {
|
||||
if !searching {
|
||||
return match total {
|
||||
0 => "No pinned files".to_string(),
|
||||
1 => "1 pinned file".to_string(),
|
||||
n => format!("{n} pinned files"),
|
||||
0 => gettext("No pinned files"),
|
||||
n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]),
|
||||
};
|
||||
}
|
||||
let drive = self.drive_count();
|
||||
let local = total - drive;
|
||||
let mut text = format!("{drive} in Drive · {local} on this computer");
|
||||
let drive = self.drive_count().to_string();
|
||||
let local = (total - self.drive_count()).to_string();
|
||||
let args = [("drive", drive.as_str()), ("local", local.as_str())];
|
||||
if self.indexing.get() {
|
||||
text.push_str(" · indexing…");
|
||||
// Translators: result counts in the footer while this computer is still being indexed; {drive} and {local} are numbers.
|
||||
gettext_f(
|
||||
"{drive} in Drive · {local} on this computer · indexing…",
|
||||
&args,
|
||||
)
|
||||
} else {
|
||||
// Translators: result counts in the footer; {drive} and {local} are numbers.
|
||||
gettext_f("{drive} in Drive · {local} on this computer", &args)
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Move the cursor by `delta` rows, wrapping at both ends.
|
||||
@@ -1271,7 +1293,9 @@ impl Ui {
|
||||
self.opening.set(true);
|
||||
self.spinner.set_visible(true);
|
||||
self.spinner.start();
|
||||
self.hint.set_label(&format!("Opening {}…", drive.name));
|
||||
// Translators: {name} is a file name.
|
||||
let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]);
|
||||
self.hint.set_label(&opening);
|
||||
|
||||
let rx = spawn_request(
|
||||
self.socket.clone(),
|
||||
@@ -1292,9 +1316,13 @@ impl Ui {
|
||||
ui.dismiss();
|
||||
}
|
||||
Ok(Ok(Response::Error { message, .. })) => {
|
||||
ui.hint.set_label(&format!("Could not open: {message}"));
|
||||
// Translators: {message} is an error from the daemon, in English.
|
||||
ui.hint.set_label(&gettext_f(
|
||||
"Could not open: {message}",
|
||||
&[("message", &message)],
|
||||
));
|
||||
}
|
||||
_ => ui.hint.set_label("Could not reach the daemon"),
|
||||
_ => ui.hint.set_label(&gettext("Could not reach the daemon")),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1402,8 +1430,10 @@ fn home_relative(parent: &str) -> String {
|
||||
.ok()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
// Translators: the user's home folder, as the location of a result.
|
||||
Some(rel) if rel.is_empty() => gettext("Home"),
|
||||
// Translators: {path} is a folder path inside the home folder, folder names separated by " / ".
|
||||
Some(rel) => gettext_f("Home / {path}", &[("path", &rel.replace('/', " / "))]),
|
||||
None => parent.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1417,16 +1447,21 @@ fn parent_of(path: &str) -> String {
|
||||
|
||||
fn format_size(bytes: u64) -> String {
|
||||
const UNITS: [(u64, &str); 3] = [
|
||||
(1024 * 1024 * 1024, "GB"),
|
||||
(1024 * 1024, "MB"),
|
||||
(1024, "KB"),
|
||||
// Translators: a file size in gigabytes; {size} is a number such as "4.2".
|
||||
(1024 * 1024 * 1024, gettext_noop("{size} GB")),
|
||||
// Translators: a file size in megabytes; {size} is a number such as "4.2".
|
||||
(1024 * 1024, gettext_noop("{size} MB")),
|
||||
// Translators: a file size in kilobytes; {size} is a number such as "4.2".
|
||||
(1024, gettext_noop("{size} KB")),
|
||||
];
|
||||
for (scale, unit) in UNITS {
|
||||
if bytes >= scale {
|
||||
return format!("{:.1} {unit}", bytes as f64 / scale as f64);
|
||||
let size = format!("{:.1}", bytes as f64 / scale as f64);
|
||||
return gettext_f(unit, &[("size", &size)]);
|
||||
}
|
||||
}
|
||||
format!("{bytes} B")
|
||||
// Translators: a file size in bytes; {size} is a whole number.
|
||||
gettext_f("{size} B", &[("size", &bytes.to_string())])
|
||||
}
|
||||
|
||||
/// Coarse relative age, in the granularity a launcher row has room for.
|
||||
@@ -1436,12 +1471,18 @@ fn format_age(epoch_secs: i64) -> String {
|
||||
.map_or(0, |d| d.as_secs() as i64);
|
||||
let diff = now - epoch_secs;
|
||||
match diff {
|
||||
d if d < 60 => "now".to_string(),
|
||||
d if d < 3600 => format!("{}m", d / 60),
|
||||
d if d < 86_400 => format!("{}h", d / 3600),
|
||||
d if d < 2_592_000 => format!("{}d", d / 86_400),
|
||||
d if d < 31_536_000 => format!("{}mo", d / 2_592_000),
|
||||
d => format!("{}y", d / 31_536_000),
|
||||
// Translators: the age of a file modified under a minute ago.
|
||||
d if d < 60 => pgettext("age", "now"),
|
||||
// Translators: a compact file age; {count} is a number of minutes.
|
||||
d if d < 3600 => gettext_f("{count}m", &[("count", &(d / 60).to_string())]),
|
||||
// Translators: a compact file age; {count} is a number of hours.
|
||||
d if d < 86_400 => gettext_f("{count}h", &[("count", &(d / 3600).to_string())]),
|
||||
// Translators: a compact file age; {count} is a number of days.
|
||||
d if d < 2_592_000 => gettext_f("{count}d", &[("count", &(d / 86_400).to_string())]),
|
||||
// Translators: a compact file age; {count} is a number of months.
|
||||
d if d < 31_536_000 => gettext_f("{count}mo", &[("count", &(d / 2_592_000).to_string())]),
|
||||
// Translators: a compact file age; {count} is a number of years.
|
||||
d => gettext_f("{count}y", &[("count", &(d / 31_536_000).to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1613,6 +1654,7 @@ mod tests {
|
||||
size: 1,
|
||||
modified: 1,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "v~a".into(),
|
||||
mounted_path: None,
|
||||
score: 100,
|
||||
|
||||
@@ -17,7 +17,7 @@ use pdfs_core::control::{
|
||||
use pdfs_core::opener::{self, OpenWith};
|
||||
|
||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
||||
use crate::{Hit, file_name, gettext_f, is_document, is_image, is_media, rank_hits};
|
||||
|
||||
/// Where the mount lives, for turning a Drive path into something an
|
||||
/// application can open. Falls back to the configured default so a momentarily
|
||||
@@ -45,6 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
@@ -163,7 +164,13 @@ pub(crate) fn open(
|
||||
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
||||
/// terminal rather than a status page.
|
||||
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
||||
send(socket, &request).map_err(|e| {
|
||||
// Translators: shown as a row in the search list; {error} is a system error, in English.
|
||||
gettext_f(
|
||||
"cannot reach the Proton Drive daemon: {error}",
|
||||
&[("error", &e.to_string())],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -178,6 +185,7 @@ mod tests {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: false,
|
||||
cached: false,
|
||||
uid: "uid".into(),
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
@@ -12,6 +12,140 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B98 — A superseded upload spins its drain thread forever and shows as "Uploading" at 0 bytes
|
||||
|
||||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a
|
||||
superseded upload yet.
|
||||
**Found:** 2026-09-23. The Sync page showed `Unconfirmed 654050.crdownload — 0 bytes of 23.8 MiB`
|
||||
for hours after Brave had finished and renamed the download. `pdfs transfers` listed the same
|
||||
entry, `pdfs diagnostics` showed every worker idle, and `/proc/<pid>/task` showed `pdfs-drain-1`
|
||||
in state `R` at 100% CPU, holding a deleted staging blob open.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/transfers.rs` (`CountingReader::read`).
|
||||
|
||||
**Cause.** A write that lands while a revision is on the wire cancels that upload through the
|
||||
flag `CountingReader` checks before every read. The reader reported the cancel as
|
||||
`ErrorKind::Interrupted`. Every read loop treats that kind as "try again", including the SDK's
|
||||
`read_full_block`, and the flag never clears, so the loop never ended. The `TransferGuard` was
|
||||
never dropped, so the transfer stayed in the registry, and the drain thread was lost until the
|
||||
daemon restarted. A browser download is the natural trigger: the `.crdownload` file keeps growing,
|
||||
so each new write supersedes the upload of the previous one.
|
||||
|
||||
**Fix.** The cancel is reported as `ErrorKind::Other`, which the SDK turns into an error.
|
||||
`drain_revision` already recognises a cancelled upload and returns without counting a failure.
|
||||
Regression test: `a_cancelled_upload_fails_its_read_instead_of_asking_for_a_retry`.
|
||||
|
||||
---
|
||||
|
||||
## B97 — Photo refreshes stack up on the control socket, and shutdown turns the resolve pass into a burst of "undecryptable photo" warnings
|
||||
|
||||
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
|
||||
library yet.
|
||||
**Found:** 2026-09-19, while reading the journal for issues that were still open. `pdfs diagnostics`
|
||||
had reported a `Refresh` running for 23 minutes with `in_flight` at 5, and every mount that ended
|
||||
in a `systemctl --user restart` was followed by ~95 `skipping undecryptable photo` warnings.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/control.rs` (`Refresh` handler), `crates/pdfs-fuse/src/photos.rs`
|
||||
(`refresh_timeline`), `crates/pdfs-fuse/src/diagnostics.rs` (`request_kind`).
|
||||
|
||||
**Cause.** Two unrelated faults with one symptom each.
|
||||
|
||||
The stall: every `Refresh` arm is individually cheap — a directory refresh resolves and
|
||||
invalidates one listing, a photos refresh clears one state key and spawns the real work — but
|
||||
nothing stopped several of them running at once. A front end that asks again while the first
|
||||
refresh is still going gets a second handler that does the whole thing again: `invalidate_photos`,
|
||||
optionally `photos_mark_all_unresolved` over the entire timeline, `invalidate_albums`. Each of
|
||||
those takes the database lock the already-running refresh holds for minutes at a time, so the
|
||||
duplicates queue behind it, hold a control handler slot, and appear as a wedged `Refresh`. The
|
||||
report could not even say which view was wedged: `request_kind` reads only serde's external tag,
|
||||
so all three scopes logged as a bare `Refresh`.
|
||||
|
||||
The warning burst: `refresh_timeline` resolves photo metadata in chunks and keeps going through
|
||||
teardown. The runtime is cancelled under it, every decrypt task in flight fails with "task was
|
||||
cancelled", and the SDK logs one `skipping undecryptable photo` per node. Nothing was corrupt —
|
||||
the library was fine on the next mount, because `photos_replace` preserves prior metadata and
|
||||
leaves `resolved_at` null so the next refresh re-reads exactly those photos.
|
||||
|
||||
**What changed.** A refresh scope is now single-flight: `RefreshGuard` holds the scope key
|
||||
(`dir:<path>`, `trash`, `photos`, `photos:full`) for as long as one is being served, and a second
|
||||
request for that same key is answered `refreshing` immediately instead of repeating the work. A
|
||||
non-full photos refresh is additionally skipped while `timeline_refreshing()` is true, since that
|
||||
work outlives the request that started it. `request_kind` now names the scope, so the diagnostics
|
||||
report says `Refresh(Photos)` rather than `Refresh`. And the resolve loop stops asking for chunks
|
||||
once `Shutdown::is_stopping()`, logging `stopped` and the resolved-versus-wanted counts on the way
|
||||
out.
|
||||
|
||||
---
|
||||
|
||||
## B96 — Staged bytes that name no node are never retired, and are re-announced on every boot
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-19, in the journal: the same "staged bytes belong to no queued upload" warning
|
||||
appeared once per blob per mount, forever. The staging directory held 138 blobs / 575 MiB, of
|
||||
which 95 (~445 MiB) referenced no node at all; the oldest were two months old.
|
||||
|
||||
**Where:** `crates/pdfs-fuse/src/lib.rs` (`reconcile_staging`).
|
||||
|
||||
**Cause.** Startup reconciliation is deliberately conservative: staged bytes are the only copy of
|
||||
an un-uploaded write, so a blob it cannot match to a queued op is kept rather than deleted. But
|
||||
"kept" had no end. A blob whose node is gone from the database can never be matched by any future
|
||||
boot, so the warning was both permanent and per-blob, and the bytes accumulated with no ceiling.
|
||||
|
||||
**What changed.** A blob that names no node and whose mtime is older than `STAGING_ORPHAN_RETAIN`
|
||||
(30 days) is retired, with its size logged. A month is long enough that a write stranded by a bug
|
||||
can still be recovered by hand. Younger orphans are still kept, but they are now counted and
|
||||
reported as one aggregated warning with one example path, not one line each.
|
||||
|
||||
---
|
||||
|
||||
## B95 — A real `index.tmp` is treated as a browser tempfile and parked forever
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-19, while working out why 43 uploads were parked. Most of them were
|
||||
`index.tmp` and `preview.tmp` files from a Google Photos Takeout import — ordinary files whose
|
||||
final name simply ends in `.tmp`.
|
||||
|
||||
**Where:** `crates/pdfs-core/src/syncignore.rs` (`TRANSIENT_SUFFIXES`).
|
||||
|
||||
**Cause.** One suffix list served two mechanisms with very different consequences. On a mirror
|
||||
mount, calling a name transient means "do not reconcile it yet", which is reversible and costs
|
||||
nothing if wrong. On an on-demand mount it means "park the create until the writer renames it"
|
||||
(B70) — and a file whose real, final name ends in `.tmp` is never renamed, so the park stands
|
||||
forever and the bytes never go up.
|
||||
|
||||
**What changed.** `.tmp` and `.temp` are out of `TRANSIENT_SUFFIXES`. The remaining suffixes
|
||||
(`.crdownload`, `.part`, `.partial`, `.download`, `.swp`, `.swx`) belong to writers that always
|
||||
rename. A `.tmp` write from a program that does rename is still handled — by the rename itself,
|
||||
which supersedes the create.
|
||||
|
||||
---
|
||||
|
||||
## B94 — A parked create has no exit but the rename, so an abandoned one is stuck forever
|
||||
|
||||
**Status:** Fixed (unverified).
|
||||
**Found:** 2026-09-19, while reading the journal: 43 rows in `pending_op` carried the
|
||||
`PARK_UNTIL` sentinel, some for weeks. `pdfs status` reported them as parked uploads that never
|
||||
moved.
|
||||
|
||||
**Where:** `crates/pdfs-core/src/db/ops.rs` (`parked_create_ops`, `PARK_EXPIRY_MS`),
|
||||
`crates/pdfs-fuse/src/drain.rs` (`sweep_parked_creates`).
|
||||
|
||||
**Cause.** B70 parks a transient create by writing `PARK_UNTIL` into `next_attempt_at`, and the
|
||||
only thing that clears it is the transient-to-final rename (`Db::set_create_hold`). If the rename
|
||||
never happens — the writer crashed, the user deleted the temp file, or the name was never
|
||||
transient in the first place (B95) — the row is invisible to `claim_next_due_op` and stays in the
|
||||
queue permanently, holding its staged blob.
|
||||
|
||||
**What changed.** The primary drain worker sweeps parked creates when the queue is idle, which is
|
||||
exactly when a park that has outlived its writer matters. A parked create whose node is gone from
|
||||
the database has its queued ops discarded. One whose node still exists, that is older than
|
||||
`PARK_EXPIRY_MS` (1 hour) and that nobody has open, is un-parked rather than dropped — drain
|
||||
invariant 1 says staged bytes are only dropped once their op has landed, so the sweep lets the
|
||||
bytes through under the transient name instead of destroying them. An open node is left parked
|
||||
whatever its age: it is still being written.
|
||||
|
||||
---
|
||||
|
||||
## B93 — A photo whose node would not resolve lost its name, and with it its tab and its group
|
||||
|
||||
**Status:** Fixed (unverified) — found and fixed while making the timeline refresh incremental
|
||||
|
||||
@@ -9,9 +9,241 @@ release ships. Migrations are forward-only — a database written by a newer cli
|
||||
refuse-to-open, not a downgrade, so rolling back a release means restoring the cache from
|
||||
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.0] — 2026-09-23
|
||||
|
||||
Schema: **32**. Videos that earlier got no thumbnail are tried again.
|
||||
|
||||
### Added
|
||||
- **Video thumbnails.** Videos without a thumbnail from Drive now show a frame from the clip in
|
||||
Photos. This needs `ffmpeg`, a new optional dependency.
|
||||
- **Uploads carry a thumbnail.** Photos and videos uploaded from this client now include one, so
|
||||
they show a preview in Proton's own apps as well, without downloading the whole file.
|
||||
|
||||
### Fixed
|
||||
- **The Photos gallery no longer jumps** while thumbnails load, when zooming, or when switching
|
||||
tabs and back.
|
||||
- **The month scrubber no longer collides with the scrollbar.**
|
||||
- **The photo details panel is no longer empty.**
|
||||
- **Photo details read like a camera's:** "Google Pixel 6" instead of quoted EXIF values, shutter
|
||||
speeds such as 1/19 s, and a labelled ISO.
|
||||
|
||||
## [2.1.1] — 2026-09-23
|
||||
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Uploads no longer get stuck at 0 bytes.** When a file changed again while its previous
|
||||
version was uploading, such as a browser download that is still growing, the old upload could
|
||||
hang forever. It kept one background thread busy at full CPU and showed as "Uploading" in the
|
||||
Transfers list until the daemon restarted.
|
||||
|
||||
## [2.1.0] — 2026-09-23
|
||||
|
||||
The desktop app, the tray and the search prompt speak German, British English, Spanish, French,
|
||||
Italian, Dutch, Polish and Brazilian Portuguese, picked from the system language or chosen in
|
||||
Preferences.
|
||||
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Translations.** The desktop app, the tray and the search prompt are translated into German,
|
||||
British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese, and follow the
|
||||
system language. Counts use each language's plural rules, and dates use its own formats. The
|
||||
desktop files carry translated descriptions and search keywords. The `pdfs` CLI and messages
|
||||
from the daemon stay in English. [docs/TRANSLATING.md](TRANSLATING.md) describes how to improve a
|
||||
translation or add a language. A few labels on the Photos page (the filter tabs, the gallery
|
||||
subtitle and the heading shown when a filtered timeline is empty) are still in English.
|
||||
- **Language preference.** Preferences → General → Appearance → Language overrides the system
|
||||
language for the app, the tray and the prompt, from their next start.
|
||||
|
||||
### Changed
|
||||
- **Packages install translations.** The Arch, AUR, Debian and Fedora packages install the compiled
|
||||
catalogs under `/usr/share/locale`, and the release tarball carries them in `locale/`. Building
|
||||
the source packages now needs `gettext`.
|
||||
|
||||
## [2.0.0] — 2026-09-23
|
||||
|
||||
A redesign of the desktop app: a new shell with a Preferences dialog, keyboard-driven context
|
||||
menus, a docked details pane, a rewritten photo viewer with infinite scroll, album management,
|
||||
stop sharing and link expiry, a redesigned Shared with Me page, per-device restore, a tray that
|
||||
shows the sync state, and a new app icon.
|
||||
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Added
|
||||
- **Pause and resume sync.** The Sync page, the tray and `pdfs sync pause [--for 1h]` /
|
||||
`pdfs sync resume` hold back queued uploads and mirror-folder reconciles. Reads through the mount
|
||||
keep working, writes keep being accepted and staged, and a timed pause ends by itself. The pause
|
||||
survives a restart.
|
||||
- **See what is waiting to upload.** The Sync page lists the queue with each item's last error and
|
||||
next retry, with Retry now per item and Retry All; `pdfs sync queue` and `pdfs sync retry [id]`
|
||||
do the same from the CLI. Nothing in the queue is ever dropped from here.
|
||||
- **Resolve sync conflicts.** `(sync-conflict …)` copies under My Files are listed on the Sync page
|
||||
next to the file they conflict with, with sizes, times and whether the content differs. Keep the
|
||||
original, keep the copy or keep both under a new name; whatever is not kept goes to Trash.
|
||||
`pdfs conflicts` and `pdfs conflicts resolve <path> --keep original|copy|both` do the same from
|
||||
the CLI. Files that are open or still uploading are refused until they settle.
|
||||
- **Pause one folder.** A mirrored folder on the Sync page has its own pause button; while paused
|
||||
it is not reconciled in either direction and its mode cannot be switched, and resuming syncs it
|
||||
straight away. `pdfs sync pause --folder <id>` / `pdfs sync resume --folder <id>` do the same, and
|
||||
`pdfs sync list` marks paused folders. The pause is kept across restarts. On-demand folders
|
||||
upload through the shared queue, so only the global pause holds them back.
|
||||
- **Bandwidth limits.** Preferences → General → Network caps upload and download speed in MiB/s,
|
||||
shared by every transfer and applied to running ones from their next block; downloads include
|
||||
reads through the mount. `pdfs sync limit --up 2M --down 0` does the same, and `pdfs sync limit`
|
||||
on its own prints the current caps. The caps are kept in `config.json` across restarts.
|
||||
- **Tray states and menu.** The tray icon shows whether the drive is synced, syncing, paused,
|
||||
offline or needs attention. It asks the panel for attention while changes keep failing, and
|
||||
its tooltip gives the same line as the menu. A left click opens the app. The menu gains
|
||||
"N issues — View", which opens the Sync page, and "Sign In…" when signed out. "Hide Tray Icon"
|
||||
is remembered across logins (`tray_hidden` in `config.json`), and Preferences → Appearance
|
||||
→ Show tray icon brings the icon back. "Stop Proton Drive…" asks for confirmation in the app,
|
||||
then stops the service and the tray. `pdfs-app` accepts `--page <name>` and `--confirm-stop`.
|
||||
A second launch raises the running window instead of opening another one.
|
||||
- **Stop sharing.** Shared by Me → item menu → "Stop Sharing…" and `pdfs unshare <path>` (with no
|
||||
id) remove the public link, every invitation and every member in one go. Each step is tried even
|
||||
if an earlier one fails, and a partial result names what is still shared.
|
||||
- **Restore another computer's folders.** Every computer's menu on the Computers page has
|
||||
"Restore to This Computer…", which syncs that computer's backed-up folders down to paths you
|
||||
pick; they keep syncing with that computer's copy, and this machine keeps its own identity.
|
||||
This computer's own restore moved from the header into its row's menu. `pdfs sync restore
|
||||
--device <uid>` does the same from the CLI.
|
||||
- **Public link expiry.** The Share dialog's create-link form has an "Expires" choice (Never,
|
||||
1 day, 7 days, 30 days), and an existing link shows when it expires or that it has expired.
|
||||
|
||||
### Changed
|
||||
- **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync
|
||||
state, storage and the account; the Settings page became a Preferences dialog (Ctrl+,), with
|
||||
transfers on the Sync page and developer details in About. The app follows the system accent
|
||||
colour; the Proton purple accent is a preference.
|
||||
- **My Files header.** New folder and both uploads sit in one **New** menu; the view button switches
|
||||
grid and list, and its menu sorts by name, size or date, reversed or not, folders first or not.
|
||||
The choice is remembered. Build Thumbnails and Open in File Manager moved to the folder menu.
|
||||
- **Browsing history.** Back and forward buttons (Alt+Left/Right, mouse back/forward) replace the
|
||||
single Up button; Alt+Up goes to the parent folder. Back leaves a search first.
|
||||
- **Move to…** picks the destination from a folder browser instead of a typed path, and works on a
|
||||
whole selection. Rename selects the name without its extension.
|
||||
- **Drop to upload.** Files and folders dropped on My Files upload into the folder on screen. The
|
||||
upload toast links to the Sync page.
|
||||
- **Photos.** The main view is called Timeline. Right-clicking a photo opens a menu to open it,
|
||||
favourite it, select it or move it to Trash; Ctrl+A selects every loaded photo. An empty filter
|
||||
says what it filtered ("No videos in June 2024") instead of the first-run hint. A banner shows
|
||||
while a Takeout import runs and links to it. Pages load 200 photos at a time and keep loading
|
||||
until the window is full.
|
||||
- **Trash.** Select several items (click, Shift/Ctrl+click, Ctrl+A) to restore or permanently
|
||||
delete them together; the permanent delete still asks first, with the count.
|
||||
- **Shared by Me.** A row opens the item. Copy link stays one click away; Open, Show in My Files,
|
||||
Open Link and Manage Access… are in the row's menu.
|
||||
- **Computers.** Rename, Use This Computer's Identity and Remove are in one menu per computer.
|
||||
Removing a computer deletes its backups, so the dialog now asks for its name to be typed. The
|
||||
page says which computer this machine backs up as, and says "computer" throughout.
|
||||
- **Sign in.** Failures read as sentences — wrong password, too many attempts, no connection, no
|
||||
keyring, a skipped two-factor or verification step — instead of the raw error. The button spins
|
||||
while signing in, and the page links to account creation and password reset. The two-factor
|
||||
dialog mentions recovery codes.
|
||||
- **Real menus.** Right-click menus in My Files, Photos, Shared by Me and Computers are GTK menus:
|
||||
arrow keys, Enter and Escape work, and the Menu key or Shift+F10 opens the one for the selection
|
||||
in My Files. Items follow one order — Open, Open With…, Play · Available offline · Share…,
|
||||
Copy Link · Rename…, Move To…, Versions… · Move to Trash. Right-clicking empty space offers New
|
||||
Folder, the uploads, Select All, Refresh and Open in File Manager. Open With… asks which
|
||||
application to use.
|
||||
- **One selection in My Files.** The grid and the list share their selection, so switching views
|
||||
keeps what is selected, and dragging across empty space draws a selection box in both.
|
||||
- **Details pane.** On a window at least 1000 px wide the details pane docks beside the files
|
||||
instead of covering them. It has Info, Offline, Sharing and Versions sections: who has access or
|
||||
whether there is a public link, how many versions there are, and Share…, Copy Link and
|
||||
Versions… buttons.
|
||||
- **Search in this folder.** Inside a folder, a folder button beside the search box limits the search
|
||||
to that folder and everything below it. `pdfs search --in <folder>` does the same from the CLI.
|
||||
In the list view a Location column shows which folder each hit is in. Pinned files listed by the
|
||||
search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as
|
||||
not downloaded.
|
||||
- **Album management.** The Albums view has a New Album button, and an album's right-click menu
|
||||
offers Rename… and Delete Album…. Deleting an album keeps its photos in the timeline. A photo's
|
||||
menu and the selection bar offer Add to Album…, and inside an album, Remove from Album takes a
|
||||
photo out without trashing it. Albums shared with you only open. The CLI gains `pdfs new-album`,
|
||||
`rename-album`, `delete-album`, `add-to-album` and `remove-from-album`. This needs
|
||||
proton-drive-rs 0.6.6.
|
||||
- **Albums view fixes.** The timeline's filter row no longer shows over the album grid. The header
|
||||
counts albums. Back from an album returns to the grid where you left it instead of reloading it,
|
||||
and going from Albums back to Timeline keeps the timeline's scroll position unless an album was
|
||||
opened in between.
|
||||
- **Photo viewer rewrite.** The lightbox's controls fade out after two seconds without pointer
|
||||
movement. Photos zoom with Ctrl+scroll, a pinch, `+`/`-`, `1` for 100 % and double-click, and
|
||||
pan by dragging; `0` fits them again. The details panel docks beside the photo instead of
|
||||
covering it. Stepping past the last loaded photo loads the next page, and the counter shows the
|
||||
library's total rather than the loaded count. A video plays inline. Save a Copy uses the photo's
|
||||
real name and reports failures, and Open With… offers the app chooser.
|
||||
- **Photos scroll without a Load More button.** The timeline loads the next page while you are
|
||||
still a screen and a half from the end, with a spinner under it while a page is on its way. A
|
||||
month scrubber on the right edge of the whole timeline, marked by year, jumps to the month you
|
||||
drag it to and follows the scroll otherwise.
|
||||
- **Activity log redesign.** The feed is grouped by day (Today, Yesterday, weekday, date), with
|
||||
a time column and filter chips for All, Transfers, Changes, Sharing and Problems. A conflict the
|
||||
daemon logs again on every run now shows once, with a count. A "Needs attention" section on top
|
||||
lists the conflicts still waiting for a decision. Each logged conflict has a Resolve… button that
|
||||
opens the same dialog as the Sync page, and reads as resolved once the copy is gone.
|
||||
- **Share dialog.** The Invite button stays off until every address looks like an email; a
|
||||
malformed one marks the field red and names the entry in a tooltip. Buttons show a spinner
|
||||
while their request runs, so a slow invite or link removal can't be sent twice.
|
||||
- **New app icon.** A white cloud with the download arrow cut out of it, on a rounded Proton
|
||||
purple tile with a darker lower edge, drawn to the GNOME app-icon grid. It stays legible down to
|
||||
16 px on light and dark panels.
|
||||
- **Shared with Me redesign.** Only sections with something in them are shown, and a page
|
||||
with nothing shared says so in one place. Pending invitations sit at the top, with an Accept
|
||||
button beside each. A row reads as the name over who shared it, when, and its size; what the
|
||||
share allows is a tinted pill ("Can view", "Can edit", "Can manage") rather than a bare
|
||||
role word that looked like a button. Open, Show in My Files and Leave… are in the row's ⋮
|
||||
menu, and folders show a chevron. Inside a shared folder the header shows the path from the
|
||||
share down, with a back button. Add Bookmark is an icon in the header.
|
||||
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
||||
`pdfs favorite` command.
|
||||
|
||||
## [1.11.1] — 2026-09-19
|
||||
|
||||
Schema: **31** (unchanged).
|
||||
|
||||
### Fixed
|
||||
- **Double-click opens folders reliably.** Selecting a file no longer slides the details pane in
|
||||
by itself: the pane moved the files under the pointer between the two clicks of a double-click,
|
||||
so the folder did not open. The pane now shows on request, from the header's Details toggle,
|
||||
Alt+Enter or the item menu's Details, and then follows the selection. Closing it keeps the
|
||||
selection.
|
||||
- **The details pane opens only when asked.** On a wide window it used to show at start, empty,
|
||||
and close again whenever you entered a folder. It now follows the header toggle alone. With
|
||||
nothing selected it says so instead of closing, so opening a folder no longer moves the files.
|
||||
- **Context menus no longer have wide margins under some themes.** A menu opened on a file tile
|
||||
was placed inside the tile, so a theme's rules for tile contents (Catppuccin's
|
||||
`gridview > child box { margin: 12px }`) spaced out every section.
|
||||
- **My Files behaves more like a file manager.** A click on empty space between the files clears
|
||||
the selection; Ctrl or Shift held keeps it. Backspace goes back while the focus is on the files.
|
||||
- **An upload parked behind a temp-file rename no longer stays parked forever.** A create for a
|
||||
name that looks like a writer's temporary file waits for the rename to the final name. If that
|
||||
rename never came — the writer crashed, the file was deleted, or the name was never temporary —
|
||||
the upload sat in the queue permanently, holding its staged bytes. The queue now sweeps parked
|
||||
creates while it is idle: one whose file is gone is dropped, and one that has been parked for
|
||||
over an hour with nobody holding it open is uploaded under the name it has. An open file is
|
||||
left alone however old it is (B94).
|
||||
- **A file whose real name ends in `.tmp` uploads normally.** `.tmp` and `.temp` were treated as
|
||||
writer temp-file suffixes, so ordinary files — a Google Photos Takeout `index.tmp`, for
|
||||
instance — were parked waiting for a rename that was never going to happen (B95).
|
||||
- **Staged bytes that belong to nothing are cleaned up.** A staged blob that matches no queued
|
||||
upload and names no node used to be kept forever and warned about once per blob on every mount.
|
||||
Blobs older than 30 days are now retired with their size logged; younger ones are kept, but
|
||||
reported as a single line instead of one per blob (B96).
|
||||
- **Refreshing the gallery repeatedly no longer wedges the control socket.** Each refresh scope
|
||||
now runs one at a time; asking again while one is running is answered immediately rather than
|
||||
repeating work that contends with it for the database. `pdfs diagnostics` also names the scope
|
||||
of a running refresh (B97).
|
||||
- **Shutting down during a photo refresh no longer logs a wall of "undecryptable photo"
|
||||
warnings.** The metadata pass stops when teardown begins instead of running into the cancelled
|
||||
runtime. Nothing was wrong with those photos; the next refresh reads them again (B97).
|
||||
|
||||
## [1.11.0] — 2026-09-19
|
||||
|
||||
Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` /
|
||||
Schema: **31** (V30 `photos.content_hash` / `main_uid` / `group_key`, V31 `photos.resolved_at`); SDK bumped to `proton-sdk` /
|
||||
`proton-drive-rs` **0.6.5**.
|
||||
|
||||
### Changed
|
||||
@@ -797,6 +1029,13 @@ First stable release: FUSE files-on-demand mount, sync daemon under `proton-driv
|
||||
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||
by a new POSIX compliance suite for the filesystem.
|
||||
|
||||
[2.1.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.1.0
|
||||
[2.0.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v2.0.0
|
||||
[1.11.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.1
|
||||
[1.11.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.11.0
|
||||
[1.10.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.2
|
||||
[1.10.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.1
|
||||
[1.10.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.10.0
|
||||
[1.9.3]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.3
|
||||
[1.9.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.2
|
||||
[1.9.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.1
|
||||
|
||||
@@ -14,7 +14,7 @@ Consolidated from the historical feature roadmap; current bug status lives in [`
|
||||
* **Custom FUSE Mount Options (Horizon 1)**: Expose custom mounting parameters in configuration (e.g. `ro` for read-only, `direct_io` to bypass kernel caching, `allow_other`). Auto-generate systemd user mount units upon sync folder configuration.
|
||||
* **Symbolic Link & Hard Link Virtualization (Horizon 2)**: Add symbolic link virtualization inside the metadata store (`NodeType::Symlink`) by intercepting `readlink(2)` and `symlink(2)`. Targets should sync remotely as small encrypted metadata placeholder files on Drive (`proton-vfs-symlink: <target>`).
|
||||
* **Desktop File Manager Integration (Horizon 2)**: Develop shell extension plugins for popular Linux file managers (Nautilus Python plugin, Dolphin Service Menus, Thunar custom actions) to offer context-menu shortcuts ("Pin", "Unpin", "Copy Share Link", "View Version History") and file overlay sync badges.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favourites are implemented.
|
||||
* **Integrated Photo Gallery Enhancements (Horizon 2)**: Album *editing* — rename, cover photo, delete, and removing photos from an album — is still unported in the SDK (TS `updateAlbum` / `deleteAlbum` / `removePhotos`), as is adding a photo that lives on someone else's volume (TS `copyPhoto`). Album **creation** and adding own-volume photos landed with the Google Photos import. Still open: an auto-upload Pictures folder pipeline, and a GUI for creating albums by hand. Exif display, the date-based timeline scrubber and favorites are implemented.
|
||||
* **Google Photos import follow-ups**: the importer uploads photos one at a time — a large export would finish sooner with a bounded concurrent upload pool. Trash folders are recognized by a hardcoded list of localized names ([`takeout.rs`](../crates/pdfs-core/src/takeout.rs) `TRASH_FOLDERS`); a locale not on it imports its trash as ordinary photos. Motion-photo `.MP`/`.MV` sidecar videos are ignored rather than uploaded as related photos. Live validation against a real export is pending.
|
||||
* **Random-Access Media Streaming seek support (Horizon 2)**: Intercept out-of-order sparse reads (player seeks) in `pdfs-fuse` and prioritize downloading blocks around the seek offset, aborting/postponing sequential pre-fetches.
|
||||
* **Dynamic Sync Dashboard & Queue Visualization (Horizon 2)**: GUI real-time transfer list (progress bars, speed, ETA), sync history feed, storage usage breakdown, and global pause/resume sync controls.
|
||||
@@ -135,10 +135,10 @@ Three properties that shape the code:
|
||||
- **The active revision cannot be deleted.** `Core::delete_revision_for_uid` checks that locally so the user gets a sentence rather than an API code, and the dialog gives the current row no delete button.
|
||||
- **Saving a version never overwrites.** `SaveRevisionAs` refuses an existing destination and removes a partial file if the download fails — a half-written export looks identical to a good one to every tool that opens it afterwards.
|
||||
|
||||
### Photo Favourites (Implemented)
|
||||
### Photo Favorites (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
The gallery can mark and filter favourites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favourites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
The gallery can mark and filter favorites, through the SDK's photo-tag API (`update_photos`). Schema **v20** adds `photos.favorite`; the flag is learned in the timeline enrichment pass that already resolves each photo's name and media type, and is kept across a refresh that could not resolve a photo — the same learned-and-kept rule as `media_type`. The lightbox carries a star toggle, the Photos header a favorites filter, and the CLI has `pdfs favorite <uid> [--remove]` plus `pdfs photos --favorites`.
|
||||
|
||||
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
|
||||
|
||||
@@ -155,7 +155,7 @@ The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` /
|
||||
### RAW + JPEG Grouping (Implemented)
|
||||
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
|
||||
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favorite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
|
||||
|
||||
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
|
||||
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Translating Proton Drive for Linux
|
||||
|
||||
The desktop app (`pdfs-app`), the tray (`pdfs-tray`) and the search prompt (`pdfs-prompt`) are
|
||||
translated with GNU gettext in the `pdfs` text domain. The `pdfs` command-line tool and the daemon's
|
||||
own messages stay in English, because scripts parse their output.
|
||||
|
||||
US English is the source language. The shipped catalogs are listed in [`po/LINGUAS`](../po/LINGUAS):
|
||||
German, British English, Spanish, French, Italian, Dutch, Polish and Brazilian Portuguese.
|
||||
|
||||
## Picking a language
|
||||
|
||||
The apps follow the system locale (`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`). Preferences →
|
||||
General → Appearance → **Language** overrides that for the app, the tray and the prompt. The choice is
|
||||
stored as `language` in `config.json` and takes effect the next time they start.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `crates/pdfs-gui/src/i18n.rs` | Locale setup and the `gettext`, `gettext_f`, `ngettext_f`, `pgettext` and `gettext_noop` helpers |
|
||||
| `po/POTFILES.in` | Sources that `xgettext` scans |
|
||||
| `po/pdfs.pot` | The template, generated from the sources |
|
||||
| `po/<lang>.po` | One catalog per language |
|
||||
| `po/update.sh` | Regenerates the template and merges it into every catalog |
|
||||
| `po/build.sh <localedir>` | Compiles the catalogs to `<localedir>/<lang>/LC_MESSAGES/pdfs.mo` |
|
||||
|
||||
## Updating an existing translation
|
||||
|
||||
1. Run `po/update.sh`. It regenerates `po/pdfs.pot` and merges new and changed strings into each
|
||||
catalog, marking changed ones `fuzzy`.
|
||||
2. Edit `po/<lang>.po` with a PO editor (Poedit, Lokalize, GNOME Translation Editor) or a text
|
||||
editor. Translate the empty entries and review every `fuzzy` one, then remove its `fuzzy` flag.
|
||||
3. Check the catalog with `msgfmt --check --statistics -o /dev/null po/<lang>.po`.
|
||||
4. Try it from the source tree:
|
||||
|
||||
```bash
|
||||
po/build.sh target/locale
|
||||
PDFS_LOCALEDIR=target/locale LANGUAGE=<lang> cargo run -p pdfs-gui --bin pdfs-app
|
||||
```
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. Add the code (for example `sv` or `pt_PT`) to `po/LINGUAS`, keeping the list sorted.
|
||||
2. Run `po/update.sh`. It creates `po/<lang>.po` from the template with `msginit`.
|
||||
3. Fill in the `Plural-Forms` header if `msginit` did not, and translate the catalog.
|
||||
4. Add the code and the language's own name to `LANGUAGES` in `crates/pdfs-gui/src/i18n.rs`, so the
|
||||
Preferences picker offers it.
|
||||
5. Add `GenericName[<lang>]`, `Comment[<lang>]` and `Keywords[<lang>]` lines to the two desktop files
|
||||
in `packaging/`.
|
||||
|
||||
`cargo test -p pdfs-gui` checks that every language in the picker has a catalog, that
|
||||
`po/POTFILES.in` lists every source file that calls gettext, and that every catalog passes
|
||||
`msgfmt --check`.
|
||||
|
||||
## Rules for translators
|
||||
|
||||
- Keep every `{placeholder}` from the English text. You may move it, but not rename or drop it.
|
||||
`msgfmt --check` rejects a catalog where they differ. Plural forms keep `{n}` in every form.
|
||||
- Read the `#.` translator comments above an entry. They explain placeholders and date formats.
|
||||
- Date entries such as `%b %e` are `strftime` formats for GLib's `DateTime::format`. Translate them
|
||||
into the order and separators your language uses, not into literal dates.
|
||||
- A `msgctxt` such as `verb` or `column` tells you which meaning of a short word is meant.
|
||||
- Keep product names as they are: Proton Drive, Proton, Google Takeout.
|
||||
- Use the same term for the same thing across the catalog, and follow the terminology of your
|
||||
desktop (GNOME or KDE) for common actions such as Trash, Preferences and Cancel.
|
||||
|
||||
## Rules for developers
|
||||
|
||||
- Wrap every user-visible GUI string: `gettext("Move to Trash")`.
|
||||
- Never build a sentence from translated pieces or with `format!`. Use named placeholders:
|
||||
`gettext_f("Moved {name} to Trash", &[("name", &name)])`.
|
||||
- Counts use `ngettext_f("{n} item", "{n} items", count, &[])`; `{n}` is filled in for you.
|
||||
- Give short, ambiguous words a context: `pgettext("verb", "Open")`.
|
||||
- Mark labels in `const` tables with `gettext_noop` and translate them where they are shown.
|
||||
- The message id must be a single string literal on one line, since `xgettext` reads literals only.
|
||||
- Add a `// Translators:` comment on the line above a call whose meaning is not obvious.
|
||||
- Do not translate log messages, CLI output, action or icon names, or values the daemon sends back.
|
||||
- Run `po/update.sh` after changing strings and commit the updated template and catalogs.
|
||||
@@ -0,0 +1,353 @@
|
||||
# UI/UX audit and improvement plan
|
||||
|
||||
Audit date: 2026-09-23. Scope: `pdfs-gui` (all pages, dialogs, photo viewer), `pdfs-tray`, and the
|
||||
backend capabilities the UI would need. File references are to `crates/pdfs-gui/src/app/` unless
|
||||
stated otherwise.
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The client is functionally rich but the UI is built page by page, and it shows:
|
||||
|
||||
- **No sync control surface.** There is no way to pause or resume sync, no list of what is stuck or
|
||||
failing, and no conflict overview. The backend has no pause at all, no per-op listing, and no
|
||||
per-file conflict record (conflicts exist only as `(sync-conflict <ts>)` files plus activity rows).
|
||||
The GUI reads `online`, `pending_uploads` and `pending_changes` from `Status` and ignores
|
||||
`failing_ops`, `failing_error`, `parked_uploads` and `staged_*`.
|
||||
- **Information architecture is scattered.** Live transfers are a group on *Settings* while the
|
||||
*Activity* page is history only. *Locations* and *Computers* point at each other. Import lives under
|
||||
Settings but is launched from Gallery and returns to Settings. Sign-out, quota, mount status,
|
||||
pinned files, import and developer info all share the Settings page.
|
||||
- **Chrome is non-standard.** Every page draws its own title and toolbar row under an almost empty
|
||||
`AdwHeaderBar` whose title is always "Proton Drive". Two stacked bars, wasted height, no current
|
||||
folder in the title.
|
||||
- **Icons and wording drift.** Upload uses `document-send-symbolic` (reads as "send"), offline uses
|
||||
stars (reads as "favorite"), trash icons mean "stop syncing", "remove device" and "remove
|
||||
bookmark". "Keep offline" / "Unpin" / "Remove offline copy" / "Available offline" are one concept.
|
||||
"Computers" vs "device", "Gallery" vs "Photos", "mount service" vs "daemon" vs "Connect".
|
||||
- **Theming is hardcoded.** `PROTON_PURPLE` overrides the system accent; badge, viewer and tile
|
||||
colours are hex/rgba literals and ignore light, dark and high-contrast modes.
|
||||
- **Several visible bugs** (section 9).
|
||||
|
||||
## 2. Design principles
|
||||
|
||||
1. **Follow GNOME HIG and current libadwaita.** The system has libadwaita 1.9; the crate is pinned to
|
||||
feature `v1_5`. Raise it to at least `v1_7` to get `adw::Spinner`, `ToggleGroup`,
|
||||
`InlineViewSwitcher`, `ButtonRow`, `WrapBox`, `BottomSheet`, and (1.8) `ShortcutsDialog`.
|
||||
2. **Sync state is first class.** The user must always see: up to date / syncing / paused / offline /
|
||||
needs attention, and be one click from pausing or from the list of problems.
|
||||
3. **One term, one icon, one place** per concept (glossary in section 8).
|
||||
4. **Secondary and destructive actions go into menus.** Rows get at most one visible suffix action
|
||||
plus a `view-more-symbolic` menu. Destructive actions are red inside the menu and in the confirm
|
||||
dialog, not as filled buttons in headers.
|
||||
5. **Never block on the network visibly.** Keep old content on screen while reloading, show a header
|
||||
spinner, never flash the whole page to a "Loading…" status page.
|
||||
6. **Respect the system.** Use the system accent by default (brand purple stays on login, app icon
|
||||
and an opt-in toggle). Use named colours (`@success_color`, `@warning_color`, `@error_color`).
|
||||
|
||||
## 3. New information architecture
|
||||
|
||||
### Sidebar
|
||||
|
||||
```
|
||||
Proton Drive (header: app name)
|
||||
My files
|
||||
Photos
|
||||
Shared with me (badge: pending invitations)
|
||||
Shared by me
|
||||
Computers
|
||||
Trash
|
||||
─────────────
|
||||
Sync (badge: issue count, icon reflects state)
|
||||
───────────── footer
|
||||
[●] Up to date · Pause ← sync status strip, click opens Sync
|
||||
▓▓▓░░░ 269 GiB of 1.6 TiB ← quota
|
||||
(avatar) user@proton.me ⋮ ← account menu: Preferences, Sign out…
|
||||
```
|
||||
|
||||
- **Remove** sidebar rows: *Locations* (merged into Sync), *Activity* (becomes the Sync "History"
|
||||
view), *Settings* (becomes an `AdwPreferencesDialog`, `Ctrl+,`).
|
||||
- **Rename** *Gallery* to *Photos* and *Shared* to *Shared by me*.
|
||||
- Primary menu: Preferences, Keyboard Shortcuts, About. The About dialog's `debug_info` gets app
|
||||
version, user agent, daemon status and config path (replacing the "Developer" group).
|
||||
|
||||
### Sync page (new, replaces Locations + Activity + Settings→Activity)
|
||||
|
||||
Top: an `AdwBanner`-style status card with state icon, one-line summary ("Syncing 12 files · 3.4
|
||||
MB/s", "Paused until you resume", "2 files need attention") and one primary button: **Pause** /
|
||||
**Resume** (split button with "Pause for 1 hour / until tomorrow / until resumed").
|
||||
|
||||
Below, an `InlineViewSwitcher` with four views:
|
||||
|
||||
1. **Issues** (default when non-empty): conflicts, failing uploads (`last_error`, attempts, next
|
||||
retry), folders in `error`/`conflict` state, parked uploads older than N minutes. Each row has a
|
||||
primary fix action (Resolve…, Retry now) and a menu (Show in folder, Discard change…).
|
||||
2. **Transfers**: in-flight uploads and downloads with progress, speed, cancel; background jobs
|
||||
(thumbnail build, Takeout import, re-date) with their own cancel.
|
||||
3. **Folders**: the old Locations list. Row: path, mode as a dropdown suffix (Synced / On-demand, with
|
||||
confirm when going on-demand deletes local copies), state subtitle, `Sync now` and a menu (Open,
|
||||
Change location…, Pause this folder, Stop syncing…). "Add folder…" as a `ButtonRow` at the end.
|
||||
The primary mount appears first and is not removable.
|
||||
4. **History**: the old Activity feed, with a kind filter, failures in `@error_color`, rows
|
||||
activatable (open or reveal the target), and "Clear history".
|
||||
|
||||
### Preferences dialog (slimmed Settings)
|
||||
|
||||
| Page | Contents |
|
||||
|---|---|
|
||||
| General | Start on login; Mount location (path + Change…); Notifications (on sync issues, on import done) |
|
||||
| Storage | Cache usage bar; Cache budget (spin row, in GB, "Unlimited" switch instead of magic 0); Clear cache…; Offline files (count + "Manage" → Sync › Folders filter or a sub-page) |
|
||||
| Sync | Ignore patterns (editable list); Conflict clean-up: Off / Report / Remove identical copies (`conflict_sweep`); later: bandwidth limits |
|
||||
| Advanced | Open-with rules, search prompt config (only if kept in GUI) |
|
||||
|
||||
Moved out: account row and Sign out (sidebar account menu, with confirm), quota (sidebar footer),
|
||||
mount status (sidebar status strip), live transfers (Sync), Google Photos import (Photos page menu),
|
||||
version/user agent (About → Debug info).
|
||||
|
||||
## 4. Backend work required
|
||||
|
||||
Everything below is new; nothing like it exists today.
|
||||
|
||||
### B-1 Pause and resume (global, then per folder)
|
||||
|
||||
- `Request::SetSyncPaused { paused: bool, until: Option<i64> }`, persisted (config key
|
||||
`sync_paused_until` or a DB setting) so a pause survives restarts; `Response::Status` gains
|
||||
`paused` and `paused_until`.
|
||||
- Drain: `run_pending_drain` (`pdfs-fuse/src/drain.rs:265`) already releases claimed ops while
|
||||
offline; gate on `paused` at the same point and reuse the wake path.
|
||||
- Sync engine: add `SyncMsg::Pause/Resume` or gate `reconcile_all` / `reconcile_folder`; the 120 s
|
||||
poll and watcher keep recording, they just do not apply.
|
||||
- FUSE reads must keep working while paused (on-demand hydration is a read, not a sync); document that
|
||||
pause means "no uploads, no mirror reconcile".
|
||||
- Per folder later: `sync_folder.paused` column, skipped in `reconcile_all`.
|
||||
- Bandwidth limits later: token bucket in `CountingReader` / `CountingWriter`
|
||||
(`pdfs-fuse/src/transfers.rs`).
|
||||
|
||||
### B-2 Pending ops and transfers
|
||||
|
||||
- `Request::ListPendingOps` → id, kind, path/name, attempts, `last_error` (classified as
|
||||
`ErrorKind`), `next_attempt_at`, parked flag, staged bytes.
|
||||
- `Request::RetryOp { id }` (set `next_attempt_at = now`, wake drain) and `RetryAllFailing`.
|
||||
- `Request::DiscardOp { id }` for a create/revision whose bytes should be dropped. Must save the
|
||||
staged blob to a local recovery folder first; never silent data loss (see `docs/RECOVERY.md`).
|
||||
- `TransferItem` gains a stable id and `Request::CancelTransfer { id }`.
|
||||
- Per-folder last error message on `SyncFolderInfo` (today the text only exists in the activity feed).
|
||||
|
||||
### B-3 Conflicts
|
||||
|
||||
- `Request::ListConflicts`: walk nodes whose name parses with `sweep.rs` `conflict_base_name`, for
|
||||
the mount and every mirror folder. Return copy path, original path (if it exists), both sizes and
|
||||
mtimes, whether content is identical (size + `content_sha1`, as the sweep already proves), and
|
||||
which side is local vs remote when known.
|
||||
- Optionally a `conflict` table written by `keep_as_conflict_copy` / `preserve_conflict_copy` so the
|
||||
origin (drain vs mirror, device) is recorded instead of inferred from names.
|
||||
- `Request::ResolveConflict { copy, keep: Original | Copy | Both }`:
|
||||
- Original: trash the copy (re-verify like the enforcing sweep does).
|
||||
- Copy: upload the copy's content as a new revision of the original, then trash the copy (the old
|
||||
content stays in version history, so this is reversible).
|
||||
- Both: rename the copy to a user-chosen name, drop the `(sync-conflict …)` stamp.
|
||||
- Activity rows for each resolution.
|
||||
|
||||
## 5. Shell and visual polish (all pages)
|
||||
|
||||
- **Per-page header bars.** Give each stack page its own `adw::ToolbarView` + `adw::HeaderBar` (or
|
||||
inject page widgets into the shared bar on page switch). Title = page name, or the current folder
|
||||
in My files. Page actions move into the header; the extra toolbar row disappears.
|
||||
- **One page skeleton.** Same margins everywhere (today 12 vs 18), same `Clamp` rule (lists that are
|
||||
settings-like clamp at 720, file/photo grids are full width), same status pages (`compact`,
|
||||
`vexpand`, crossfade), same Retry semantics (Retry reloads; "Start Proton Drive" is a separate,
|
||||
explicitly labelled button when the service is down).
|
||||
- **Loading.** `adw::Spinner` in the header; keep the old model until the new one lands; status page
|
||||
only on first load.
|
||||
- **Theme.** Drop the global `accent_*` override; offer "Use Proton purple accent" in Preferences.
|
||||
Replace hex colours with named colours; test light, dark and high contrast.
|
||||
- **Move the 70-line CSS string** in `main.rs:232` to `resources/style.css` in the gresource
|
||||
(`style-dark.css` if needed), so it can be edited and linted.
|
||||
- **Shortcuts.** Replace the hand-rolled cheatsheet with `adw::ShortcutsDialog` (or
|
||||
`GtkShortcutsWindow` before 1.8) listing all bindings, including F5/Ctrl+R and the viewer keys.
|
||||
- **Toasts vs dialogs.** Dialogs report their own errors inline (versions, share, add bookmark); a
|
||||
toast behind a modal is invisible. Pluralise properly (`ngettext`-ready helpers, no "item(s)").
|
||||
- **i18n groundwork.** Wrap user strings in a `gettext!`-style macro now, even without translations,
|
||||
so later localisation is mechanical.
|
||||
|
||||
## 6. Page-by-page plan
|
||||
|
||||
### My files
|
||||
|
||||
Header: `[‹ ›] Path bar (breadcrumbs, scroll to end, Ctrl+L to edit) … [search] [view ▾] [+ New ▾]`
|
||||
|
||||
- **New ▾** (`list-add-symbolic`, suggested): New folder (Ctrl+Shift+N), Upload files (Ctrl+U),
|
||||
Upload folder. Replaces three separate icons, fixes the "send" icon.
|
||||
- **View ▾** split button: Grid/List (Ctrl+1/2), Sort by Name/Size/Modified, ascending/descending,
|
||||
Folders first, zoom slider. Persist choice (config or GSettings).
|
||||
- **Folder ⋮ menu** (in header or background context menu): Refresh, Build thumbnails, Make folder
|
||||
available offline, Open in file manager, Share folder…. Build thumbnails leaves the main toolbar.
|
||||
- **Navigation.** Back/forward history (Alt+←/→), Up (Alt+↑); searching then pressing Back clears
|
||||
the search consistently.
|
||||
- **Context menus** as `gtk::PopoverMenu` from `gio::Menu` + actions: keyboard navigable, Menu /
|
||||
Shift+F10, same action set as details pane. Add a background menu. Order: Open, Open with…, Play
|
||||
(media) · Available offline (check item) · Share…, Copy link · Rename…, Move to…, Versions… ·
|
||||
Move to Trash (destructive).
|
||||
- **Offline state.** One concept "Available offline", toggle semantics. Badges: cloud-only = no
|
||||
badge or subtle `cloud-outline`; downloaded = check; available offline = pin/`folder-download`
|
||||
style filled icon. Use named colours. Folders can be made available offline too, or the toggle is
|
||||
hidden for them (fix `details.rs:259`).
|
||||
- **List view.** Sortable, resizable columns: Name, Status, Size, Modified (date + time), and
|
||||
Location for search results. Rubberband selection. One selection model shared between views.
|
||||
- **Details pane.** On wide windows a real side pane (breakpoint, not overlay); header bar with
|
||||
close; real thumbnail; sections: Info, Offline, Sharing (people count, link), Versions (latest 3 +
|
||||
"All versions…"). Same actions as the context menu.
|
||||
- **Selection bar** (≥1 selected, not ≥2): count + size, Available offline, Move to…, Share (single),
|
||||
Move to Trash; Select all (Ctrl+A). Drop the confirm dialog for trash since Undo exists (trash is
|
||||
reversible), or keep confirm and drop Undo — not both.
|
||||
- **Drag and drop.** Drop external files/folders to upload (reuse Takeout's `FileList` target), drag
|
||||
the whole selection, highlight drop targets, breadcrumbs accept drops, Undo toast on move.
|
||||
- **Move to…** a folder-picker dialog (tree/list of Drive folders) instead of a free-text path.
|
||||
- **Rename** pre-selects the stem, not the extension; drop the redundant "Rename “x”." body.
|
||||
- **Search.** Scope toggle (This folder / Everywhere), "Showing first 200 results" note, Location
|
||||
column, correct cached badges (search hits hardcode `cached: false`).
|
||||
- **Upload feedback.** Toast "Uploading 3 items" with "View" → Sync › Transfers.
|
||||
|
||||
### Photos (was Gallery)
|
||||
|
||||
- Header: title "Photos", `InlineViewSwitcher` Timeline | Albums in the header, Select toggle,
|
||||
`+ Upload` (fix: use `adw::ButtonContent`, today `.icon_name()` overwrites `.label()` at
|
||||
`photos.rs:426-432`), ⋮ menu: Import from Google Photos…, Refresh, Rebuild library.
|
||||
- Filters: a single filter dropdown/`ToggleGroup` (All, Photos, Videos, RAW, Favorites) instead of
|
||||
two segmented rows where "Photos" appears twice. Counts follow the active filter.
|
||||
- **Month scrubber** on the right edge that *jumps* (not filters); the month dropdown becomes a real
|
||||
"Filter by date" if kept.
|
||||
- Infinite scroll with prefetch (no "Load more" pill; page size ≥ 200; also trigger when the first
|
||||
page does not fill the viewport).
|
||||
- Tiles: `ContentFit::Cover` (crop) in justified rows, known aspect ratio from metadata to prevent
|
||||
reflow, placeholder as a tinted card of the final size, subtle hover (no 1.06 scale with heavy
|
||||
shadow on a 2 px gap grid), last row justified up to a max stretch.
|
||||
- Context menu on tiles: Open, Favorite, Add to album, Download/Save copy, Show in My files, Move to
|
||||
Trash. Selection: Shift range, Ctrl+A, bulk Favorite, Add to album, Download.
|
||||
- Empty states per filter ("No favorites yet", "No videos in June 2024") instead of the global
|
||||
"No photos yet" with upload buttons.
|
||||
- Import: runs show a banner on Photos ("Importing from Google Photos — 1,204 of 5,000 · View") and
|
||||
Back returns to where the user came from.
|
||||
- **Albums:** Create, rename, delete, remove from album (not trash). Fix state bugs: filter row
|
||||
hidden on the album grid, subtitle count, scroll position kept when switching back.
|
||||
- **Viewer:** `adw::Window` (or full-window overlay), controls auto-hide after 2 s idle, zoom/pan
|
||||
(pinch, Ctrl+scroll, double-click), info panel beside the image instead of over the top bar,
|
||||
pages beyond the loaded 60, counter against the real total, videos play inline with
|
||||
`gtk::Video`, trash with the same confirm/undo rule as the grid, real filename in "Save a copy",
|
||||
success toast, "Open with…" app chooser, favorite tooltip reflects state, readable dates.
|
||||
|
||||
### Shared with me / Shared by me
|
||||
|
||||
- Shared with me: header title shows the drill-down path, with back; invitations as a banner at
|
||||
the top ("2 invitations · Review") opening a list, Reject confirmed; bookmarks become a separate
|
||||
view or section via a switcher. Rows: Open, Save to My files, Available offline where supported.
|
||||
- Shared by me: rows activatable (open), suffix "Manage" becomes a menu with Copy link, Manage
|
||||
access…, Stop sharing….
|
||||
- Share dialog: inline validation, loading spinner, confirm "Remove access" and "Delete link",
|
||||
human role names, link expiry.
|
||||
|
||||
### Computers
|
||||
|
||||
- Show the real device name for "This computer" with rename inline.
|
||||
- Other computers: one suffix menu (Rename, Use this identity…, Remove…). Remove uses a destructive
|
||||
confirm that requires typing the device name since it deletes backups.
|
||||
- "Restore folders" becomes a per-device action ("Restore to this computer…") with an inline-empty
|
||||
state when nothing is restorable, and moves the "Add folder" responsibility fully to Sync.
|
||||
- Terminology: "computer" everywhere in UI copy.
|
||||
|
||||
### Trash
|
||||
|
||||
- Same skeleton as other list pages (clamped `boxed-list`), sort by deleted date, multi-select with
|
||||
Restore / Delete permanently, "Empty Trash…" moved into a header ⋮ menu or kept as a flat button
|
||||
with a destructive confirm, proper plurals, Retry on error.
|
||||
|
||||
### Login
|
||||
|
||||
- Human error messages mapped from error kinds, spinner in the button, "Create account" and "Forgot
|
||||
password" links, recovery-code hint in 2FA, cancelling CAPTCHA returns to the form with a message.
|
||||
|
||||
## 7. Tray
|
||||
|
||||
- Symbolic icon variants: `pdfs-synced`, `pdfs-syncing`, `pdfs-paused`, `pdfs-offline`,
|
||||
`pdfs-attention`; tooltip with the one-line state; `NeedsAttention` status for issues.
|
||||
- Menu: status line · "N issues — View" (opens Sync › Issues) · Pause sync ▸ (1 h, until tomorrow,
|
||||
until resumed) / Resume · Open Proton Drive · Open folder · "Sign in…" when logged out (no
|
||||
"Connect") · Stop Proton Drive (confirm) · "Hide tray icon" instead of "Quit" (daemon keeps
|
||||
running and the label must not suggest otherwise).
|
||||
|
||||
## 8. Glossary (one term, one icon)
|
||||
|
||||
| Concept | UI term | Icon |
|
||||
|---|---|---|
|
||||
| Root of Drive | My files | `folder-symbolic` |
|
||||
| Background service | Proton Drive (running / stopped) — never "daemon" or "mount service" in UI | — |
|
||||
| Cloud-only file | Online only | none / `cloud-outline` |
|
||||
| Pinned file | Available offline | pin/download-filled custom icon, not stars |
|
||||
| Upload | Upload files / Upload folder | `document-upload`-style arrow up, custom pair |
|
||||
| Folder sync mode | Synced / On-demand | — |
|
||||
| Stop syncing a folder | Stop syncing… | `media-playback-stop-symbolic` in a menu, not trash |
|
||||
| Remove a computer | Remove computer… | destructive, in menu |
|
||||
| Conflict copy | Conflict | `dialog-warning-symbolic` + warning colour |
|
||||
| Failure | Failed / Needs attention | `dialog-error-symbolic` + error colour |
|
||||
| Pause | Pause sync / Resume sync | `media-playback-pause-symbolic` / `-start-` |
|
||||
| Photos section | Photos | `image-x-generic-symbolic` |
|
||||
| Sharing | Shared by me / Shared with me | distinct icons (`emblem-shared` / `folder-publicshare`) |
|
||||
|
||||
Spelling: US English throughout ("Favorite", "color"), matching the `pdfs favorite` command and
|
||||
Proton's own apps. Title Case for
|
||||
dialog headings and buttons in dialogs, Sentence case for menu items and rows (HIG).
|
||||
|
||||
## 9. Bugs found during the audit (quick wins)
|
||||
|
||||
1. Photos Upload pill loses its label (`photos.rs:426-432`); same for viewer "Show on map"
|
||||
(`photo_viewer.rs:594-598`).
|
||||
2. Takeout Back always goes to Settings, sidebar highlights Settings when opened from Photos
|
||||
(`main.rs:779`, `takeout.rs:131-136`).
|
||||
3. Thumbnail-build completion message computed then hidden (`browser.rs:1632-1667`).
|
||||
4. Search results show cached files as cloud-only (`browser.rs:2344`).
|
||||
5. Details "Available offline" switch shown for folders (`details.rs:259`).
|
||||
6. Grid and list keep separate selections; details pane can describe a hidden-view item.
|
||||
7. F2 silently does nothing with multiple items selected.
|
||||
8. Filter changes on the Albums grid swap to the timeline while the Albums toggle stays active
|
||||
(`photos.rs:958-997`).
|
||||
9. Subtitle "60 photos" next to "All 1,416" (loaded count vs total, `photos.rs:2007-2015`); viewer
|
||||
counter "12 of 60".
|
||||
10. Viewer info panel covers Close/Trash/Save (`photo_viewer.rs:588` vs `648`).
|
||||
11. Locations mode switch and removal reload Computers, not Locations; a rejected mode switch stays
|
||||
flipped until the next tick (`devices.rs:612`, `devices.rs:809`, `locations.rs:374`).
|
||||
12. Going on-demand deletes local copies without confirmation.
|
||||
13. Retry on Not-connected silently restarts the systemd unit; Locations' Retry does not.
|
||||
14. Toasts from Versions/Share dialogs land behind the modal.
|
||||
15. "Emptied trash —" dangling dash, "item(s)", "archive(s)", raw revision id as version title,
|
||||
raw lowercase link role.
|
||||
16. Bulk context menu shows a separator directly under the header when a folder is selected.
|
||||
17. Sign out, Reject invitation, Remove access, Delete link, tray Disconnect: no confirmation.
|
||||
18. Stale doc comments (`devices.rs:46-48`, `locations.rs:329-331`, `albums.rs:4`,
|
||||
`browser.rs:1155`).
|
||||
|
||||
## 10. Phased roadmap
|
||||
|
||||
Each phase is shippable on its own. Estimates are rough, for one developer.
|
||||
|
||||
| Phase | Content | Crates | Size |
|
||||
|---|---|---|---|
|
||||
| 0 Quick wins | Section 9 bugs, glossary wording, icon swaps (upload, offline, stop syncing), confirmations, plurals | gui, tray | S (2–3 days) |
|
||||
| 1 Shell | libadwaita feature bump, per-page header bars, sidebar restructure + footer (status, quota, account), Preferences dialog, About debug info, theme/colour cleanup, CSS to gresource, shortcuts dialog | gui | M (1 week) |
|
||||
| 2 Sync hub + pause | B-1 global pause, B-2 list/retry pending ops, Sync page (status card, Issues, Transfers, Folders, History), tray states and pause menu, GUI uses `failing_*` / `parked_*` | core, fuse, gui, tray, cli | L (1.5–2 weeks) |
|
||||
| 3 Conflicts | B-3 list + resolve, Issues rows with a compare/resolve dialog (both versions side by side: size, date, device, preview for images/text), CLI `pdfs conflicts` | core, fuse, gui, cli | M–L |
|
||||
| 4 File browser | Header layout, New menu, sort/persisted view, PopoverMenu context menus, details side pane, DnD upload, folder picker for Move, search scope | gui | L |
|
||||
| 5 Photos | Rename, header switcher, filters, infinite scroll + scrubber, tile layout, context menu, album management, viewer rewrite (auto-hide, zoom, inline video) | gui (+ core for album mutations) | L |
|
||||
| 6 Rest | Shared pages, Computers, Trash, Login, per-folder pause, bandwidth limits, i18n wrapping | all | M |
|
||||
|
||||
Phase 2 is where the user-reported gaps (pause, resume, stuck items) get closed; phase 3 closes the
|
||||
conflict overview. Phases 0–1 fix most of the "unpolished" impression cheaply and should come first
|
||||
because later phases build on the new header/page skeleton.
|
||||
|
||||
### Verification per phase
|
||||
|
||||
- `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||
`cargo test --workspace`.
|
||||
- Backend phases: DB migration tests in `crates/pdfs-core/src/db/tests.rs` (paused state, conflict
|
||||
table if added), drain tests proving no op is claimed while paused, resolve-conflict tests proving
|
||||
the losing side stays recoverable (trash or version history).
|
||||
- GUI phases: before/after screenshots in `images/` (light and dark), keyboard-only walkthrough of
|
||||
each page, narrow-window check (sidebar collapse breakpoint).
|
||||
|
After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 34 KiB |
@@ -1,26 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Proton Drive for Linux">
|
||||
<defs>
|
||||
<linearGradient id="pdl-bg" x1="0" y1="0" x2="0" y2="128" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#8B72FF"/>
|
||||
<stop offset="1" stop-color="#5A38E8"/>
|
||||
<linearGradient id="pdl-face" x1="12" y1="12" x2="116" y2="112" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#A48BFF"/>
|
||||
<stop offset="0.55" stop-color="#7552FF"/>
|
||||
<stop offset="1" stop-color="#5B36F2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pdl-cloud" x1="0" y1="34" x2="0" y2="88" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#FFFFFF"/>
|
||||
<stop offset="1" stop-color="#ECE6FF"/>
|
||||
</linearGradient>
|
||||
<!-- the cloud, with the arrow cut out of it as negative space -->
|
||||
<mask id="pdl-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="128" height="128">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="66" cy="57" r="23"/>
|
||||
<circle cx="42" cy="68" r="16"/>
|
||||
<circle cx="88" cy="69" r="15"/>
|
||||
<rect x="26" y="68" width="77" height="20" rx="10"/>
|
||||
</g>
|
||||
<g stroke="#000000" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 49v26"/>
|
||||
<path d="M54.5 65.5l9.5 9.5 9.5-9.5"/>
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
|
||||
<rect width="128" height="128" rx="29" fill="url(#pdl-bg)"/>
|
||||
<!-- base: a rounded tile with a darker lower edge for depth -->
|
||||
<rect x="12" y="16" width="104" height="100" rx="26" fill="#4424C0"/>
|
||||
<rect x="12" y="12" width="104" height="100" rx="26" fill="url(#pdl-face)"/>
|
||||
<rect x="12.5" y="12.5" width="103" height="99" rx="25.5" stroke="#FFFFFF" stroke-opacity="0.2"/>
|
||||
|
||||
<!-- cloud: the remote drive (symmetric about x=64) -->
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="64" cy="42" r="20"/>
|
||||
<circle cx="44" cy="52" r="14"/>
|
||||
<circle cx="84" cy="52" r="14"/>
|
||||
<rect x="32" y="50" width="64" height="16" rx="8"/>
|
||||
</g>
|
||||
|
||||
<!-- pull: files arrive only when opened -->
|
||||
<g stroke="#FFFFFF" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 75v17"/>
|
||||
<path d="M53 84l11 11 11-11"/>
|
||||
<path d="M45 108h38" stroke-opacity="0.55"/>
|
||||
</g>
|
||||
<!-- cloud shadow, then the cloud itself -->
|
||||
<rect y="3" width="128" height="128" fill="#2E1596" fill-opacity="0.3" mask="url(#pdl-cut)"/>
|
||||
<rect width="128" height="128" fill="url(#pdl-cloud)" mask="url(#pdl-cut)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 990 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 899 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -9,17 +9,18 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.11.0
|
||||
pkgver=2.2.0
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'perl-image-exiftool')
|
||||
makedepends=('cargo' 'git')
|
||||
makedepends=('cargo' 'git' 'gettext')
|
||||
provides=('pdfs')
|
||||
# Files-on-demand mount + keyring need these at runtime.
|
||||
optdepends=('gnome-keyring: credential storage via Secret Service'
|
||||
'ffmpeg: video thumbnails'
|
||||
'xdg-utils: "Open folder" action from the tray'
|
||||
'fzf: live search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal for pdfs-prompt --fzf started from a keybinding')
|
||||
@@ -73,5 +74,8 @@ package() {
|
||||
install -Dm644 "packaging/proton-drive.service" \
|
||||
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||
|
||||
# Translations: compiled from po/ into /usr/share/locale.
|
||||
po/build.sh "$pkgdir/usr/share/locale"
|
||||
|
||||
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# AUR packages
|
||||
|
||||
Source of truth for the three AUR packages of this project. Each subdirectory is
|
||||
the full content of one AUR git repository (`pkgbase` = directory name):
|
||||
|
||||
| Directory | What it does | AUR repository |
|
||||
| --- | --- | --- |
|
||||
| `proton-drive-for-linux` | Builds the latest tagged release from source | `ssh://aur@aur.archlinux.org/proton-drive-for-linux.git` |
|
||||
| `proton-drive-for-linux-git` | Builds `main` from git | `ssh://aur@aur.archlinux.org/proton-drive-for-linux-git.git` |
|
||||
| `proton-drive-for-linux-bin` | Installs the binaries from the GitHub release | `ssh://aur@aur.archlinux.org/proton-drive-for-linux-bin.git` |
|
||||
|
||||
The three packages conflict with each other and with the in-tree
|
||||
`packaging/PKGBUILD` package (`proton-drive-linux`), and every one of them
|
||||
`provides` `proton-drive-for-linux`, `proton-drive-linux`, and `pdfs`.
|
||||
|
||||
Each directory tracks only `PKGBUILD`, `.SRCINFO`, `.gitignore`, and `LICENSE`
|
||||
(the 0BSD license of the packaging sources, per
|
||||
[RFC40](https://rfc.archlinux.page/0040-license-package-sources/)); the
|
||||
`.gitignore` ignores everything else so downloaded sources, `src/`, `pkg/`, and
|
||||
built packages never reach the AUR.
|
||||
|
||||
## Releasing a new version
|
||||
|
||||
`.github/workflows/aur.yml` does this automatically:
|
||||
|
||||
- **On a release tag**, `release.yml` calls it after the GitHub release is
|
||||
published. It runs `./update.sh <version>`, pushes all three packages with
|
||||
`./push.sh`, and commits the updated recipes back to `main` as
|
||||
`chore(aur): update packages to <version>`.
|
||||
- **On a push to `main` that changes `proton-drive-for-linux-git/`**, it
|
||||
regenerates that package's `.SRCINFO` and pushes only the -git package.
|
||||
- **By hand**, run the workflow from the Actions tab. Give a version to publish
|
||||
a release, or leave it empty to publish only the -git package.
|
||||
|
||||
It needs the `AUR_SSH_PRIVATE_KEY` repository secret: a private key whose public
|
||||
half is registered with the AUR account.
|
||||
|
||||
To do the same by hand:
|
||||
|
||||
1. Tag and publish the upstream release, so the source tarball and the
|
||||
`proton-drive-linux-<version>-x86_64.tar.gz` release asset exist.
|
||||
2. Run `./update.sh <version>` — it rewrites `pkgver`, resets `pkgrel=1`,
|
||||
refreshes the checksums of the release-based packages, and regenerates every
|
||||
`.SRCINFO`.
|
||||
3. Build and inspect the packages in a clean chroot:
|
||||
`cd proton-drive-for-linux && pkgctl build --clean` (needs `devtools`).
|
||||
4. Commit here, then publish with `./push.sh` (or `./push.sh proton-drive-for-linux-bin`
|
||||
for a single package).
|
||||
|
||||
`proton-drive-for-linux-git` carries a `pkgver()` function, so its `pkgver` is
|
||||
computed at build time; do not push a commit that only bumps it. This is why new
|
||||
commits on `main` do not trigger an AUR push.
|
||||
|
||||
## Requirements for pushing
|
||||
|
||||
An SSH key registered with the AUR account and an entry in `~/.ssh/config`:
|
||||
|
||||
```
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur
|
||||
User aur
|
||||
```
|
||||
|
||||
`push.sh` creates a scratch clone of the AUR repository, copies the tracked
|
||||
files over it, commits, and pushes to `master`.
|
||||
@@ -0,0 +1,44 @@
|
||||
pkgbase = proton-drive-for-linux-bin
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)
|
||||
pkgver = 2.1.1
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
depends = webkitgtk-6.0
|
||||
depends = dbus
|
||||
depends = gcc-libs
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=2.1.1
|
||||
provides = proton-drive-linux=2.1.1
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-git
|
||||
options = !strip
|
||||
options = !debug
|
||||
source = proton-drive-linux-2.1.1-x86_64.tar.gz::https://github.com/narrrl/proton-drive-linux/releases/download/v2.1.1/proton-drive-linux-2.1.1-x86_64.tar.gz
|
||||
source = io.narl.proton-drive-linux.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.1/packaging/io.narl.proton-drive-linux.desktop
|
||||
source = io.narl.proton-drive-linux-tray.desktop::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.1/packaging/io.narl.proton-drive-linux-tray.desktop
|
||||
source = io.narl.proton-drive-linux.svg::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.1/packaging/io.narl.proton-drive-linux.svg
|
||||
source = proton-drive.service::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.1/packaging/proton-drive.service
|
||||
source = LICENSE-2.1.1::https://raw.githubusercontent.com/narrrl/proton-drive-linux/v2.1.1/LICENSE
|
||||
sha256sums = 3eb9d6787a0dfa66e2f942e9b2dd4a362cc93b50d1f37d176fe931ea1d22d53c
|
||||
sha256sums = d190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123
|
||||
sha256sums = 1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0
|
||||
sha256sums = e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828
|
||||
sha256sums = 25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60
|
||||
sha256sums = 02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c
|
||||
|
||||
pkgname = proton-drive-for-linux-bin
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the AUR repository to the sources only: ignore everything, then
|
||||
# force-add the tracked files with `git add -f`.
|
||||
*
|
||||
!.gitignore
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 Nils Pukropp <contact@narl.io>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Maintainer: Nils Pukropp <contact@narl.io>
|
||||
|
||||
pkgname=proton-drive-for-linux-bin
|
||||
pkgver=2.1.1
|
||||
pkgrel=1
|
||||
pkgdesc="Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (binaries)"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/narrrl/proton-drive-linux"
|
||||
license=('MIT')
|
||||
depends=('fuse3' 'gtk4' 'libadwaita' 'webkitgtk-6.0' 'dbus' 'gcc-libs' 'glibc'
|
||||
'hicolor-icon-theme')
|
||||
optdepends=('perl-image-exiftool: thumbnails for camera RAW files'
|
||||
'ffmpeg: video thumbnails'
|
||||
'gnome-keyring: credential storage over the Secret Service API'
|
||||
'kwallet: credential storage over the Secret Service API'
|
||||
'xdg-utils: "Open folder" action in the tray menu'
|
||||
'fzf: search-as-you-type launcher (pdfs-prompt --fzf)'
|
||||
'foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding')
|
||||
provides=("${pkgname%-bin}=$pkgver" "proton-drive-linux=$pkgver" 'pdfs')
|
||||
conflicts=('proton-drive-linux' 'proton-drive-for-linux' 'proton-drive-for-linux-git')
|
||||
# Upstream ships the binaries already built on Ubuntu 24.04; do not rewrite them.
|
||||
options=('!strip' '!debug')
|
||||
_tag="v$pkgver"
|
||||
_raw="https://raw.githubusercontent.com/narrrl/proton-drive-linux/$_tag"
|
||||
# The release tarball holds the four binaries only, so the desktop integration
|
||||
# files come straight from the tagged tree.
|
||||
source=("proton-drive-linux-$pkgver-x86_64.tar.gz::$url/releases/download/$_tag/proton-drive-linux-$pkgver-x86_64.tar.gz"
|
||||
"io.narl.proton-drive-linux.desktop::$_raw/packaging/io.narl.proton-drive-linux.desktop"
|
||||
"io.narl.proton-drive-linux-tray.desktop::$_raw/packaging/io.narl.proton-drive-linux-tray.desktop"
|
||||
"io.narl.proton-drive-linux.svg::$_raw/packaging/io.narl.proton-drive-linux.svg"
|
||||
"proton-drive.service::$_raw/packaging/proton-drive.service"
|
||||
"LICENSE-$pkgver::$_raw/LICENSE")
|
||||
sha256sums=('3eb9d6787a0dfa66e2f942e9b2dd4a362cc93b50d1f37d176fe931ea1d22d53c'
|
||||
'd190d6771fff0b975271fb62fd3fb0fa8bbf17d7278c10853c18557855b0c123'
|
||||
'1d95e250370220e3bb6aead6ce34d1d6cc759f42c4803b75f1667e78ff47c6c0'
|
||||
'e598a93c2715c3a9fdc7fb937902083d4a1135e8fbdfe6fe7e37f0383ebbd828'
|
||||
'25fa3bd9ba52df534be9d8bc66f77d7502fe1423177237d0d14b673dedeb1b60'
|
||||
'02c419e7f9af08dda720e4f852f3e57cf5a69e192605440f750e9d244fa2817c')
|
||||
|
||||
package() {
|
||||
install -Dm755 "$srcdir/pdfs" "$pkgdir/usr/bin/pdfs"
|
||||
install -Dm755 "$srcdir/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray"
|
||||
install -Dm755 "$srcdir/pdfs-app" "$pkgdir/usr/bin/pdfs-app"
|
||||
install -Dm755 "$srcdir/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt"
|
||||
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux-tray.desktop" \
|
||||
"$pkgdir/etc/xdg/autostart/io.narl.proton-drive-linux-tray.desktop"
|
||||
install -Dm644 "$srcdir/io.narl.proton-drive-linux.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-drive-linux.svg"
|
||||
|
||||
# systemd user unit for the auto-mount daemon; enabled per user, not by pacman.
|
||||
install -Dm644 "$srcdir/proton-drive.service" \
|
||||
"$pkgdir/usr/lib/systemd/user/proton-drive.service"
|
||||
|
||||
# Translations ship precompiled in the release tarball.
|
||||
install -dm755 "$pkgdir/usr/share"
|
||||
cp -r --no-preserve=ownership "$srcdir/locale" "$pkgdir/usr/share/locale"
|
||||
|
||||
install -Dm644 "$srcdir/LICENSE-$pkgver" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
pkgbase = proton-drive-for-linux-git
|
||||
pkgdesc = Unofficial Proton Drive client: FUSE files-on-demand mount, CLI, GTK4 app, tray (git)
|
||||
pkgver = 2.0.0.r1.g56161f6
|
||||
pkgrel = 1
|
||||
url = https://github.com/narrrl/proton-drive-linux
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
makedepends = cargo
|
||||
makedepends = git
|
||||
makedepends = gettext
|
||||
depends = fuse3
|
||||
depends = gtk4
|
||||
depends = libadwaita
|
||||
depends = webkitgtk-6.0
|
||||
depends = dbus
|
||||
depends = gcc-libs
|
||||
depends = glibc
|
||||
depends = hicolor-icon-theme
|
||||
optdepends = perl-image-exiftool: thumbnails for camera RAW files
|
||||
optdepends = ffmpeg: video thumbnails
|
||||
optdepends = gnome-keyring: credential storage over the Secret Service API
|
||||
optdepends = kwallet: credential storage over the Secret Service API
|
||||
optdepends = xdg-utils: "Open folder" action in the tray menu
|
||||
optdepends = fzf: search-as-you-type launcher (pdfs-prompt --fzf)
|
||||
optdepends = foot: terminal that hosts pdfs-prompt --fzf when started from a keybinding
|
||||
provides = proton-drive-for-linux=2.0.0.r1.g56161f6
|
||||
provides = proton-drive-linux=2.0.0.r1.g56161f6
|
||||
provides = pdfs
|
||||
conflicts = proton-drive-linux
|
||||
conflicts = proton-drive-for-linux
|
||||
conflicts = proton-drive-for-linux-bin
|
||||
options = !lto
|
||||
source = proton-drive-for-linux-git::git+https://github.com/narrrl/proton-drive-linux.git
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = proton-drive-for-linux-git
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep the AUR repository to the sources only: ignore everything, then
|
||||
# force-add the tracked files with `git add -f`.
|
||||
*
|
||||
!.gitignore
|
||||
!.SRCINFO
|
||||
!LICENSE
|
||||
!PKGBUILD
|
||||
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 Nils Pukropp <contact@narl.io>
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||