mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b5ccb28b | ||
|
|
af84ce2168 | ||
|
|
52651caf8e | ||
|
|
0b63a65661 | ||
|
|
7f84ff7dea | ||
|
|
15985f8684 | ||
|
|
dfc48375e2 | ||
|
|
2dde69525e | ||
|
|
f6136ce344 | ||
|
|
4fa61a14c3 | ||
|
|
fe8fb8cdcf | ||
|
|
f457fa68fc | ||
|
|
be8464f4a0 | ||
|
|
a5d29969dc | ||
|
|
43d1afe705 | ||
|
|
29e5769dec | ||
|
|
0fbc6421ba | ||
|
|
abf76cd5e8 | ||
|
|
8d0a575a7c | ||
|
|
91a5f21340 | ||
|
|
8351382ef2 | ||
|
|
b58b60010e | ||
|
|
a86c3e586d | ||
|
|
fdda35918f | ||
|
|
8bc52edeb4 | ||
|
|
792eee75a4 | ||
|
|
067561564e | ||
|
|
cf9e1665ca | ||
|
|
cc308ac25a | ||
|
|
690b91deab | ||
|
|
9cd8a196aa | ||
|
|
860d29cfe8 | ||
|
|
6cc8f72bab | ||
|
|
376f3edb28 | ||
|
|
09c0684a5e | ||
|
|
c1df3b6cc2 | ||
|
|
afd71713d5 | ||
|
|
c6bdba024f | ||
|
|
5a8e73657c | ||
|
|
cf0d495f8c | ||
|
|
bee15ee7cf | ||
|
|
f24ac8a429 | ||
|
|
f78f663740 | ||
|
|
470948c191 | ||
|
|
b095b9f6b0 | ||
|
|
f4d6580cd3 | ||
|
|
b2f62f70e1 | ||
|
|
dcb2ab7fac | ||
|
|
eb56f4a1e0 | ||
|
|
c28803b037 | ||
|
|
cd8474954b | ||
|
|
d7e07d042d | ||
|
|
45852082c0 | ||
|
|
99ad3838e1 | ||
|
|
637b512f6a | ||
|
|
16ad6548c8 | ||
|
|
847ece17d7 | ||
|
|
50d2acdaba | ||
|
|
53caf63ad4 | ||
|
|
5176dd91e1 | ||
|
|
e88903d5ff | ||
|
|
b3e6af1ae5 | ||
|
|
b0f9513915 | ||
|
|
91b0864fce | ||
|
|
8bd9481902 | ||
|
|
b2d5870834 |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in the client misbehaves
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
|
||||||
|
tracked there with context.
|
||||||
|
|
||||||
|
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Distribution and desktop
|
||||||
|
placeholder: "Fedora 44, GNOME 48 (Wayland)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where does it happen
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount / file operations
|
||||||
|
- Sync or backed-up folders (Computers)
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Login / keyring
|
||||||
|
- Photos
|
||||||
|
- Packaging / installation
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: diagnose
|
||||||
|
attributes:
|
||||||
|
label: "`pdfs diagnose` output"
|
||||||
|
description: Redact paths or the account address if you prefer.
|
||||||
|
render: console
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Daemon logs
|
||||||
|
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
|
||||||
|
render: console
|
||||||
|
- type: checkboxes
|
||||||
|
id: dataloss
|
||||||
|
attributes:
|
||||||
|
label: Data
|
||||||
|
options:
|
||||||
|
- label: This bug involved missing, truncated, or overwritten file content.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||||
|
about: Report privately — never in a public issue.
|
||||||
|
- name: Known issues
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||||
|
about: The authoritative ledger of tracked bugs and their status.
|
||||||
|
- name: Proton Drive service problems
|
||||||
|
url: https://proton.me/support
|
||||||
|
about: This is an unofficial client. Account or service issues go to Proton AG.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a capability or an improvement
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that the client makes hard or impossible today?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What you'd like to see
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Which part of the client
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount
|
||||||
|
- Sync / Computers
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Photos
|
||||||
|
- Packaging
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you considered
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- The user-visible effect, in a sentence or two. -->
|
||||||
|
|
||||||
|
**Crates touched:**
|
||||||
|
**Related `docs/BUGS.md` entries:**
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
|
||||||
|
|
||||||
|
## Implications
|
||||||
|
|
||||||
|
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
|
||||||
|
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||||
|
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||||
|
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||||
|
- [ ] None of the above
|
||||||
|
|
||||||
|
<!-- Screenshots for GTK changes. -->
|
||||||
@@ -122,13 +122,14 @@ jobs:
|
|||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: amd64
|
Architecture: amd64
|
||||||
Maintainer: Nils Pukropp <contact@narl.io>
|
Maintainer: Nils Pukropp <contact@narl.io>
|
||||||
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4
|
Depends: fuse3, libgtk-4-1 (>= 4.12.0), libadwaita-1-0 (>= 1.5.0), libsecret-1-0, libwebkitgtk-6.0-4, libimage-exiftool-perl
|
||||||
Installed-Size: ${INSTALLED_SIZE}
|
Installed-Size: ${INSTALLED_SIZE}
|
||||||
Description: Proton Drive client for Linux
|
Homepage: https://github.com/narrrl/proton-drive-linux
|
||||||
Proton Drive client for Linux featuring:
|
Description: Unofficial Proton Drive client for Linux
|
||||||
- FUSE files-on-demand mount
|
Files-on-demand Proton Drive for the Linux desktop, unaffiliated with Proton AG:
|
||||||
- Command-line interface (CLI)
|
- FUSE virtual mount with block-level caching
|
||||||
- GTK4 system tray, desktop application, and search launcher
|
- Scriptable command-line interface (pdfs)
|
||||||
|
- GTK4 desktop application, system tray, and search launcher
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Build Debian package
|
# Build Debian package
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Code of Conduct
|
||||||
|
|
||||||
|
## Our pledge
|
||||||
|
|
||||||
|
We want participation in this project to be a harassment-free experience for everyone, regardless
|
||||||
|
of age, body size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||||
|
level of experience, education, socioeconomic status, nationality, personal appearance, race,
|
||||||
|
religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Standards
|
||||||
|
|
||||||
|
Examples of behavior that helps:
|
||||||
|
|
||||||
|
- being respectful of differing viewpoints and experience levels,
|
||||||
|
- giving and accepting constructive feedback gracefully,
|
||||||
|
- focusing on what is best for the project and its users.
|
||||||
|
|
||||||
|
Examples of unacceptable behavior:
|
||||||
|
|
||||||
|
- sexualized language or imagery, and unwelcome sexual attention,
|
||||||
|
- trolling, insulting or derogatory comments, and personal or political attacks,
|
||||||
|
- public or private harassment,
|
||||||
|
- publishing others' private information without explicit permission.
|
||||||
|
|
||||||
|
## Scope and enforcement
|
||||||
|
|
||||||
|
This applies in all project spaces — issues, pull requests, discussions — and when representing the
|
||||||
|
project in public. Report unacceptable behavior to <contact@narl.io>. Reports are handled
|
||||||
|
confidentially. Maintainers may edit, remove, or reject contributions that violate this document,
|
||||||
|
and may temporarily or permanently ban any contributor for behavior they deem inappropriate.
|
||||||
|
|
||||||
|
Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thanks for taking the time. This is an unofficial Proton Drive client that people trust with real
|
||||||
|
files, so the bar for changes that touch the filesystem, the sync engine, or the database is
|
||||||
|
deliberately high — everything else is ordinary Rust review.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- Read [`AGENTS.md`](AGENTS.md) for repository conventions and
|
||||||
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for how the daemon, front ends, and SDK fit
|
||||||
|
together.
|
||||||
|
- Check [`docs/BUGS.md`](docs/BUGS.md) — it is the authoritative issue ledger, and your problem may
|
||||||
|
already be tracked there with context.
|
||||||
|
- For anything larger than a fix, open an issue first so the design can be discussed before you
|
||||||
|
spend the time.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
|
cd proton-drive-linux
|
||||||
|
cargo build --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI and FUSE targets need the GTK4, libadwaita, WebKitGTK 6, libsecret, D-Bus, and FUSE3
|
||||||
|
development packages listed in the [README](README.md#prerequisites). Minimum supported Rust
|
||||||
|
version is 1.96.
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
CI runs exactly these, and they must pass with zero warnings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
scripts/fuse-acceptance.sh --offline-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes to the FUSE layer, sync, or the pending-operation queue should also be exercised against a
|
||||||
|
real mount before review — see [`docs/TESTING.md`](docs/TESTING.md). Live runs are destructive:
|
||||||
|
use a dedicated test account, never one holding data you cannot lose.
|
||||||
|
|
||||||
|
## Making changes
|
||||||
|
|
||||||
|
- **Keep shared logic in `pdfs-core`.** The CLI and GUI never touch the database or the network;
|
||||||
|
they speak the control protocol to the daemon.
|
||||||
|
- **The SQLite schema is forward-only.** Never edit a shipped migration — add a new one and bump
|
||||||
|
`SCHEMA_VERSION` in `crates/pdfs-core/src/db/migrations.rs`.
|
||||||
|
- **Typed errors and `?` over panics** on runtime paths. Blocking filesystem, keyring, or
|
||||||
|
control-socket work stays off GTK's main thread.
|
||||||
|
- **`staging/` and `recovery/` may hold the only copy of user data.** Nothing may clear or purge
|
||||||
|
them opportunistically.
|
||||||
|
|
||||||
|
## Commits and pull requests
|
||||||
|
|
||||||
|
Commits follow scoped [Conventional Commits](https://www.conventionalcommits.org), e.g.
|
||||||
|
`fix(fuse,core): keep truncate composable with queued revisions`.
|
||||||
|
|
||||||
|
A pull request should state:
|
||||||
|
|
||||||
|
- the user-visible effect and which crates it touches,
|
||||||
|
- the `docs/BUGS.md` entries it fixes or affects,
|
||||||
|
- the commands you ran to verify it,
|
||||||
|
- screenshots for GTK changes,
|
||||||
|
- and, explicitly, any migration, recovery, data-loss, or packaging implications.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Do not open a public issue for a vulnerability — see [SECURITY.md](SECURITY.md).
|
||||||
Generated
+548
-791
File diff suppressed because it is too large.
Load diff
+24
-8
@@ -3,15 +3,20 @@ resolver = "2"
|
|||||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.8.2"
|
version = "2.0.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||||
rust-version = "1.96"
|
rust-version = "1.96"
|
||||||
|
repository = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
homepage = "https://github.com/narrrl/proton-drive-linux"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["proton", "drive", "fuse", "sync", "linux"]
|
||||||
|
categories = ["filesystem", "command-line-utilities"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
proton-sdk = { version = "0.6.0" }
|
proton-sdk = { version = "0.6.6" }
|
||||||
proton-drive-rs = { version = "0.6.0" }
|
proton-drive-rs = { version = "0.6.6" }
|
||||||
|
|
||||||
# Internal crates.
|
# Internal crates.
|
||||||
pdfs-core = { path = "crates/pdfs-core" }
|
pdfs-core = { path = "crates/pdfs-core" }
|
||||||
@@ -20,6 +25,7 @@ pdfs-fuse = { path = "crates/pdfs-fuse" }
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time", "signal"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
base64 = "0.23"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
# Non-poisoning mutexes. A handler that panics while holding a lock must not
|
||||||
@@ -29,18 +35,28 @@ anyhow = "1"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
directories = "5"
|
directories = "6"
|
||||||
ignore = "0.4"
|
ignore = "0.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.11"
|
||||||
|
# Google Takeout exports are zip sets; the importer reads their central
|
||||||
|
# directories and streams entries out without extracting to disk first.
|
||||||
|
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||||
|
# Proton's photo duplicate detection is keyed on the plaintext SHA-1, so an
|
||||||
|
# import has to speak SHA-1 whatever its age.
|
||||||
|
sha1 = "0.11"
|
||||||
# Thumbnail generation for photos the server has none for. Decoders only for the
|
# Thumbnail generation for photos the server has none for. Decoders only for the
|
||||||
# formats a camera or phone actually produces — the rest of the crate's codecs
|
# formats a camera or phone actually produces — the rest of the crate's codecs
|
||||||
# would be dead weight in the daemon.
|
# would be dead weight in the daemon.
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "tiff", "bmp", "rayon"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
|
# keyring 4 split into `keyring-core` plus per-platform stores. The libdbus
|
||||||
|
# Secret Service store is the one keyring 3's `sync-secret-service` wrapped, and
|
||||||
|
# with no `target` it matches items v3 wrote, so existing logins survive.
|
||||||
|
keyring-core = "1"
|
||||||
|
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
fuser = "0.17"
|
fuser = "0.18"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
@@ -1,17 +1,75 @@
|
|||||||
# Proton Drive client for Linux (unofficial)
|
<div align="center">
|
||||||
|
|
||||||
A fast, unofficial Proton Drive client for Linux. This client features an advanced files-on-demand FUSE virtual mount with block-level caching, a command-line interface (CLI), and a fully non-blocking GTK4 desktop application with system tray integration.
|
<img src="images/logo.svg" alt="" width="112" height="112">
|
||||||
|
|
||||||
|
# Proton Drive for Linux
|
||||||
|
|
||||||
|
**Files-on-demand Proton Drive for the Linux desktop** — a FUSE virtual mount with block-level
|
||||||
|
caching, a scriptable CLI, and a native GTK4 app with tray and search launcher.
|
||||||
|
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/narrrl/proton-drive-linux/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://www.rust-lang.org)
|
||||||
|
[](#prerequisites)
|
||||||
|
|
||||||
|
<img src="images/files.png" alt="The GTK4 file browser" width="820">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This is an **unofficial**, community-built client. It is not affiliated with, endorsed by, or
|
||||||
|
> supported by Proton AG. "Proton" and "Proton Drive" are trademarks of Proton AG, used here only
|
||||||
|
> to describe what the software talks to. Use a test account first, and read
|
||||||
|
> [`docs/RECOVERY.md`](docs/RECOVERY.md) before trusting it with data you cannot lose.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian / Ubuntu
|
||||||
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install ./proton-drive-linux-*.rpm
|
||||||
|
|
||||||
|
# Arch Linux (AUR)
|
||||||
|
paru -S proton-drive-for-linux # or -git / -bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Packages for each release are attached to the [latest
|
||||||
|
release](https://github.com/narrrl/proton-drive-linux/releases/latest); see
|
||||||
|
[Installation & Packages](#installation--packages) for details and
|
||||||
|
[Building from Source](#building-from-source) to compile it yourself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs login # sign in (SRP + 2FA, credentials go to the system keyring)
|
||||||
|
pdfs status # mount state, queued writes, cache usage
|
||||||
|
pdfs-app # the desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Features](#features) · [Quick search](#quick-search-and-media-opening) · [Opening files](#choosing-how-files-open)
|
||||||
|
- [Selective sync](#selective-sync-pdfsignore) · [Diagnostics](#diagnostics--maintenance) · [Scripting](#scripting---json)
|
||||||
|
- [Performance](#performance--caching) · [Filesystem safety](#filesystem-safety) · [Screenshots](#screenshots)
|
||||||
|
- [Prerequisites](#prerequisites) · [Building](#building-from-source) · [Packages](#installation--packages) · [Releases](#automated-releases-cicd)
|
||||||
|
|
||||||
|
**Documentation:** [Architecture](docs/ARCHITECTURE.md) · [Testing](docs/TESTING.md) ·
|
||||||
|
[Recovery](docs/RECOVERY.md) · [Development](docs/DEVELOPMENT.md) ·
|
||||||
|
[Known issues](docs/BUGS.md) · [Changelog](docs/CHANGELOG.md) ·
|
||||||
|
[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
- **Files-on-Demand (FUSE)**: Mount your Proton Drive as a virtual filesystem where files are downloaded only when opened, utilizing smart block-level caching and disk-backed writes.
|
||||||
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
- **Command-Line Interface (CLI)**: Manage your drive, authenticate, and monitor sync status directly from the terminal.
|
||||||
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
- **Non-Blocking GTK4 Desktop App**: Browse files, manage pins, configure options, and monitor active transfers through a modern, native GUI with a fully non-blocking asynchronous main loop.
|
||||||
|
- **File Thumbnails**: Browse locally cached image previews in Files, Shared, and Trash, including embedded previews from camera RAW files. Build a whole folder tree from the Files toolbar and resize the grid from the status bar.
|
||||||
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
- **Shared Links & Invites**: Browse files shared with you by other users, and view/manage your own public shared links directly in the GUI.
|
||||||
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
- **Local Backup (Computers)**: Sync and back up local directories (like Downloads, Documents, Pictures, etc.) directly to your Proton Drive account.
|
||||||
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
- **Locations**: One page (and `pdfs locations`) listing every place Proton Drive occupies on this machine — the main mount plus each backed-up folder — with its mode, sync state, and whether it is read-only. Switching a folder between a full local copy and on-demand happens here.
|
||||||
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
- **System Tray Integration**: Background indicator for status monitoring, quick actions, and fast sync controls.
|
||||||
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey.
|
- **Unified Search Launcher (HUD)**: A resident Google Drive-style launcher (`pdfs-prompt`) that searches Proton Drive and local files together, ranks the best matches, tolerates abbreviations and typos, and is ideal for a system-wide hotkey. It can also present the same search in your own launcher (`--dmenu`) or, searching live as you type, in `fzf` (`--fzf`).
|
||||||
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
- **Secure Credential Storage**: Integrates with the system Secret Service (GNOME Keyring, KWallet, etc.) with smart in-memory credential caching to avoid UI thread blockages.
|
||||||
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
- **Proton Photos Support**: Access your Proton Photos timeline, view thumbnails, and download backed-up media natively (available in the GUI as a navigation tab and via the CLI).
|
||||||
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
- **File Version History**: Every revision Proton Drive still holds for a file, from the browser's details pane (**Versions**) or `pdfs versions list|restore|save|rm`. Restoring is server-side — no re-upload — and an old version can be written out to a local file without touching the live one.
|
||||||
@@ -66,6 +124,51 @@ launcher command, set them in `config.json`:
|
|||||||
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
anywhere in `menu` is replaced by the prompt text; without one, the launcher's
|
||||||
own prompt flag is appended.
|
own prompt flag is appended.
|
||||||
|
|
||||||
|
### Live launcher search (fzf)
|
||||||
|
|
||||||
|
The two-step above exists because no dmenu-style launcher can call back for new
|
||||||
|
results per keystroke. `fzf` can, so `pdfs-prompt --fzf` searches as you type,
|
||||||
|
exactly like the built-in window:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pdfs-prompt --fzf # pinned files, then live results as you type
|
||||||
|
pdfs-prompt --fzf --query invoice # start on a search
|
||||||
|
```
|
||||||
|
|
||||||
|
Every keystroke re-queries the daemon (debounced, like the GTK prompt) and fzf's
|
||||||
|
own matcher is switched off, so the ordering you see is the daemon's ranking —
|
||||||
|
abbreviations and typos included, which a launcher filtering a fixed list cannot
|
||||||
|
do. Enter opens; Escape closes.
|
||||||
|
|
||||||
|
fzf is a terminal program, so when it is started without one — from a WM
|
||||||
|
keybinding — it spawns a terminal for itself: foot, ghostty, kitty, alacritty,
|
||||||
|
wezterm or xterm, whichever is installed. Each is launched with the app-id/class
|
||||||
|
`pdfs-prompt`, so one window rule floats it whichever terminal you have. In
|
||||||
|
Hyprland:
|
||||||
|
|
||||||
|
```
|
||||||
|
windowrulev2 = float, class:^(pdfs-prompt)$
|
||||||
|
windowrulev2 = size 900 500, class:^(pdfs-prompt)$
|
||||||
|
windowrulev2 = center, class:^(pdfs-prompt)$
|
||||||
|
bind = SUPER, space, exec, pdfs-prompt --fzf
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it as the default front end, and pick the terminal explicitly, in
|
||||||
|
`config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": {
|
||||||
|
"mode": "fzf",
|
||||||
|
"terminal": ["foot", "--app-id=pdfs-prompt", "--"],
|
||||||
|
"menu_limit": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `{cmd}` token anywhere in `terminal` is replaced by the command to run;
|
||||||
|
without one it is appended, which is what every detected terminal expects.
|
||||||
|
|
||||||
## Choosing How Files Open
|
## Choosing How Files Open
|
||||||
|
|
||||||
By default every result is handed to `xdg-open`. An `open_with` block in
|
By default every result is handed to `xdg-open`. An `open_with` block in
|
||||||
@@ -270,19 +373,21 @@ sudo apt-get install -y \
|
|||||||
libadwaita-1-dev \
|
libadwaita-1-dev \
|
||||||
libwebkitgtk-6.0-dev \
|
libwebkitgtk-6.0-dev \
|
||||||
libsecret-1-dev \
|
libsecret-1-dev \
|
||||||
libdbus-1-dev
|
libdbus-1-dev \
|
||||||
|
libimage-exiftool-perl
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0
|
sudo pacman -S --needed pkgconf fuse3 gtk4 libadwaita libsecret dbus webkitgtk-6.0 perl-image-exiftool
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fedora (44+)
|
### Fedora (44+)
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install -y \
|
sudo dnf install -y \
|
||||||
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
pkgconf-pkg-config fuse3-devel gtk4-devel libadwaita-devel \
|
||||||
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel cargo rust
|
webkitgtk6.0-devel libsecret-devel dbus-devel glib2-devel \
|
||||||
|
perl-Image-ExifTool cargo rust
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime extras (pick your desktop):
|
Runtime extras (pick your desktop):
|
||||||
@@ -302,7 +407,7 @@ Ensure you have Rust and Cargo installed (minimum supported Rust version is 1.96
|
|||||||
|
|
||||||
1. Clone the repository and navigate into the project directory:
|
1. Clone the repository and navigate into the project directory:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/narl/proton-drive-linux.git
|
git clone https://github.com/narrrl/proton-drive-linux.git
|
||||||
cd proton-drive-linux
|
cd proton-drive-linux
|
||||||
```
|
```
|
||||||
2. Build the workspace in release mode:
|
2. Build the workspace in release mode:
|
||||||
@@ -326,8 +431,24 @@ Install the debian package via `dpkg` or `apt`:
|
|||||||
sudo apt install ./proton-drive-linux_*.deb
|
sudo apt install ./proton-drive-linux_*.deb
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Arch Linux
|
### 2. Arch Linux (AUR)
|
||||||
A local `PKGBUILD` is available under the `packaging/` directory. You can build and install it using:
|
Three AUR packages cover the usual choices; they conflict with each other, so
|
||||||
|
install one:
|
||||||
|
|
||||||
|
| Package | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| [`proton-drive-for-linux`](https://aur.archlinux.org/packages/proton-drive-for-linux) | builds the latest tagged release from source |
|
||||||
|
| [`proton-drive-for-linux-git`](https://aur.archlinux.org/packages/proton-drive-for-linux-git) | builds `main` from git |
|
||||||
|
| [`proton-drive-for-linux-bin`](https://aur.archlinux.org/packages/proton-drive-for-linux-bin) | installs the release binaries, no compile |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
paru -S proton-drive-for-linux # or any AUR helper
|
||||||
|
```
|
||||||
|
|
||||||
|
The package sources live in `packaging/aur/`; see
|
||||||
|
[`packaging/aur/README.md`](packaging/aur/README.md) for how they are updated and
|
||||||
|
published. A local `PKGBUILD` that compiles the working tree in place is still
|
||||||
|
available for development builds:
|
||||||
```bash
|
```bash
|
||||||
cd packaging && makepkg -fi
|
cd packaging && makepkg -fi
|
||||||
```
|
```
|
||||||
@@ -372,6 +493,15 @@ This project has a GitHub Actions CI workflow configured under `.github/workflow
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Bug reports, packaging fixes, and patches are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for
|
||||||
|
the workflow and the quality gates CI enforces, and [SECURITY.md](SECURITY.md) for reporting a
|
||||||
|
vulnerability privately.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [MIT License](LICENSE).
|
Released under the [MIT License](LICENSE).
|
||||||
|
|
||||||
|
Proton Drive is a service of Proton AG. This project is an independent client and carries no
|
||||||
|
affiliation with or endorsement from Proton AG.
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
Fixes land on the latest release. Please reproduce on the most recent version before reporting.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Do not open a public issue.** Report privately through GitHub's
|
||||||
|
[private vulnerability reporting](https://github.com/narrrl/proton-drive-linux/security/advisories/new),
|
||||||
|
or by email to <contact@narl.io>.
|
||||||
|
|
||||||
|
Please include the version (`pdfs --version`), your distribution and desktop environment, what an
|
||||||
|
attacker gains, and a reproduction if you have one. You can expect an acknowledgement within a few
|
||||||
|
days; a fix ships in the next release, and you are credited in the advisory unless you ask
|
||||||
|
otherwise.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This client holds Proton account credentials and decrypted file content on the local machine, so
|
||||||
|
the interesting boundaries are:
|
||||||
|
|
||||||
|
- **Credentials.** Sessions and the mailbox password live in the system Secret Service. Anything
|
||||||
|
that writes them elsewhere — logs, temp files, crash dumps — is a vulnerability.
|
||||||
|
- **The control socket.** Anything that can connect to `control.sock` has the daemon's
|
||||||
|
authenticated session. It is `0600`, and the state, cache, and config directories must be real,
|
||||||
|
current-user-owned `0700` directories; the daemon fails closed otherwise. Reports that defeat
|
||||||
|
those checks are in scope.
|
||||||
|
- **Local cache and staging.** Decrypted block content and staged writes on disk.
|
||||||
|
- **Cryptography.** Key derivation, signature handling, and anything that could publish plaintext
|
||||||
|
or the wrong key material to Proton.
|
||||||
|
|
||||||
|
Out of scope: vulnerabilities in Proton's own service (report those to Proton AG), and issues that
|
||||||
|
require an attacker who already has your user account on the machine.
|
||||||
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "CLI for the Proton Drive Linux client"
|
description = "CLI for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
+1033
-35
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,8 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "Core auth/session/config for the Proton Drive Linux client"
|
description = "Core auth/session/config for the Proton Drive Linux client"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -18,8 +20,11 @@ tracing.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
ignore.workspace = true
|
ignore.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
keyring.workspace = true
|
keyring-core.workspace = true
|
||||||
|
dbus-secret-service-keyring-store.workspace = true
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
//! interactive step; the refresh token auto-renews via the HTTP client's 401
|
||||||
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
//! path, so no fresh 2FA is required until the refresh token itself expires.
|
||||||
|
|
||||||
use keyring::Entry;
|
use keyring_core::Entry;
|
||||||
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
use proton_drive_rs::{KeySalt, ProtonDriveClient};
|
||||||
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
use proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||||
use proton_sdk::cache::EncryptedCacheRepository;
|
use proton_sdk::cache::EncryptedCacheRepository;
|
||||||
@@ -81,6 +81,11 @@ fn client_config() -> ProtonClientConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn keyring_entry() -> Result<Entry> {
|
fn keyring_entry() -> Result<Entry> {
|
||||||
|
// Installed lazily rather than once at startup so a session bus that was not
|
||||||
|
// up yet on the first attempt is retried on the next one.
|
||||||
|
if keyring_core::get_default_store().is_none() {
|
||||||
|
keyring_core::set_default_store(dbus_secret_service_keyring_store::Store::new()?);
|
||||||
|
}
|
||||||
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +220,7 @@ pub fn load() -> Result<StoredSession> {
|
|||||||
let entry = keyring_entry()?;
|
let entry = keyring_entry()?;
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(json) => Ok(serde_json::from_str(&json)?),
|
Ok(json) => Ok(serde_json::from_str(&json)?),
|
||||||
Err(keyring::Error::NoEntry) => Err(Error::NotLoggedIn),
|
Err(keyring_core::Error::NoEntry) => Err(Error::NotLoggedIn),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +243,7 @@ pub fn logout() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match keyring_entry()?.delete_credential() {
|
match keyring_entry()?.delete_credential() {
|
||||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()),
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -297,6 +298,10 @@ pub struct Pin {
|
|||||||
/// which is why it is defaulted.
|
/// which is why it is defaulted.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub is_dir: Option<bool>,
|
pub is_dir: Option<bool>,
|
||||||
|
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||||
|
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||||
@@ -415,6 +420,10 @@ pub struct ContentCache {
|
|||||||
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
|
||||||
/// of `content_dir` so the budget scan never sees thumbnail files.
|
/// of `content_dir` so the budget scan never sees thumbnail files.
|
||||||
thumb_dir: PathBuf,
|
thumb_dir: PathBuf,
|
||||||
|
/// Owner-protected scratch space for decrypted camera RAWs passed to
|
||||||
|
/// exiftool. Files are removed on drop and swept on the next cache open after
|
||||||
|
/// an unclean shutdown.
|
||||||
|
raw_thumb_dir: PathBuf,
|
||||||
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
|
||||||
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
|
||||||
/// sees them; blocks carry their own LRU budget.
|
/// sees them; blocks carry their own LRU budget.
|
||||||
@@ -507,6 +516,19 @@ impl ContentCache {
|
|||||||
std::fs::create_dir_all(&content_dir)?;
|
std::fs::create_dir_all(&content_dir)?;
|
||||||
let thumb_dir = content_dir.join("thumbs");
|
let thumb_dir = content_dir.join("thumbs");
|
||||||
std::fs::create_dir_all(&thumb_dir)?;
|
std::fs::create_dir_all(&thumb_dir)?;
|
||||||
|
let raw_thumb_dir = content_dir.join("raw-thumbnails");
|
||||||
|
std::fs::create_dir_all(&raw_thumb_dir)?;
|
||||||
|
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_string_lossy().starts_with("pdfs-raw-")
|
||||||
|
&& entry.file_type().is_ok_and(|kind| kind.is_file())
|
||||||
|
{
|
||||||
|
let _ = std::fs::remove_file(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let block_dir = content_dir.join("blocks");
|
let block_dir = content_dir.join("blocks");
|
||||||
std::fs::create_dir_all(&block_dir)?;
|
std::fs::create_dir_all(&block_dir)?;
|
||||||
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
// Scratch holds disk-backed write buffers. A previous run's leftovers are
|
||||||
@@ -530,6 +552,7 @@ impl ContentCache {
|
|||||||
let cache = Self {
|
let cache = Self {
|
||||||
content_dir,
|
content_dir,
|
||||||
thumb_dir,
|
thumb_dir,
|
||||||
|
raw_thumb_dir,
|
||||||
block_dir,
|
block_dir,
|
||||||
scratch_dir,
|
scratch_dir,
|
||||||
staging_dir,
|
staging_dir,
|
||||||
@@ -1666,6 +1689,12 @@ impl ContentCache {
|
|||||||
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owner-protected transient directory used to stage decrypted camera RAWs
|
||||||
|
/// for metadata helpers. Callers still remove individual files promptly.
|
||||||
|
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
|
||||||
|
&self.raw_thumb_dir
|
||||||
|
}
|
||||||
|
|
||||||
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
|
||||||
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
/// Validated by node `mtime` alone: a new revision (which is what changes the
|
||||||
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
|
||||||
@@ -1829,6 +1858,7 @@ impl ContentCache {
|
|||||||
path: row.path,
|
path: row.path,
|
||||||
recursive: row.recursive,
|
recursive: row.recursive,
|
||||||
is_dir: row.is_dir,
|
is_dir: row.is_dir,
|
||||||
|
cached: false,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ pub struct AppConfig {
|
|||||||
/// writes an explicit value here. Defaulted for configs predating the field.
|
/// writes an explicit value here. Defaulted for configs predating the field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_budget: Option<u64>,
|
pub cache_budget: Option<u64>,
|
||||||
|
/// Upload cap in bytes per second shared by every upload. `None` or `0`
|
||||||
|
/// means no cap. Defaulted for configs predating the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub upload_limit: Option<u64>,
|
||||||
|
/// Download cap in bytes per second shared by every download, FUSE reads
|
||||||
|
/// included. `None` or `0` means no cap. Defaulted for configs predating
|
||||||
|
/// the field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub download_limit: Option<u64>,
|
||||||
/// Mountpoint the daemon mounts at. `None` means
|
/// Mountpoint the daemon mounts at. `None` means
|
||||||
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
/// [`AppDirs::default_mountpoint`]; the Settings page writes an explicit
|
||||||
/// path here. Defaulted for configs predating the field.
|
/// path here. Defaulted for configs predating the field.
|
||||||
@@ -119,6 +128,73 @@ pub struct AppConfig {
|
|||||||
/// field.
|
/// field.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prompt: Option<PromptConfig>,
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The My Files page's view choices, remembered between runs.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct FilesView {
|
||||||
|
/// Columns instead of the icon grid.
|
||||||
|
pub list: bool,
|
||||||
|
pub sort: FileSort,
|
||||||
|
pub descending: bool,
|
||||||
|
/// Folders ahead of files, whatever the sort.
|
||||||
|
pub folders_first: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FilesView {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
list: false,
|
||||||
|
sort: FileSort::Name,
|
||||||
|
descending: false,
|
||||||
|
folders_first: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key a My Files listing is ordered by.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FileSort {
|
||||||
|
#[default]
|
||||||
|
Name,
|
||||||
|
Size,
|
||||||
|
Modified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSort {
|
||||||
|
/// The name used in the desktop app's actions and the config file.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FileSort::Name => "name",
|
||||||
|
FileSort::Size => "size",
|
||||||
|
FileSort::Modified => "modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"name" => Some(FileSort::Name),
|
||||||
|
"size" => Some(FileSort::Size),
|
||||||
|
"modified" => Some(FileSort::Modified),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -127,12 +203,17 @@ impl Default for AppConfig {
|
|||||||
app_version: APP_VERSION.to_string(),
|
app_version: APP_VERSION.to_string(),
|
||||||
user_agent: USER_AGENT.to_string(),
|
user_agent: USER_AGENT.to_string(),
|
||||||
cache_budget: None,
|
cache_budget: None,
|
||||||
|
upload_limit: None,
|
||||||
|
download_limit: None,
|
||||||
mountpoint: None,
|
mountpoint: None,
|
||||||
ignore_patterns: None,
|
ignore_patterns: None,
|
||||||
device_uid: None,
|
device_uid: None,
|
||||||
conflict_sweep: None,
|
conflict_sweep: None,
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_accent: None,
|
||||||
|
files_view: FilesView::default(),
|
||||||
|
tray_hidden: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,17 +552,42 @@ mod tests {
|
|||||||
app_version: "external-drive-test-client@1.0.0".to_string(),
|
app_version: "external-drive-test-client@1.0.0".to_string(),
|
||||||
user_agent: "test-agent/1.0".to_string(),
|
user_agent: "test-agent/1.0".to_string(),
|
||||||
cache_budget: Some(1234),
|
cache_budget: Some(1234),
|
||||||
|
upload_limit: Some(500_000),
|
||||||
|
download_limit: None,
|
||||||
mountpoint: Some("/tmp/x".to_string()),
|
mountpoint: Some("/tmp/x".to_string()),
|
||||||
ignore_patterns: Some(vec!["build/".to_string()]),
|
ignore_patterns: Some(vec!["build/".to_string()]),
|
||||||
device_uid: Some("dev-uid".to_string()),
|
device_uid: Some("dev-uid".to_string()),
|
||||||
conflict_sweep: Some(SweepMode::Enforce),
|
conflict_sweep: Some(SweepMode::Enforce),
|
||||||
open_with: None,
|
open_with: None,
|
||||||
prompt: None,
|
prompt: None,
|
||||||
|
proton_accent: Some(true),
|
||||||
|
files_view: FilesView {
|
||||||
|
list: true,
|
||||||
|
sort: FileSort::Modified,
|
||||||
|
descending: true,
|
||||||
|
folders_first: false,
|
||||||
|
},
|
||||||
|
tray_hidden: true,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
assert_eq!(decoded.app_version, "external-drive-test-client@1.0.0");
|
||||||
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
assert_eq!(decoded.user_agent, "test-agent/1.0");
|
||||||
|
assert_eq!(decoded.proton_accent, Some(true));
|
||||||
|
assert!(decoded.tray_hidden);
|
||||||
|
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]
|
#[test]
|
||||||
@@ -554,6 +660,7 @@ mod tests {
|
|||||||
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
let json = r#"{"app_version":"v","user_agent":"u"}"#;
|
||||||
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
let decoded: AppConfig = serde_json::from_str(json).unwrap();
|
||||||
assert!(decoded.ignore_patterns.is_none());
|
assert!(decoded.ignore_patterns.is_none());
|
||||||
|
assert!(!decoded.tray_hidden, "an old config keeps the tray icon");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decoded.resolved_ignore_patterns().len(),
|
decoded.resolved_ignore_patterns().len(),
|
||||||
DEFAULT_IGNORE_PATTERNS.len(),
|
DEFAULT_IGNORE_PATTERNS.len(),
|
||||||
|
|||||||
+748
-12
File diff suppressed because it is too large.
Load diff
@@ -178,16 +178,33 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
|
||||||
|
/// which has to put the re-uploaded copy back into the albums the original
|
||||||
|
/// was in.
|
||||||
|
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([uid], |r| r.get(0))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many photos of `album_uid` are persisted.
|
/// How many photos of `album_uid` are persisted.
|
||||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
@@ -222,10 +239,14 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 28;
|
pub(super) const SCHEMA_VERSION: i64 = 31;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -218,6 +218,58 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V28)?;
|
tx.execute_batch(MIGRATION_V28)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 29 {
|
||||||
|
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
|
||||||
|
// and a fixture rewound from the current schema already has the
|
||||||
|
// column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V29)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 30 {
|
||||||
|
// Same guards as V26-V29: the column may already be there on a
|
||||||
|
// fixture rewound from the current schema, and a very old fixture
|
||||||
|
// may not have `photos` at all.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V30)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 31 {
|
||||||
|
// Same guards as V26-V30.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -632,7 +684,7 @@ CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
|||||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||||
";
|
";
|
||||||
|
|
||||||
/// V20: whether a photo is a favourite.
|
/// V20: whether a photo is a favorite.
|
||||||
///
|
///
|
||||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||||
@@ -846,3 +898,57 @@ ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT
|
|||||||
const MIGRATION_V28: &str = "
|
const MIGRATION_V28: &str = "
|
||||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||||
|
/// restore can reason about the shape of what it is putting back.
|
||||||
|
///
|
||||||
|
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||||
|
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||||
|
/// one of those in isolation is what the parent link fixes — a child restored
|
||||||
|
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||||
|
/// without its separately-trashed contents comes back empty.
|
||||||
|
///
|
||||||
|
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||||
|
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||||
|
/// did before.
|
||||||
|
const MIGRATION_V29: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||||
|
/// who took it, so the timeline records what ties them together.
|
||||||
|
///
|
||||||
|
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||||
|
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||||
|
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||||
|
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||||
|
/// its own uid.
|
||||||
|
///
|
||||||
|
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||||
|
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||||
|
/// the gallery behaved before this column existed.
|
||||||
|
const MIGRATION_V30: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v31: which photos a refresh still has to read from the server.
|
||||||
|
///
|
||||||
|
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||||
|
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||||
|
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||||
|
/// records that the work was already done, so a refresh only reads the photos it
|
||||||
|
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||||
|
///
|
||||||
|
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||||
|
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||||
|
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||||
|
/// ever learned about.
|
||||||
|
const MIGRATION_V31: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
|
";
|
||||||
@@ -56,8 +56,8 @@ pub use local::LocalFileHit;
|
|||||||
pub use maintenance::{DbStats, VacuumOutcome};
|
pub use maintenance::{DbStats, VacuumOutcome};
|
||||||
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
pub use nodes::{PublishedSharedRoot, SearchHit, StoredNode};
|
||||||
pub use ops::{
|
pub use ops::{
|
||||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
AttachedBlob, FAILING_ATTEMPTS, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION,
|
||||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
OP_TRASH, PARK_EXPIRY_MS, PARK_UNTIL, PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||||
};
|
};
|
||||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||||
pub use pins::PinRow;
|
pub use pins::PinRow;
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ impl Db {
|
|||||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(mounts)
|
Ok(mounts)
|
||||||
|
|||||||
@@ -355,6 +355,22 @@ impl Db {
|
|||||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||||
/// nothing below 3 chars.
|
/// nothing below 3 chars.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||||
|
self.search_in(query, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`search`](Self::search) restricted to the subtree under the
|
||||||
|
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||||
|
/// everything.
|
||||||
|
pub fn search_in(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
scope: Option<&str>,
|
||||||
|
) -> Result<Vec<SearchHit>> {
|
||||||
|
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||||
|
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||||
|
// checked once their path is walked below.
|
||||||
|
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -365,9 +381,10 @@ impl Db {
|
|||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid, path FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -380,9 +397,10 @@ impl Db {
|
|||||||
"SELECT n.node_json, n.uid, n.path
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
@@ -392,6 +410,13 @@ impl Db {
|
|||||||
Some(path) => path,
|
Some(path) => path,
|
||||||
None => walk_path_of(&conn, &uid)?,
|
None => walk_path_of(&conn, &uid)?,
|
||||||
};
|
};
|
||||||
|
if let Some(scope) = scope
|
||||||
|
&& !path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ pub const LOCAL_VOLUME: &str = "local";
|
|||||||
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||||
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
|
/// How long a create may stay parked before the park is treated as permanent
|
||||||
|
/// and the bytes are let through anyway (ms).
|
||||||
|
///
|
||||||
|
/// The park has exactly one exit: a rename from the transient name to the
|
||||||
|
/// finished one. Every writer that reaches that rename does so in seconds — an
|
||||||
|
/// atomic-write scratch file immediately, a download when it completes. A park
|
||||||
|
/// still standing an hour later belongs to a writer that will never perform
|
||||||
|
/// that rename: an editor that deletes its swap file instead, a download the
|
||||||
|
/// user abandoned, or a file that simply *is* named `report.tmp` and is never
|
||||||
|
/// going to be called anything else.
|
||||||
|
///
|
||||||
|
/// Before this existed such a row sat at [`PARK_UNTIL`] for the life of the
|
||||||
|
/// database, holding the only copy of the file's bytes in `staging/` and
|
||||||
|
/// counting against the queue while promising progress that could not come. The
|
||||||
|
/// sweep un-parks it instead of dropping it: bytes the user can see in the mount
|
||||||
|
/// are bytes the user expects on Drive, and invariant 1 of the drain (a staged
|
||||||
|
/// blob is dropped only once its op has landed) admits no other answer.
|
||||||
|
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// A mutation that has been accepted locally but not yet performed against the
|
||||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||||
///
|
///
|
||||||
@@ -661,6 +680,40 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every parked create/mkdir, oldest first, for the park sweep.
|
||||||
|
///
|
||||||
|
/// Parked rows are invisible to [`next_due_op`](Self::next_due_op) by
|
||||||
|
/// construction, so nothing else in the drain ever looks at them. This is
|
||||||
|
/// the one query that does.
|
||||||
|
pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3)
|
||||||
|
ORDER BY created_at",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many queued ops there are of each kind, for `Response::Status`.
|
/// How many queued ops there are of each kind, for `Response::Status`.
|
||||||
///
|
///
|
||||||
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
||||||
@@ -719,6 +772,31 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
|
||||||
|
/// left alone: they wait for a rename, not for time, and un-parking one would
|
||||||
|
/// upload a file its writer has not finished. Returns whether a row changed.
|
||||||
|
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
|
||||||
|
params![id, now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||||
|
/// least once. Returns how many rows changed.
|
||||||
|
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?1
|
||||||
|
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||||
|
params![now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||||
/// the staged bytes are still the only copy of the user's write.
|
/// the staged bytes are still the only copy of the user's write.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -34,67 +34,347 @@ pub struct StoredPhoto {
|
|||||||
pub kind: crate::control::PhotoKind,
|
pub kind: crate::control::PhotoKind,
|
||||||
/// Whether the photo carries Proton's `Favorite` tag.
|
/// Whether the photo carries Proton's `Favorite` tag.
|
||||||
pub favorite: bool,
|
pub favorite: bool,
|
||||||
|
/// How many photos this photo's group holds — one shot taken as RAW+JPEG is
|
||||||
|
/// two files and one group. `1` for a photo that stands on its own.
|
||||||
|
pub group_size: usize,
|
||||||
|
/// Whether any member of the group is a camera raw file. The grid badges
|
||||||
|
/// that, because the JPEG it shows is not the whole of what is stored.
|
||||||
|
pub has_raw: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
/// One row of a timeline replacement. Everything but the uid and the capture
|
||||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
/// time is "what this refresh learned": a `None` keeps whatever is already
|
||||||
/// `None` keeps whatever is already stored rather than clearing it.
|
/// stored rather than clearing it, because the timeline DTO carries only the uid
|
||||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
/// and the capture time and resolving the node can fail.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq)]
|
||||||
|
pub struct TimelineRow {
|
||||||
|
pub uid: String,
|
||||||
|
pub capture_time: i64,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub media_type: Option<String>,
|
||||||
|
pub favorite: Option<bool>,
|
||||||
|
/// The server's duplicate-detection hash for the plaintext.
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||||
|
/// sibling), when the server says it belongs to one.
|
||||||
|
pub main_uid: Option<String>,
|
||||||
|
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||||
|
/// makes every other field authoritative — including a `None` that means
|
||||||
|
/// "the server no longer reports one" — while `None` means the refresh
|
||||||
|
/// skipped this photo and whatever is stored must be kept.
|
||||||
|
pub resolved_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineRow {
|
||||||
|
/// A row carrying only what the timeline listing itself gives.
|
||||||
|
pub fn new(uid: impl Into<String>, capture_time: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
uid: uid.into(),
|
||||||
|
capture_time,
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a previous refresh learned about a photo and a new one must not lose.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct Learned {
|
||||||
|
name: Option<String>,
|
||||||
|
ratio: Option<f64>,
|
||||||
|
thumb_state: i64,
|
||||||
|
media_type: Option<String>,
|
||||||
|
favorite: bool,
|
||||||
|
content_hash: Option<String>,
|
||||||
|
main_uid: Option<String>,
|
||||||
|
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||||
|
/// does not make it look unresolved again.
|
||||||
|
resolved_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
|
||||||
|
/// Grouping by day rather than by exact second is deliberate: a camera writes
|
||||||
|
/// the RAW and the JPEG of one shot a moment apart, and two shots of the same
|
||||||
|
/// subject on different days are not one photo.
|
||||||
|
fn capture_day(conn: &rusqlite::Connection, capture_time: i64) -> Option<String> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT strftime('%Y-%m-%d', ?1, 'unixepoch', 'localtime')",
|
||||||
|
params![capture_time],
|
||||||
|
|r| r.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name without its extension, lowercased — `IMG_1234.CR2` and
|
||||||
|
/// `IMG_1234.JPG` share `img_1234` — with the camera's own part-of-a-shot
|
||||||
|
/// marker removed, so the two files a raw capture writes share a stem too.
|
||||||
|
fn name_stem(name: Option<&str>) -> Option<String> {
|
||||||
|
let name = name?;
|
||||||
|
let stem = match name.rsplit_once('.') {
|
||||||
|
Some((stem, _)) if !stem.is_empty() => stem,
|
||||||
|
_ => name,
|
||||||
|
};
|
||||||
|
let stem = stem.to_ascii_lowercase();
|
||||||
|
Some(strip_shot_marker(&stem).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a `.RAW-NN.ROLE` marker from a stem.
|
||||||
|
///
|
||||||
|
/// Pixel stores one raw capture as `PXL_20260919_000625670.RAW-01.COVER.jpg`
|
||||||
|
/// and `PXL_20260919_000625670.RAW-02.ORIGINAL.dng`: one shot, two files, two
|
||||||
|
/// stems that differ in exactly the part naming which file of the shot this is.
|
||||||
|
/// Cutting the marker off is what lets rule 2 see the pair. The check is narrow
|
||||||
|
/// on purpose — digits, then a single role word — so an ordinary name that
|
||||||
|
/// happens to contain `.raw-` keeps its stem.
|
||||||
|
fn strip_shot_marker(stem: &str) -> &str {
|
||||||
|
let Some(at) = stem.rfind(".raw-") else {
|
||||||
|
return stem;
|
||||||
|
};
|
||||||
|
let rest = &stem[at + ".raw-".len()..];
|
||||||
|
let Some((index, role)) = rest.split_once('.') else {
|
||||||
|
return stem;
|
||||||
|
};
|
||||||
|
let marked = !index.is_empty()
|
||||||
|
&& index.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
&& !role.is_empty()
|
||||||
|
&& role.bytes().all(|b| b.is_ascii_alphabetic());
|
||||||
|
if marked { &stem[..at] } else { stem }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the grouping pass: what rule 2 needs, plus what choosing a
|
||||||
|
/// representative needs.
|
||||||
|
struct Grouped {
|
||||||
|
uid: String,
|
||||||
|
kind: crate::control::PhotoKind,
|
||||||
|
main_uid: Option<String>,
|
||||||
|
day: Option<String>,
|
||||||
|
stem: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which group each photo belongs to, and which member of it the gallery shows.
|
||||||
|
///
|
||||||
|
/// Precedence, as agreed:
|
||||||
|
///
|
||||||
|
/// 1. The server's own relation — a photo with a `main_photo_uid` joins that
|
||||||
|
/// photo's group. The server knows about live photos and bursts; we do not
|
||||||
|
/// have to guess at them.
|
||||||
|
/// 2. Otherwise, same capture day **and** same file-name stem **and** one of the
|
||||||
|
/// two is a camera raw while the other is not. That is the RAW+JPEG pair a
|
||||||
|
/// camera writes, and the differing-kind clause keeps two JPEGs that happen
|
||||||
|
/// to share a name apart.
|
||||||
|
/// 3. Otherwise the photo is its own group.
|
||||||
|
///
|
||||||
|
/// The representative is the non-raw member when the group has one — a JPEG
|
||||||
|
/// decodes in milliseconds and is what the person expects to see — and the
|
||||||
|
/// earliest member in server order otherwise.
|
||||||
|
fn group_photos(entries: &[Grouped]) -> Vec<String> {
|
||||||
|
let mut parent: Vec<usize> = (0..entries.len()).collect();
|
||||||
|
fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||||
|
while parent[i] != i {
|
||||||
|
parent[i] = parent[parent[i]];
|
||||||
|
i = parent[i];
|
||||||
|
}
|
||||||
|
i
|
||||||
|
}
|
||||||
|
let union = |parent: &mut [usize], a: usize, b: usize| {
|
||||||
|
let (a, b) = (find(parent, a), find(parent, b));
|
||||||
|
if a != b {
|
||||||
|
parent[b] = a;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let index: HashMap<&str, usize> = entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| (e.uid.as_str(), i))
|
||||||
|
.collect();
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
if let Some(main) = entry.main_uid.as_deref()
|
||||||
|
&& let Some(&j) = index.get(main)
|
||||||
|
{
|
||||||
|
union(&mut parent, j, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2 needs only one pass: everything sharing a day and a stem lands in
|
||||||
|
// the same bucket, and the raw / non-raw split is checked pairwise there.
|
||||||
|
let mut by_name: HashMap<(&str, &str), Vec<usize>> = HashMap::new();
|
||||||
|
for (i, entry) in entries.iter().enumerate() {
|
||||||
|
if let (Some(day), Some(stem)) = (entry.day.as_deref(), entry.stem.as_deref()) {
|
||||||
|
by_name.entry((day, stem)).or_default().push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let is_raw = |i: usize| entries[i].kind == crate::control::PhotoKind::Raw;
|
||||||
|
for bucket in by_name.values() {
|
||||||
|
for (n, &i) in bucket.iter().enumerate() {
|
||||||
|
for &j in &bucket[n + 1..] {
|
||||||
|
if is_raw(i) != is_raw(j) {
|
||||||
|
union(&mut parent, i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Representative per root, then the key every member carries.
|
||||||
|
let mut representative: HashMap<usize, usize> = HashMap::new();
|
||||||
|
for i in 0..entries.len() {
|
||||||
|
let root = find(&mut parent, i);
|
||||||
|
match representative.get(&root).copied() {
|
||||||
|
Some(current) if !is_raw(current) || is_raw(i) => {}
|
||||||
|
_ => {
|
||||||
|
representative.insert(root, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(0..entries.len())
|
||||||
|
.map(|i| {
|
||||||
|
let root = find(&mut parent, i);
|
||||||
|
entries[representative[&root]].uid.clone()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The columns every photo read selects, with the group aggregates joined in.
|
||||||
|
/// `g.n` is how many photos the group holds and `g.raw` whether any of them is a
|
||||||
|
/// camera raw file — one query rather than a second lookup per tile.
|
||||||
|
///
|
||||||
|
/// `COALESCE(group_key, uid)` is what makes a row written before schema v30 its
|
||||||
|
/// own group: the column is `NULL` until the next refresh computes it, and a
|
||||||
|
/// `NULL` would otherwise match nothing and drop the photo off the page.
|
||||||
|
const PHOTO_SELECT: &str = "SELECT p.uid, p.capture_time, p.name, p.ratio, p.thumb_state, \
|
||||||
|
p.kind, p.favorite, g.n, g.raw \
|
||||||
|
FROM photos p JOIN (SELECT COALESCE(group_key, uid) AS k, COUNT(*) AS n, \
|
||||||
|
MAX(kind = 2) AS raw FROM photos GROUP BY k) g ON g.k = COALESCE(p.group_key, p.uid)";
|
||||||
|
|
||||||
|
/// Read one row of [`PHOTO_SELECT`].
|
||||||
|
fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||||
|
Ok(StoredPhoto {
|
||||||
|
uid: r.get(0)?,
|
||||||
|
capture_time: r.get(1)?,
|
||||||
|
name: r.get(2)?,
|
||||||
|
ratio: r.get(3)?,
|
||||||
|
thumb_state: r.get(4)?,
|
||||||
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
|
favorite: r.get::<_, i64>(6)? != 0,
|
||||||
|
group_size: r.get::<_, i64>(7)?.max(1) as usize,
|
||||||
|
has_raw: r.get::<_, i64>(8)? != 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Replace the timeline wholesale. Each item is
|
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
/// read from the server in this pass, so its fields are taken as they are —
|
||||||
/// when the refresh could not resolve that photo's node, in which case the
|
/// a `None` there means the server no longer reports that value. A row whose
|
||||||
/// flag already stored is kept rather than silently cleared.
|
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
|
||||||
|
/// whatever is already stored rather than having it silently cleared.
|
||||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
// daemon may not know them when it replaces the timeline — because the
|
||||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
// refresh skipped an already-resolved photo, or because resolving it
|
||||||
// refresh instead of collapsing back to name-extension guesses.
|
// failed. Keep any previously learned value so the tab split and the
|
||||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||||
let mut stmt =
|
// both are derived from the name. The photo relation is kept for the same
|
||||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
// reason — losing it would break a group up until the next resolve.
|
||||||
|
let learned: HashMap<String, Learned> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||||
|
main_uid, resolved_at FROM photos",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, String>(0)?,
|
r.get::<_, String>(0)?,
|
||||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
Learned {
|
||||||
|
name: r.get(1)?,
|
||||||
|
ratio: r.get(2)?,
|
||||||
|
thumb_state: r.get(3)?,
|
||||||
|
media_type: r.get(4)?,
|
||||||
|
favorite: r.get::<_, i64>(5)? != 0,
|
||||||
|
content_hash: r.get(6)?,
|
||||||
|
main_uid: r.get(7)?,
|
||||||
|
resolved_at: r.get(8)?,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
rows.collect::<rusqlite::Result<_>>()?
|
rows.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Everything the insert and the grouping need, resolved once per photo.
|
||||||
|
let resolved: Vec<(&TimelineRow, Learned, crate::control::PhotoKind)> = items
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
let mut carried = learned.get(&row.uid).cloned().unwrap_or(Learned {
|
||||||
|
thumb_state: THUMB_UNKNOWN,
|
||||||
|
..Learned::default()
|
||||||
|
});
|
||||||
|
if row.resolved_at.is_some() {
|
||||||
|
// This pass read the node itself, so what it says is the
|
||||||
|
// truth — including what it no longer says. A photo unlinked
|
||||||
|
// from its main on another device loses its `main_uid` here,
|
||||||
|
// which a carry could never express.
|
||||||
|
carried.name = row.name.clone();
|
||||||
|
carried.media_type = row.media_type.clone();
|
||||||
|
carried.favorite = row.favorite.unwrap_or(false);
|
||||||
|
carried.content_hash = row.content_hash.clone();
|
||||||
|
carried.main_uid = row.main_uid.clone();
|
||||||
|
} else {
|
||||||
|
carried.name = row.name.clone().or(carried.name);
|
||||||
|
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||||
|
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||||
|
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||||
|
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||||
|
}
|
||||||
|
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||||
|
// The tab this photo lands in is derived here, once, so a page or
|
||||||
|
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||||
|
// reclassification of every row.
|
||||||
|
let kind = crate::control::PhotoKind::classify(
|
||||||
|
carried.name.as_deref(),
|
||||||
|
carried.media_type.as_deref(),
|
||||||
|
);
|
||||||
|
(row, carried, kind)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let entries: Vec<Grouped> = resolved
|
||||||
|
.iter()
|
||||||
|
.map(|(row, carried, kind)| Grouped {
|
||||||
|
uid: row.uid.clone(),
|
||||||
|
kind: *kind,
|
||||||
|
main_uid: carried.main_uid.clone(),
|
||||||
|
day: capture_day(&tx, row.capture_time),
|
||||||
|
stem: name_stem(carried.name.as_deref()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let group_keys = group_photos(&entries);
|
||||||
|
|
||||||
tx.execute("DELETE FROM photos", [])?;
|
tx.execute("DELETE FROM photos", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO photos
|
"INSERT INTO photos
|
||||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
content_hash, main_uid, group_key, resolved_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
)?;
|
)?;
|
||||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
for (seq, ((row, carried, kind), group_key)) in
|
||||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||||
.get(uid)
|
{
|
||||||
.cloned()
|
|
||||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
|
||||||
let media_type = media_type.clone().or(learned_media);
|
|
||||||
let favorite = favorite.unwrap_or(learned_favorite);
|
|
||||||
// The tab this photo lands in is derived here, once, so a page or
|
|
||||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
|
||||||
// reclassification of every row.
|
|
||||||
let kind =
|
|
||||||
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
|
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
uid,
|
row.uid,
|
||||||
capture_time,
|
row.capture_time,
|
||||||
name,
|
carried.name,
|
||||||
ratio,
|
carried.ratio,
|
||||||
thumb_state,
|
carried.thumb_state,
|
||||||
seq as i64,
|
seq as i64,
|
||||||
media_type,
|
carried.media_type,
|
||||||
kind.as_i64(),
|
kind.as_i64(),
|
||||||
favorite as i64,
|
carried.favorite as i64,
|
||||||
|
carried.content_hash,
|
||||||
|
carried.main_uid,
|
||||||
|
group_key,
|
||||||
|
carried.resolved_at,
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,11 +382,84 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The uids whose node metadata is already resolved, so a refresh can skip
|
||||||
|
/// reading their nodes again. Everything else — a photo never seen before, or
|
||||||
|
/// one a remote event marked stale — is resolved. That is what keeps a
|
||||||
|
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
|
||||||
|
pub fn photos_resolved(&self) -> Result<Vec<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut uids = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
uids.push(row?);
|
||||||
|
}
|
||||||
|
Ok(uids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark photos as needing a fresh metadata resolve, and report how many rows
|
||||||
|
/// that matched. This is how a remote change is recorded: the event feed says
|
||||||
|
/// *which* node changed but never *what* changed about it, so the uid is
|
||||||
|
/// written down and the next refresh reads the node.
|
||||||
|
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut marked = 0;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
|
||||||
|
for uid in uids {
|
||||||
|
marked += stmt.execute(params![uid])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(marked)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same for the whole timeline: every photo is resolved again on the next
|
||||||
|
/// refresh. For a continuity loss, where the daemon cannot know what it
|
||||||
|
/// missed, and for an explicit `refresh photos --full`.
|
||||||
|
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget photos that have been trashed, so the gallery does not show them
|
||||||
|
/// until the next timeline refresh would have caught up.
|
||||||
|
///
|
||||||
|
/// Album membership goes with them: a trashed photo is gone from every album
|
||||||
|
/// that held it, and leaving the rows behind would keep it on the album
|
||||||
|
/// pages and in the album covers.
|
||||||
|
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
|
||||||
|
if uids.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let mut removed = 0;
|
||||||
|
{
|
||||||
|
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
|
||||||
|
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
|
||||||
|
for uid in uids {
|
||||||
|
removed += photos.execute([uid])?;
|
||||||
|
albums.execute([uid])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
/// One page of the persisted timeline, newest first. `kind`, when set,
|
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||||
/// `favorites` restricts the page to favourited photos.
|
/// `favorites` restricts the page to favorited photos.
|
||||||
|
///
|
||||||
|
/// The page holds one row per *group*: the RAW and the JPEG of one shot are
|
||||||
|
/// one tile. The Raw tab is the exception and lists every raw file, because
|
||||||
|
/// that tab exists to answer "which raws do I have".
|
||||||
pub fn photos_page(
|
pub fn photos_page(
|
||||||
&self,
|
&self,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
@@ -118,23 +471,24 @@ impl Db {
|
|||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
// query rather than a statement per case.
|
// query rather than a statement per case.
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(PHOTO_SELECT);
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
|
||||||
);
|
|
||||||
let mut binds: Vec<i64> = Vec::new();
|
let mut binds: Vec<i64> = Vec::new();
|
||||||
let mut conds: Vec<String> = Vec::new();
|
let mut conds: Vec<String> = Vec::new();
|
||||||
|
if kind != Some(crate::control::PhotoKind::Raw) {
|
||||||
|
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
|
||||||
|
}
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
binds.push(k.as_i64());
|
binds.push(k.as_i64());
|
||||||
conds.push(format!("kind = ?{}", binds.len()));
|
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if let Some((from, to)) = range {
|
if let Some((from, to)) = range {
|
||||||
binds.push(from);
|
binds.push(from);
|
||||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||||
binds.push(to);
|
binds.push(to);
|
||||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if favorites {
|
if favorites {
|
||||||
conds.push("favorite = 1".to_string());
|
conds.push("p.favorite = 1".to_string());
|
||||||
}
|
}
|
||||||
if !conds.is_empty() {
|
if !conds.is_empty() {
|
||||||
sql.push_str(" WHERE ");
|
sql.push_str(" WHERE ");
|
||||||
@@ -145,22 +499,41 @@ impl Db {
|
|||||||
binds.push(offset as i64);
|
binds.push(offset as i64);
|
||||||
let offset_pos = binds.len();
|
let offset_pos = binds.len();
|
||||||
sql.push_str(&format!(
|
sql.push_str(&format!(
|
||||||
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
" ORDER BY p.seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(rusqlite::params_from_iter(binds), |r| {
|
.query_map(rusqlite::params_from_iter(binds), stored_photo)?
|
||||||
Ok(StoredPhoto {
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
uid: r.get(0)?,
|
Ok(rows)
|
||||||
capture_time: r.get(1)?,
|
}
|
||||||
name: r.get(2)?,
|
|
||||||
ratio: r.get(3)?,
|
/// Every photo of one photo's group, in server order — the RAW and the JPEG
|
||||||
thumb_state: r.get(4)?,
|
/// of a shot, or just the photo itself when it stands alone. The lightbox
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
/// switches between these, and deleting a tile trashes all of them.
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
pub fn photos_group(&self, uid: &str) -> Result<Vec<StoredPhoto>> {
|
||||||
})
|
let conn = self.read();
|
||||||
})?
|
let sql = format!(
|
||||||
|
"{PHOTO_SELECT} WHERE COALESCE(p.group_key, p.uid) = \
|
||||||
|
(SELECT COALESCE(group_key, uid) FROM photos WHERE uid = ?1) ORDER BY p.seq"
|
||||||
|
);
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![uid], stored_photo)?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole persisted timeline, in stored order — every file, not one row
|
||||||
|
/// per group. Only for whole-library passes (the photo re-date); the gallery
|
||||||
|
/// pages with [`Db::photos_page`].
|
||||||
|
pub fn photos_all(&self) -> Result<Vec<StoredPhoto>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let sql = format!("{PHOTO_SELECT} ORDER BY p.seq");
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], stored_photo)?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -175,9 +548,15 @@ impl Db {
|
|||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
|
// The scrubber has to agree with the page it scrubs, so it counts what
|
||||||
|
// the page shows: groups everywhere except on the Raw tab.
|
||||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||||
None => ("", Vec::new()),
|
Some(k) => (
|
||||||
|
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
|
||||||
|
vec![k.as_i64()],
|
||||||
|
),
|
||||||
|
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||||
@@ -202,7 +581,13 @@ impl Db {
|
|||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
// Photos and Videos count groups — one shot is one entry, however many
|
||||||
|
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||||
|
// them individually.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT kind, COUNT(*) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -225,21 +610,9 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
WHERE uid IN ({placeholders})"
|
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||||
))?;
|
|
||||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
|
||||||
Ok(StoredPhoto {
|
|
||||||
uid: r.get(0)?,
|
|
||||||
capture_time: r.get(1)?,
|
|
||||||
name: r.get(2)?,
|
|
||||||
ratio: r.get(3)?,
|
|
||||||
thumb_state: r.get(4)?,
|
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
photos.push(row?);
|
photos.push(row?);
|
||||||
@@ -247,7 +620,7 @@ impl Db {
|
|||||||
Ok(photos)
|
Ok(photos)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a photo's favourite flag locally, after the server accepted the
|
/// Record a photo's favorite flag locally, after the server accepted the
|
||||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||||
/// someone else's volume is never in our own `photos` table.
|
/// someone else's volume is never in our own `photos` table.
|
||||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||||
|
|||||||
@@ -113,9 +113,13 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
|
|||||||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
|
TimelineRow {
|
||||||
("p2".into(), 200, None, None, None),
|
media_type: Some("video/mp4".into()),
|
||||||
("p3".into(), 100, None, None, None),
|
favorite: Some(true),
|
||||||
|
..TimelineRow::new("p1", 300)
|
||||||
|
},
|
||||||
|
TimelineRow::new("p2", 200),
|
||||||
|
TimelineRow::new("p3", 100),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -126,9 +130,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||||||
// p1's media type arrives as `None` this time and must not be forgotten.
|
// p1's media type arrives as `None` this time and must not be forgotten.
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
|
TimelineRow {
|
||||||
("p1".into(), 300, None, None, None),
|
name: Some("new.jpg".into()),
|
||||||
("p2".into(), 200, None, None, None),
|
favorite: Some(false),
|
||||||
|
..TimelineRow::new("p0", 400)
|
||||||
|
},
|
||||||
|
TimelineRow::new("p1", 300),
|
||||||
|
TimelineRow::new("p2", 200),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -200,23 +208,32 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
|||||||
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p1".into(), 300, None, None, Some(true)),
|
TimelineRow {
|
||||||
("p2".into(), 200, None, None, Some(false)),
|
favorite: Some(true),
|
||||||
|
..TimelineRow::new("p1", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
favorite: Some(false),
|
||||||
|
..TimelineRow::new("p2", 200)
|
||||||
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
["p1"],
|
["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
|
// A local toggle survives a refresh that could not resolve the photo's node
|
||||||
// (`None`), and loses to one that could.
|
// (`None`), and loses to one that could.
|
||||||
db.photos_set_favorite("p2", true).unwrap();
|
db.photos_set_favorite("p2", true).unwrap();
|
||||||
db.photos_replace(&[
|
db.photos_replace(&[
|
||||||
("p1".into(), 300, None, None, Some(false)),
|
TimelineRow {
|
||||||
("p2".into(), 200, None, None, None),
|
favorite: Some(false),
|
||||||
|
..TimelineRow::new("p1", 300)
|
||||||
|
},
|
||||||
|
TimelineRow::new("p2", 200),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||||
@@ -232,7 +249,7 @@ fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
|||||||
fn photos_page_slices_the_timeline_in_order() {
|
fn photos_page_slices_the_timeline_in_order() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
let items: Vec<_> = (0..5)
|
let items: Vec<_> = (0..5)
|
||||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
|
.map(|i| TimelineRow::new(format!("p{i}"), 500 - i as i64))
|
||||||
.collect();
|
.collect();
|
||||||
db.photos_replace(&items).unwrap();
|
db.photos_replace(&items).unwrap();
|
||||||
|
|
||||||
@@ -254,6 +271,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
is_dir: false,
|
is_dir: false,
|
||||||
size: 10,
|
size: 10,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
|
parent_uid: Some("t2".into()),
|
||||||
},
|
},
|
||||||
StoredTrash {
|
StoredTrash {
|
||||||
uid: "t2".into(),
|
uid: "t2".into(),
|
||||||
@@ -261,6 +279,7 @@ fn trash_replace_lists_folders_first() {
|
|||||||
is_dir: true,
|
is_dir: true,
|
||||||
size: 0,
|
size: 0,
|
||||||
mtime: 2,
|
mtime: 2,
|
||||||
|
parent_uid: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -271,6 +290,18 @@ fn trash_replace_lists_folders_first() {
|
|||||||
["Alpha", "zeta.txt"]
|
["Alpha", "zeta.txt"]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The parent links come back with the rows: a restore reads the tree shape
|
||||||
|
// from them.
|
||||||
|
let mut parents = db.trash_parents().unwrap();
|
||||||
|
parents.sort();
|
||||||
|
assert_eq!(
|
||||||
|
parents,
|
||||||
|
[
|
||||||
|
("t1".to_string(), Some("t2".to_string())),
|
||||||
|
("t2".to_string(), None),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// A replace is a replace: emptying the trash on the server empties it here.
|
// A replace is a replace: emptying the trash on the server empties it here.
|
||||||
db.trash_replace(&[]).unwrap();
|
db.trash_replace(&[]).unwrap();
|
||||||
assert!(db.trash_list().unwrap().is_empty());
|
assert!(db.trash_list().unwrap().is_empty());
|
||||||
@@ -1052,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]
|
#[test]
|
||||||
fn search_excludes_trashed_and_respects_limit() {
|
fn search_excludes_trashed_and_respects_limit() {
|
||||||
let db = Db::open_in_memory().unwrap();
|
let db = Db::open_in_memory().unwrap();
|
||||||
@@ -3018,6 +3083,57 @@ fn a_parked_transient_create_stays_off_the_drain_until_finalized() {
|
|||||||
assert_eq!(due.blob_path.as_deref(), Some("/staging/blob1"));
|
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.
|
/// Backoff still gates: nothing is returned before an op is due.
|
||||||
#[test]
|
#[test]
|
||||||
fn next_due_op_respects_backoff() {
|
fn next_due_op_respects_backoff() {
|
||||||
@@ -3201,6 +3317,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
};
|
};
|
||||||
db.upsert_node(&parent_node).unwrap();
|
db.upsert_node(&parent_node).unwrap();
|
||||||
|
|
||||||
@@ -3227,6 +3345,8 @@ fn test_db_has_children_and_trashed_filtering() {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
};
|
};
|
||||||
db.upsert_node(&child_node).unwrap();
|
db.upsert_node(&child_node).unwrap();
|
||||||
|
|
||||||
@@ -3901,3 +4021,551 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
|||||||
"an op somebody else is draining is not work this worker is waiting for"
|
"an op somebody else is draining is not work this worker is waiting for"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
||||||
|
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||||
|
#[test]
|
||||||
|
fn photos_delete_removes_the_photo_and_its_album_membership() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("p1", 300),
|
||||||
|
TimelineRow::new("p2", 200),
|
||||||
|
TimelineRow::new("p3", 100),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.albums_replace(&[StoredAlbum {
|
||||||
|
uid: "a1".into(),
|
||||||
|
name: "Trip".into(),
|
||||||
|
photo_count: 3,
|
||||||
|
cover_uid: Some("p1".into()),
|
||||||
|
last_activity: Some(500),
|
||||||
|
shared: false,
|
||||||
|
}])
|
||||||
|
.unwrap();
|
||||||
|
db.album_photos_replace(
|
||||||
|
"a1",
|
||||||
|
&[
|
||||||
|
("p1".into(), 300, None, None),
|
||||||
|
("p2".into(), 200, None, None),
|
||||||
|
("p3".into(), 100, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p1", "p3"],
|
||||||
|
"only the trashed photo leaves the timeline"
|
||||||
|
);
|
||||||
|
assert_eq!(db.album_photos_count("a1").unwrap(), 2);
|
||||||
|
let album = db.album_photos_page("a1", 0, 10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
album.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["p1", "p3"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A uid we no longer hold is not an error: the event poller and the trash
|
||||||
|
// reply can both report the same deletion.
|
||||||
|
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0);
|
||||||
|
assert_eq!(db.photos_delete(&[]).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shot taken as RAW+JPEG is one photo to the person who took it: the grid
|
||||||
|
/// shows the JPEG once, and the Raw tab still lists the raw file.
|
||||||
|
#[test]
|
||||||
|
fn a_raw_and_its_jpeg_are_one_entry_in_the_grid() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.JPG".into()),
|
||||||
|
..TimelineRow::new("jpeg", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("img_1234.cr2".into()),
|
||||||
|
..TimelineRow::new("raw", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_9999.JPG".into()),
|
||||||
|
..TimelineRow::new("alone", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["jpeg", "alone"],
|
||||||
|
"the pair shows once, as the member that can be displayed"
|
||||||
|
);
|
||||||
|
assert_eq!(page[0].group_size, 2);
|
||||||
|
assert!(page[0].has_raw, "the tile can say the raw is there");
|
||||||
|
assert_eq!(page[1].group_size, 1);
|
||||||
|
assert!(!page[1].has_raw);
|
||||||
|
|
||||||
|
// The Raw tab answers "which raws do I have", so it lists files.
|
||||||
|
let raws = db
|
||||||
|
.photos_page(0, 10, Some(crate::control::PhotoKind::Raw), None, false)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
raws.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["raw"]
|
||||||
|
);
|
||||||
|
assert_eq!(db.photos_counts().unwrap(), (2, 0, 1));
|
||||||
|
|
||||||
|
// Both members are reachable from either uid: the lightbox switches between
|
||||||
|
// them, and deleting the tile has to trash both.
|
||||||
|
let group = db.photos_group("raw").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
group.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["jpeg", "raw"]
|
||||||
|
);
|
||||||
|
assert_eq!(db.photos_group("alone").unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixel names the two files of one raw capture after the role each plays, so
|
||||||
|
/// their stems differ. The shot is still one shot, and the grid shows the cover
|
||||||
|
/// JPEG.
|
||||||
|
#[test]
|
||||||
|
fn a_pixel_raw_capture_groups_with_its_cover_jpeg() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("PXL_20260919_000625670.RAW-01.COVER.jpg".into()),
|
||||||
|
..TimelineRow::new("cover", 1_789_776_385)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("PXL_20260919_000625670.RAW-02.ORIGINAL.dng".into()),
|
||||||
|
..TimelineRow::new("original", 1_789_776_385)
|
||||||
|
},
|
||||||
|
// A name that merely contains the marker's opening is not a marker.
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("scan.raw-notes.jpg".into()),
|
||||||
|
..TimelineRow::new("notes", 1_789_776_385)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("scan.raw-other.dng".into()),
|
||||||
|
..TimelineRow::new("other", 1_789_776_385)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["cover", "notes", "other"],
|
||||||
|
"the capture shows once, as the cover JPEG"
|
||||||
|
);
|
||||||
|
assert_eq!(page[0].group_size, 2);
|
||||||
|
assert!(page[0].has_raw);
|
||||||
|
assert_eq!(
|
||||||
|
db.photos_group("original")
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.uid.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["cover", "original"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two files that merely share a name are not one shot: the same stem on
|
||||||
|
/// different days, or two files of the same kind, stay apart.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_name_alone_does_not_group_photos() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.JPG".into()),
|
||||||
|
..TimelineRow::new("today", 1_700_000_000)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.CR2".into()),
|
||||||
|
..TimelineRow::new("last-year", 1_600_000_000)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.PNG".into()),
|
||||||
|
..TimelineRow::new("same-kind", 1_700_000_000)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(page.len(), 3, "three shots, three tiles");
|
||||||
|
assert!(page.iter().all(|p| p.group_size == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server knows about live photos and bursts, so its own relation groups
|
||||||
|
/// files this client would never pair by name.
|
||||||
|
#[test]
|
||||||
|
fn the_servers_photo_relation_groups_a_live_photo() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_0001.HEIC".into()),
|
||||||
|
..TimelineRow::new("main", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("clip.mov".into()),
|
||||||
|
main_uid: Some("main".into()),
|
||||||
|
..TimelineRow::new("related", 299)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["main"]
|
||||||
|
);
|
||||||
|
assert_eq!(page[0].group_size, 2);
|
||||||
|
assert!(!page[0].has_raw, "a live photo's video is not a raw file");
|
||||||
|
|
||||||
|
// The relation is learned-and-kept: a refresh that could not resolve the
|
||||||
|
// nodes must not break the group up.
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("main", 300),
|
||||||
|
TimelineRow::new("related", 299),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_v30_leaves_a_v29_timeline_intact() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v29-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("IMG_1234.JPG".into()),
|
||||||
|
..TimelineRow::new("jpeg", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.CR2".into()),
|
||||||
|
..TimelineRow::new("raw", 300)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
db.photo_set_thumb("jpeg", THUMB_HAVE, Some(1.5)).unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V29 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP INDEX idx_photos_group;
|
||||||
|
ALTER TABLE photos DROP COLUMN group_key;
|
||||||
|
ALTER TABLE photos DROP COLUMN main_uid;
|
||||||
|
ALTER TABLE photos DROP COLUMN content_hash;
|
||||||
|
UPDATE sync_state SET value = '29' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||||
|
["jpeg", "raw"],
|
||||||
|
"no photo goes missing before the next refresh groups them"
|
||||||
|
);
|
||||||
|
assert!(page.iter().all(|p| p.group_size == 1));
|
||||||
|
assert_eq!(page[0].ratio, Some(1.5), "what was learned is still there");
|
||||||
|
|
||||||
|
// The next refresh fills the column in and the pair collapses.
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.JPG".into()),
|
||||||
|
..TimelineRow::new("jpeg", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.CR2".into()),
|
||||||
|
..TimelineRow::new("raw", 300)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(page.len(), 1);
|
||||||
|
assert_eq!(page[0].uid, "jpeg");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refresh that skips an already-resolved photo sends a row carrying only the
|
||||||
|
/// uid and capture time. The name must survive that, because the tab split and
|
||||||
|
/// the grouping are both derived from it.
|
||||||
|
#[test]
|
||||||
|
fn photos_replace_keeps_a_skipped_photos_name() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.JPG".into()),
|
||||||
|
media_type: Some("image/jpeg".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("jpeg", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_1234.CR2".into()),
|
||||||
|
media_type: Some("application/octet-stream".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("raw", 300)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(page.len(), 1, "one shot, two files");
|
||||||
|
assert!(page[0].has_raw);
|
||||||
|
|
||||||
|
// The next refresh resolves neither, because both are already resolved.
|
||||||
|
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||||||
|
.unwrap();
|
||||||
|
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||||
|
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
||||||
|
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||||||
|
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||||||
|
assert_eq!(page[0].group_size, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the photos a refresh actually read are marked resolved; the rest stay
|
||||||
|
/// eligible, so the next refresh tries them again.
|
||||||
|
#[test]
|
||||||
|
fn photos_replace_marks_only_the_rows_it_resolved() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("ok.jpg".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("ok", 300)
|
||||||
|
},
|
||||||
|
// The chunk holding this one failed.
|
||||||
|
TimelineRow::new("failed", 200),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.photos_resolved().unwrap(), ["ok"]);
|
||||||
|
|
||||||
|
// A resolved photo stays resolved across a refresh that skips it.
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("ok", 300),
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("late.jpg".into()),
|
||||||
|
resolved_at: Some(2),
|
||||||
|
..TimelineRow::new("failed", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let mut resolved = db.photos_resolved().unwrap();
|
||||||
|
resolved.sort();
|
||||||
|
assert_eq!(resolved, ["failed", "ok"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a refresh read is the truth, including what it no longer reports: a
|
||||||
|
/// photo unlinked from its main on another device leaves the group.
|
||||||
|
#[test]
|
||||||
|
fn a_resolved_replace_clears_a_relation_the_server_dropped() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_0001.HEIC".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("main", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("clip.mov".into()),
|
||||||
|
main_uid: Some("main".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("related", 299)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||||||
|
|
||||||
|
// Both are read again and the server no longer reports the relation.
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_0001.HEIC".into()),
|
||||||
|
resolved_at: Some(2),
|
||||||
|
..TimelineRow::new("main", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("clip.mov".into()),
|
||||||
|
resolved_at: Some(2),
|
||||||
|
..TimelineRow::new("related", 299)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.photos_group("related").unwrap().len(),
|
||||||
|
1,
|
||||||
|
"an unlinked photo stands on its own"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refresh that did *not* read the nodes must not break the group up, which is
|
||||||
|
/// the older behaviour and still the one a failed resolve gets.
|
||||||
|
#[test]
|
||||||
|
fn an_unresolved_replace_keeps_a_relation() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("IMG_0001.HEIC".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("main", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("clip.mov".into()),
|
||||||
|
main_uid: Some("main".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("related", 299)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow::new("main", 300),
|
||||||
|
TimelineRow::new("related", 299),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remote event names the photo it changed, and that is all it takes to have
|
||||||
|
/// the next refresh read that one node again.
|
||||||
|
#[test]
|
||||||
|
fn marking_a_photo_unresolved_makes_it_eligible_again() {
|
||||||
|
let db = Db::open_in_memory().unwrap();
|
||||||
|
db.photos_replace(&[
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("a.jpg".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("a", 300)
|
||||||
|
},
|
||||||
|
TimelineRow {
|
||||||
|
name: Some("b.jpg".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("b", 200)
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.photos_mark_unresolved(&["a".to_string()]).unwrap(), 1);
|
||||||
|
assert_eq!(db.photos_resolved().unwrap(), ["b"]);
|
||||||
|
// A uid the timeline does not hold changes nothing.
|
||||||
|
assert_eq!(db.photos_mark_unresolved(&["gone".to_string()]).unwrap(), 0);
|
||||||
|
|
||||||
|
assert_eq!(db.photos_mark_all_unresolved().unwrap(), 2);
|
||||||
|
assert!(db.photos_resolved().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An existing timeline opens as resolved, so upgrading does not re-read the
|
||||||
|
/// whole library once — except for the photos nothing was ever learned about.
|
||||||
|
#[test]
|
||||||
|
fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"pdfs-db-v30-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("IMG_1234.JPG".into()),
|
||||||
|
resolved_at: Some(1),
|
||||||
|
..TimelineRow::new("named", 300)
|
||||||
|
},
|
||||||
|
// A photo whose resolve had failed: nothing was ever learned.
|
||||||
|
TimelineRow::new("nameless", 200),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Put the file back in the state a released V30 database was in.
|
||||||
|
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"DROP INDEX idx_photos_unresolved;
|
||||||
|
ALTER TABLE photos DROP COLUMN resolved_at;
|
||||||
|
UPDATE sync_state SET value = '30' WHERE key = 'schema_version';",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = Db::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
db.photos_resolved().unwrap(),
|
||||||
|
["named"],
|
||||||
|
"the stored timeline is trusted, a nameless row is not"
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,10 @@ pub struct StoredTrash {
|
|||||||
pub is_dir: bool,
|
pub is_dir: bool,
|
||||||
pub size: i64,
|
pub size: i64,
|
||||||
pub mtime: i64,
|
pub mtime: i64,
|
||||||
|
/// The folder this node was trashed from, when it is known. `None` for a
|
||||||
|
/// row written before schema v29, and for a node whose parent the server
|
||||||
|
/// did not report.
|
||||||
|
pub parent_uid: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
@@ -20,7 +24,8 @@ impl Db {
|
|||||||
tx.execute("DELETE FROM trash", [])?;
|
tx.execute("DELETE FROM trash", [])?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"INSERT INTO trash (uid, name, is_dir, size, mtime) VALUES (?1, ?2, ?3, ?4, ?5)",
|
"INSERT INTO trash (uid, name, is_dir, size, mtime, parent_uid) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
)?;
|
)?;
|
||||||
for item in items {
|
for item in items {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
@@ -28,7 +33,8 @@ impl Db {
|
|||||||
item.name,
|
item.name,
|
||||||
item.is_dir as i64,
|
item.is_dir as i64,
|
||||||
item.size,
|
item.size,
|
||||||
item.mtime
|
item.mtime,
|
||||||
|
item.parent_uid
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +47,7 @@ impl Db {
|
|||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.read();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid FROM trash
|
||||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |r| {
|
let rows = stmt.query_map([], |r| {
|
||||||
@@ -51,6 +57,7 @@ impl Db {
|
|||||||
is_dir: r.get::<_, i64>(2)? != 0,
|
is_dir: r.get::<_, i64>(2)? != 0,
|
||||||
size: r.get(3)?,
|
size: r.get(3)?,
|
||||||
mtime: r.get(4)?,
|
mtime: r.get(4)?,
|
||||||
|
parent_uid: r.get(5)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
@@ -60,5 +67,19 @@ impl Db {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every trashed node paired with the folder it was trashed from, for the
|
||||||
|
/// restore's tree walk. Kept separate from [`Db::trash_list`] because the
|
||||||
|
/// restore wants only the links, not the names, sizes or ordering.
|
||||||
|
pub fn trash_parents(&self) -> Result<Vec<(String, Option<String>)>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare("SELECT uid, parent_uid FROM trash")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
pairs.push(row?);
|
||||||
|
}
|
||||||
|
Ok(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device sync (devices.md) -----------------------------------------
|
// ---- device sync (devices.md) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ pub enum Error {
|
|||||||
Proton(#[from] proton_sdk::ProtonError),
|
Proton(#[from] proton_sdk::ProtonError),
|
||||||
|
|
||||||
#[error("keyring: {0}")]
|
#[error("keyring: {0}")]
|
||||||
Keyring(#[from] keyring::Error),
|
Keyring(#[from] keyring_core::Error),
|
||||||
|
|
||||||
#[error("serialization: {0}")]
|
#[error("serialization: {0}")]
|
||||||
Serde(#[from] serde_json::Error),
|
Serde(#[from] serde_json::Error),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub mod search;
|
|||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod syncignore;
|
pub mod syncignore;
|
||||||
|
pub mod takeout;
|
||||||
|
pub mod tray;
|
||||||
|
|
||||||
pub use access::{Access, access_for, perm_bits};
|
pub use access::{Access, access_for, perm_bits};
|
||||||
pub use error::{CoreError, CoreResult, Error, Result};
|
pub use error::{CoreError, CoreResult, Error, Result};
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ fn to_entry(entry: &ignore::DirEntry) -> Option<LocalEntry> {
|
|||||||
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
/// The paths a scan should never descend into: the Drive mountpoint (walking it
|
||||||
/// would fault every remote node in through FUSE) plus our own state and cache
|
/// would fault every remote node in through FUSE) plus our own state and cache
|
||||||
/// dirs, which hold blobs no user searches for by name.
|
/// dirs, which hold blobs no user searches for by name.
|
||||||
|
///
|
||||||
|
/// Callers should extend this with [`nested_mount_points`] for each scan root —
|
||||||
|
/// see that function for why `same_file_system(true)` is not enough on its own.
|
||||||
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -> Vec<PathBuf> {
|
||||||
vec![
|
vec![
|
||||||
mountpoint.to_path_buf(),
|
mountpoint.to_path_buf(),
|
||||||
@@ -207,6 +210,66 @@ pub fn default_excludes(mountpoint: &Path, state_dir: &Path, cache_dir: &Path) -
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every mountpoint strictly below `root`, read from `/proc/self/mounts`.
|
||||||
|
///
|
||||||
|
/// [`scan`] already asks the walker to stay on one filesystem, but that check is
|
||||||
|
/// a *stat of the mountpoint* (`ignore`'s `is_same_file_system`), performed after
|
||||||
|
/// the directory is queued — so the walker has to touch a foreign mount to learn
|
||||||
|
/// it should skip it. On a dead network FUSE mount (an sshfs whose server is
|
||||||
|
/// gone, an unresponsive rclone) that stat blocks in `request_wait_answer` with
|
||||||
|
/// nobody left to answer it, and blocks *uninterruptibly*: the walker thread then
|
||||||
|
/// ignores SIGKILL, so the daemon can never exit, systemd's stop times out, and
|
||||||
|
/// the half-dead process keeps the cache.db lock that the next start needs
|
||||||
|
/// (docs/BUGS.md B90).
|
||||||
|
///
|
||||||
|
/// Excluding the mountpoints by path avoids that stat entirely. It does not
|
||||||
|
/// change what ends up indexed: a nested mount is a different device, so
|
||||||
|
/// `same_file_system(true)` was already going to skip it.
|
||||||
|
pub fn nested_mount_points(root: &Path) -> Vec<PathBuf> {
|
||||||
|
let Ok(mounts) = std::fs::read_to_string("/proc/self/mounts") else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
parse_nested_mount_points(&mounts, root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mountpoint-field parse behind [`nested_mount_points`], split out so it can
|
||||||
|
/// be tested without a real `/proc`.
|
||||||
|
fn parse_nested_mount_points(mounts: &str, root: &Path) -> Vec<PathBuf> {
|
||||||
|
mounts
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.split_whitespace().nth(1))
|
||||||
|
.map(|field| PathBuf::from(unescape_mount_field(field)))
|
||||||
|
// Strictly below: `root` itself being a mountpoint must not exclude the
|
||||||
|
// whole scan.
|
||||||
|
.filter(|path| path.starts_with(root) && path != root)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Undo the octal escaping `/proc/self/mounts` applies to space, tab, newline and
|
||||||
|
/// backslash in path fields. An un-unescaped `\040` would simply fail to match a
|
||||||
|
/// real path, silently putting a mount with a space in its name back in the walk.
|
||||||
|
fn unescape_mount_field(field: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(field.len());
|
||||||
|
let mut rest = field;
|
||||||
|
while let Some(at) = rest.find('\\') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
let escape = rest[at + 1..].get(..3);
|
||||||
|
match escape.and_then(|digits| u8::from_str_radix(digits, 8).ok()) {
|
||||||
|
Some(byte) => {
|
||||||
|
out.push(byte as char);
|
||||||
|
rest = &rest[at + 4..];
|
||||||
|
}
|
||||||
|
// Not an octal escape after all — keep the backslash verbatim.
|
||||||
|
None => {
|
||||||
|
out.push('\\');
|
||||||
|
rest = &rest[at + 1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -268,4 +331,42 @@ mod tests {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&tmp).unwrap();
|
std::fs::remove_dir_all(&tmp).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dead network FUSE mount under the scan root must be skipped by *path*,
|
||||||
|
/// before anything stats it — see `nested_mount_points`. The scan root itself
|
||||||
|
/// being a mountpoint must not exclude the entire walk.
|
||||||
|
#[test]
|
||||||
|
fn nested_mount_points_are_excluded_but_the_root_mount_is_not() {
|
||||||
|
let mounts = "\
|
||||||
|
/dev/sda2 /home ext4 rw 0 0
|
||||||
|
narl@narl.io:/opt /home/narl/remote/narl.io fuse.sshfs rw 0 0
|
||||||
|
google_drive: /home/narl/remote/gdrive fuse.rclone rw 0 0
|
||||||
|
tmpfs /run/user/1000 tmpfs rw 0 0
|
||||||
|
";
|
||||||
|
let nested = parse_nested_mount_points(mounts, Path::new("/home/narl"));
|
||||||
|
assert_eq!(
|
||||||
|
nested,
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/home/narl/remote/narl.io"),
|
||||||
|
PathBuf::from("/home/narl/remote/gdrive"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// `/home` is an ancestor, not a descendant: excluding it would empty the
|
||||||
|
// scan. `/run/user/1000` is unrelated.
|
||||||
|
assert!(parse_nested_mount_points(mounts, Path::new("/home")).len() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/proc/self/mounts` octal-escapes a space; leaving it escaped would put a
|
||||||
|
/// mount whose path contains one back into the walk.
|
||||||
|
#[test]
|
||||||
|
fn mount_paths_are_unescaped() {
|
||||||
|
let mounts = "srv:/x /home/narl/My\\040Cloud fuse.sshfs rw 0 0\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_nested_mount_points(mounts, Path::new("/home/narl")),
|
||||||
|
vec![PathBuf::from("/home/narl/My Cloud")]
|
||||||
|
);
|
||||||
|
assert_eq!(unescape_mount_field("/a\\134b"), "/a\\b");
|
||||||
|
assert_eq!(unescape_mount_field("/plain/path"), "/plain/path");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +72,25 @@ const KNOWN_MENUS: [MenuFlavor; 6] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Terminals that can host `pdfs-prompt --fzf`, most-preferred first.
|
||||||
|
///
|
||||||
|
/// The fzf front end is a terminal program, so a keybinding that has no tty
|
||||||
|
/// needs one spawned for it. Each entry sets a stable app-id/class
|
||||||
|
/// ([`TERMINAL_APP_ID`]) so a tiling WM can be told to float exactly this
|
||||||
|
/// window, and ends with the flag after which the command to run follows.
|
||||||
|
const KNOWN_TERMINALS: [&[&str]; 6] = [
|
||||||
|
&["foot", "--app-id=pdfs-prompt", "--"],
|
||||||
|
&["ghostty", "--class=pdfs-prompt", "-e"],
|
||||||
|
&["kitty", "--class", "pdfs-prompt"],
|
||||||
|
&["alacritty", "--class", "pdfs-prompt", "-e"],
|
||||||
|
&["wezterm", "start", "--class", "pdfs-prompt", "--"],
|
||||||
|
&["xterm", "-class", "pdfs-prompt", "-e"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The app-id/class every [`KNOWN_TERMINALS`] entry sets, so the window rule a
|
||||||
|
/// user writes for one terminal keeps working if they switch.
|
||||||
|
pub const TERMINAL_APP_ID: &str = "pdfs-prompt";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum PromptFlag {
|
enum PromptFlag {
|
||||||
/// `--prompt=TEXT`
|
/// `--prompt=TEXT`
|
||||||
@@ -105,6 +124,13 @@ pub struct PromptConfig {
|
|||||||
/// the prompt text; without one the launcher's own prompt flag is appended.
|
/// the prompt text; without one the launcher's own prompt flag is appended.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub menu: Option<Vec<String>>,
|
pub menu: Option<Vec<String>>,
|
||||||
|
/// Terminal argv for `pdfs-prompt --fzf` when it is started without a tty.
|
||||||
|
/// Unset means "find one" — see [`resolve_terminal`]. A `{cmd}` token
|
||||||
|
/// anywhere in the argv is replaced by the command to run; without one the
|
||||||
|
/// command is appended, which is what every [`KNOWN_TERMINALS`] entry
|
||||||
|
/// expects.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terminal: Option<Vec<String>>,
|
||||||
/// How many results to feed the launcher. Unset means
|
/// How many results to feed the launcher. Unset means
|
||||||
/// [`DEFAULT_MENU_LIMIT`].
|
/// [`DEFAULT_MENU_LIMIT`].
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -124,6 +150,8 @@ pub enum PromptMode {
|
|||||||
Gtk,
|
Gtk,
|
||||||
/// The user's own launcher, driven over stdin/stdout.
|
/// The user's own launcher, driven over stdin/stdout.
|
||||||
Dmenu,
|
Dmenu,
|
||||||
|
/// `fzf` in a terminal, re-querying the daemon on every keystroke.
|
||||||
|
Fzf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
/// Rows an external launcher can show without becoming a scroll exercise. Larger
|
||||||
@@ -186,6 +214,43 @@ pub fn resolve_menu(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
|||||||
Some(argv)
|
Some(argv)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The terminal to host `pdfs-prompt --fzf` in: the configured one, or the
|
||||||
|
/// first [`KNOWN_TERMINALS`] entry on `PATH`. Returns the argv up to and
|
||||||
|
/// including the "run this command" flag — the caller appends the command, or
|
||||||
|
/// substitutes it for a `{cmd}` token, via [`with_command`].
|
||||||
|
pub fn resolve_terminal(configured: Option<&Vec<String>>) -> Option<Vec<String>> {
|
||||||
|
if let Some(argv) = configured.filter(|argv| !argv.is_empty()) {
|
||||||
|
return Some(argv.clone());
|
||||||
|
}
|
||||||
|
let flavor = KNOWN_TERMINALS
|
||||||
|
.iter()
|
||||||
|
.find(|argv| argv.first().is_some_and(|program| on_path(program)))?;
|
||||||
|
Some(flavor.iter().map(|arg| (*arg).to_string()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Place `command` into a terminal argv: substituted for a `{cmd}` token if the
|
||||||
|
/// user wrote one, otherwise appended.
|
||||||
|
///
|
||||||
|
/// The token exists for terminals that want the command in the middle of their
|
||||||
|
/// own arguments; appending is right for every [`KNOWN_TERMINALS`] entry, whose
|
||||||
|
/// last element is the flag the command follows.
|
||||||
|
pub fn with_command(argv: &[String], command: &[String]) -> Vec<String> {
|
||||||
|
if !argv.iter().any(|arg| arg == "{cmd}") {
|
||||||
|
let mut argv = argv.to_vec();
|
||||||
|
argv.extend_from_slice(command);
|
||||||
|
return argv;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(argv.len() + command.len());
|
||||||
|
for arg in argv {
|
||||||
|
if arg == "{cmd}" {
|
||||||
|
out.extend_from_slice(command);
|
||||||
|
} else {
|
||||||
|
out.push(arg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
/// Show `items` in the launcher named by `argv` and wait for a choice.
|
||||||
///
|
///
|
||||||
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
/// Blocking by design: the caller is a one-shot CLI invocation whose entire job
|
||||||
@@ -305,7 +370,8 @@ fn flavor_of(program: &str) -> Option<&'static MenuFlavor> {
|
|||||||
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
KNOWN_MENUS.iter().find(|flavor| flavor.program == base)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_path(program: &str) -> bool {
|
/// Whether `program` is an executable file on `PATH`.
|
||||||
|
pub fn on_path(program: &str) -> bool {
|
||||||
let Some(path) = std::env::var_os("PATH") else {
|
let Some(path) = std::env::var_os("PATH") else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -408,6 +474,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_configured_terminal_is_used_verbatim() {
|
||||||
|
let configured = vec!["my-term".to_string(), "-e".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
resolve_terminal(Some(&configured)),
|
||||||
|
Some(configured.clone())
|
||||||
|
);
|
||||||
|
// An empty list is "not configured", not "run nothing".
|
||||||
|
assert_eq!(
|
||||||
|
resolve_terminal(Some(&Vec::new())),
|
||||||
|
resolve_terminal(None),
|
||||||
|
"an empty terminal list must fall back to detection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_is_appended_unless_the_user_placed_it() {
|
||||||
|
let command = vec!["pdfs-prompt".to_string(), "--fzf".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
with_command(&["foot".into(), "--".into()], &command),
|
||||||
|
vec!["foot", "--", "pdfs-prompt", "--fzf"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
with_command(
|
||||||
|
&["term".into(), "-e".into(), "{cmd}".into(), "--hold".into()],
|
||||||
|
&command
|
||||||
|
),
|
||||||
|
vec!["term", "-e", "pdfs-prompt", "--fzf", "--hold"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_known_terminal_sets_the_same_app_id() {
|
||||||
|
for argv in KNOWN_TERMINALS {
|
||||||
|
assert!(
|
||||||
|
argv.iter().any(|arg| arg.contains(TERMINAL_APP_ID)),
|
||||||
|
"{argv:?} must be floatable by one window rule"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_configured_launcher_is_used_verbatim() {
|
fn a_configured_launcher_is_used_verbatim() {
|
||||||
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
let configured = vec!["my-menu".to_string(), "--pick".to_string()];
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
|||||||
/// Live device sync progress, absent when no pass is running.
|
/// Live device sync progress, absent when no pass is running.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub progress: Option<SyncProgress>,
|
pub progress: Option<SyncProgress>,
|
||||||
|
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -23,8 +23,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -165,12 +166,30 @@ pub fn open_named(policy: &OpenWith, path: &Path, name: &str, is_dir: bool) {
|
|||||||
spawn(&argv, path);
|
spawn(&argv, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Launch `argv`, detached from whatever is opening it.
|
||||||
|
///
|
||||||
|
/// The child gets its own process group and no inherited stdio, because the
|
||||||
|
/// caller may be about to exit and take its terminal with it: `pdfs-prompt
|
||||||
|
/// --fzf` runs inside a terminal it spawned for itself, and that terminal
|
||||||
|
/// closes the moment the selection is made. A child left in the terminal's
|
||||||
|
/// foreground process group is sent `SIGHUP` when the pty hangs up, so
|
||||||
|
/// "open this folder in a terminal" would open a window that dies immediately
|
||||||
|
/// — which is exactly what it did. Nothing here needs the caller's stdio in the
|
||||||
|
/// first place: every `argv` is another application, and a terminal one is
|
||||||
|
/// wrapped in its own emulator by the `terminal` rule flag.
|
||||||
fn spawn(argv: &[String], path: &Path) {
|
fn spawn(argv: &[String], path: &Path) {
|
||||||
let Some((program, args)) = argv.split_first() else {
|
let Some((program, args)) = argv.split_first() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
tracing::info!(path = %path.display(), command = ?argv, "opening");
|
||||||
if let Err(e) = Command::new(program).args(args).spawn() {
|
let spawned = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.process_group(0)
|
||||||
|
.spawn();
|
||||||
|
if let Err(e) = spawned {
|
||||||
tracing::error!(command = ?argv, "open failed: {e}");
|
tracing::error!(command = ?argv, "open failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,10 +47,18 @@ fn systemctl_query(args: &[&str]) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable the service so it survives reboots/logins and start it now. Called on
|
/// Enable the service so it survives reboots/logins, and bring it up now.
|
||||||
/// successful login.
|
/// Called on successful login.
|
||||||
|
///
|
||||||
|
/// `restart` rather than `enable --now`: the unit may already be active but
|
||||||
|
/// parked in the daemon's login-wait backoff (`pdfs daemon` sleeps between
|
||||||
|
/// keyring checks). `--now` is a no-op on an active unit, so the daemon would
|
||||||
|
/// keep sleeping and the mount would not appear until the next poll fired.
|
||||||
|
/// Restarting makes the new session take effect immediately, and starts the
|
||||||
|
/// unit when it is not running at all.
|
||||||
pub fn enable_start() -> bool {
|
pub fn enable_start() -> bool {
|
||||||
systemctl(&["enable", "--now"])
|
let enabled = systemctl(&["enable"]);
|
||||||
|
systemctl(&["restart"]) && enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the running mount without disabling it. Called by the tray's
|
/// Stop the running mount without disabling it. Called by the tray's
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[
|
|||||||
"Thumbs.db",
|
"Thumbs.db",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Suffixes of files that are, by convention, incomplete or throwaway while they
|
/// Suffixes that mean a file is incomplete *by construction*: only the program
|
||||||
/// wear the name: a download in flight, an editor's swap or backup, an atomic
|
/// writing it gives it this name, only while the write is in flight, and the
|
||||||
/// write's scratch copy. On an on-demand mount these should never seal a remote
|
/// finished article always arrives under a different one. On an on-demand mount
|
||||||
/// revision — the app renames the finished file to its real name, and only that
|
/// these should never seal a remote revision — the app renames the finished file
|
||||||
/// belongs on Drive. See [`is_transient_name`] and docs/BUGS.md B70.
|
/// to its real name, and only that belongs on Drive. See [`is_transient_name`]
|
||||||
|
/// and docs/BUGS.md B70.
|
||||||
|
///
|
||||||
|
/// `.tmp`/`.temp` are deliberately **not** here, though they are in
|
||||||
|
/// [`DEFAULT_IGNORE_PATTERNS`]. The two lists answer different questions. Skipping
|
||||||
|
/// a `report.tmp` during a mirror reconcile costs nothing — the file stays on
|
||||||
|
/// disk, and a later pass picks it up if the rules change. Parking one on the
|
||||||
|
/// on-demand mount is irreversible in practice: the only exit is a rename to a
|
||||||
|
/// finished name, and a file that is simply *called* `index.tmp` — a static-site
|
||||||
|
/// template, a tool's output — never gets one. It then never reaches Drive at
|
||||||
|
/// all, silently, while its bytes pin `staging/`. That is what a real Takeout
|
||||||
|
/// tree of `index.tmp`/`preview.tmp` gallery templates did: 43 creates parked
|
||||||
|
/// forever. The generic-scratch case they were added for renames within
|
||||||
|
/// milliseconds, so it never needed the park; the download suffixes below, which
|
||||||
|
/// stay in flight for minutes, are what B70 was actually about.
|
||||||
const TRANSIENT_SUFFIXES: &[&str] = &[
|
const TRANSIENT_SUFFIXES: &[&str] = &[
|
||||||
".crdownload", // Chromium/Brave/Edge
|
".crdownload", // Chromium/Brave/Edge
|
||||||
".part", // Firefox, wget, aria2c
|
".part", // Firefox, wget, aria2c
|
||||||
".partial", // some downloaders / older IE
|
".partial", // some downloaders / older IE
|
||||||
".download", // Safari / macOS
|
".download", // Safari / macOS
|
||||||
".tmp", // generic scratch
|
|
||||||
".temp", // generic scratch
|
|
||||||
".swp", // vim swap
|
".swp", // vim swap
|
||||||
".swx", // vim swap (secondary)
|
".swx", // vim swap (secondary)
|
||||||
];
|
];
|
||||||
@@ -319,8 +331,6 @@ mod tests {
|
|||||||
"movie.mkv.part",
|
"movie.mkv.part",
|
||||||
"big.iso.partial",
|
"big.iso.partial",
|
||||||
"photo.jpg.download",
|
"photo.jpg.download",
|
||||||
"export.xml.tmp",
|
|
||||||
"notes.TEMP",
|
|
||||||
".notes.txt.swp",
|
".notes.txt.swp",
|
||||||
"doc.txt~",
|
"doc.txt~",
|
||||||
".~lock.report.odt#",
|
".~lock.report.odt#",
|
||||||
@@ -340,6 +350,10 @@ mod tests {
|
|||||||
"archive.partly.zip", // ".partly" is not ".part"
|
"archive.partly.zip", // ".partly" is not ".part"
|
||||||
"template.dotx",
|
"template.dotx",
|
||||||
"readme",
|
"readme",
|
||||||
|
// A file that is *named* `.tmp` and never renamed: parking it would
|
||||||
|
// keep it off Drive for good.
|
||||||
|
"index.tmp",
|
||||||
|
"notes.TEMP",
|
||||||
] {
|
] {
|
||||||
assert!(!is_transient_name(name), "{name} should not be transient");
|
assert!(!is_transient_name(name), "{name} should not be transient");
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
//! Talking to a running `pdfs-tray`.
|
||||||
|
//!
|
||||||
|
//! The tray binds [`AppDirs::tray_socket`] to keep a single instance. The same
|
||||||
|
//! socket takes one command, [`QUIT`], so the desktop app can take the icon
|
||||||
|
//! away when the user hides it or stops Proton Drive.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
|
||||||
|
use crate::config::AppDirs;
|
||||||
|
|
||||||
|
/// The line that asks the tray to exit.
|
||||||
|
pub const QUIT: &str = "quit";
|
||||||
|
|
||||||
|
/// Ask a running tray to exit. False when no tray answers.
|
||||||
|
pub fn quit(dirs: &AppDirs) -> bool {
|
||||||
|
UnixStream::connect(dirs.tray_socket())
|
||||||
|
.and_then(|mut stream| stream.write_all(format!("{QUIT}\n").as_bytes()))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
@@ -5,10 +5,14 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
description = "FUSE on-demand filesystem for Proton Drive"
|
description = "FUSE on-demand filesystem for Proton Drive"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pdfs-core.workspace = true
|
pdfs-core.workspace = true
|
||||||
|
zip.workspace = true
|
||||||
|
sha1.workspace = true
|
||||||
proton-drive-rs.workspace = true
|
proton-drive-rs.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -16,6 +20,7 @@ tracing.workspace = true
|
|||||||
fuser.workspace = true
|
fuser.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
notify = "8"
|
notify = "8"
|
||||||
parking_lot.workspace = true
|
parking_lot.workspace = true
|
||||||
@@ -221,6 +221,84 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create an album and return its uid. The listing is refreshed before
|
||||||
|
/// answering, so the next [`Core::albums`] already shows it.
|
||||||
|
pub(crate) fn create_album(&self, name: &str) -> CoreResult<String> {
|
||||||
|
let uid = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().create_album(name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "create album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(uid.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rename_album(&self, album: &NodeUid, name: &str) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().rename_album(album, name))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "rename album"))?;
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete an album. Its photos stay in the timeline unless `delete_photos`,
|
||||||
|
/// which only matters for photos that exist in the album alone.
|
||||||
|
pub(crate) fn delete_album(&self, album: &NodeUid, delete_photos: bool) -> CoreResult<()> {
|
||||||
|
self.rt
|
||||||
|
.block_on(self.photos().delete_album(album, delete_photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "delete album"))?;
|
||||||
|
let key = album.to_string();
|
||||||
|
if let Err(error) = self.db.album_photos_replace(&key, &[]) {
|
||||||
|
warn!(%album, %error, "a deleted album's photos could not be dropped");
|
||||||
|
}
|
||||||
|
self.after_album_change(None);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add photos to an album, per photo: `(added, failed)`.
|
||||||
|
pub(crate) fn add_to_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().add_photos_to_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "add to album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take photos out of an album, per photo: `(removed, failed)`. The photos
|
||||||
|
/// stay in the timeline.
|
||||||
|
pub(crate) fn remove_from_album(
|
||||||
|
&self,
|
||||||
|
album: &NodeUid,
|
||||||
|
photos: &[NodeUid],
|
||||||
|
) -> CoreResult<AlbumOutcome> {
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.photos().remove_photos_from_album(album, photos))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "remove from album"))?;
|
||||||
|
self.after_album_change(Some(album));
|
||||||
|
Ok(album_outcome(outcomes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the persisted listing, and `album`'s contents when given, up to
|
||||||
|
/// date after a change made here, so the reply is followed by a listing
|
||||||
|
/// that shows it. A failed refresh only leaves them stale: the change
|
||||||
|
/// itself went through.
|
||||||
|
fn after_album_change(&self, album: Option<&NodeUid>) {
|
||||||
|
self.invalidate_albums();
|
||||||
|
if let Err(error) = self.rt.block_on(self.refresh_albums()) {
|
||||||
|
warn!(%error, "album listing refresh after a change failed");
|
||||||
|
}
|
||||||
|
if let Some(album) = album
|
||||||
|
&& let Err(error) = self.rt.block_on(self.refresh_album(album))
|
||||||
|
{
|
||||||
|
warn!(%album, %error, "album refresh after a change failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the album listing's and every album's freshness stamp, so the next
|
/// Drop the album listing's and every album's freshness stamp, so the next
|
||||||
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
/// request re-enumerates. Paired with [`Core::invalidate_photos`] — a change
|
||||||
/// to the timeline can just as easily be a change to an album.
|
/// to the timeline can just as easily be a change to an album.
|
||||||
@@ -230,6 +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.
|
/// Project a persisted album into the wire item a front-end paints.
|
||||||
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
fn album_info(album: StoredAlbum) -> AlbumInfo {
|
||||||
AlbumInfo {
|
AlbumInfo {
|
||||||
|
|||||||
@@ -425,6 +425,165 @@ pub(super) async fn run_event_sync(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The state key holding the photos volume's event cursor. Separate from the
|
||||||
|
/// Drive cursor because the two volumes have their own event streams, and one
|
||||||
|
/// cursor cannot address both.
|
||||||
|
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
|
||||||
|
|
||||||
|
/// Poll the photos volume's event stream, so a photo deleted or added on another
|
||||||
|
/// device shows up in the gallery within one poll instead of on the timeline's
|
||||||
|
/// freshness window.
|
||||||
|
///
|
||||||
|
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
|
||||||
|
/// part of the FUSE mount, so there is no inode space to converge and nothing to
|
||||||
|
/// invalidate in the kernel. A trash or a delete removes the rows behind the
|
||||||
|
/// gallery; anything else only drops the timeline's freshness stamp, and the
|
||||||
|
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
|
||||||
|
/// file.
|
||||||
|
pub(super) async fn run_photos_event_sync(core: Core) {
|
||||||
|
let photos = core.photos();
|
||||||
|
// The photos volume only exists once the user has used Photos at all, and
|
||||||
|
// the daemon may well start before the network does.
|
||||||
|
let scope = loop {
|
||||||
|
match photos.get_photos_root().await {
|
||||||
|
Ok(Some(root)) => break root.tree_event_scope_id(),
|
||||||
|
Ok(None) => {
|
||||||
|
debug!("no photos volume; photo event sync not started");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the photos root failed; retrying");
|
||||||
|
tokio::time::sleep(ONLINE_PROBE_MAX).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cursor: Option<DriveEventId> = core
|
||||||
|
.db
|
||||||
|
.state_str(PHOTOS_EVENT_CURSOR)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(DriveEventId::from);
|
||||||
|
info!(?cursor, "photo event sync started");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(POLL_INTERVAL).await;
|
||||||
|
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
|
||||||
|
Ok(events) => events,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "photo event poll failed; retrying after interval");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(head) = events.last().map(|event| event.id().clone()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
apply_photo_events(&core, &events);
|
||||||
|
// One cursor write per batch, after the batch is applied: a crash in
|
||||||
|
// between replays events whose effects are idempotent.
|
||||||
|
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
|
||||||
|
warn!(%error, "persisting the photo event cursor failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cursor = Some(head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one batch of photos-volume events asks the gallery's tables to do.
|
||||||
|
#[derive(Debug, Default, PartialEq)]
|
||||||
|
pub(super) struct PhotoEventPlan {
|
||||||
|
/// Photos that left the volume, by delete or by trash.
|
||||||
|
pub(super) gone: Vec<String>,
|
||||||
|
/// Photos whose node changed, so their stored metadata is no longer trusted.
|
||||||
|
pub(super) touched: Vec<String>,
|
||||||
|
/// Whether the whole timeline has to be read again.
|
||||||
|
pub(super) full: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhotoEventPlan {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.gone.is_empty() && self.touched.is_empty() && !self.full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one batch of photos-volume events. Pure, so the classification is
|
||||||
|
/// testable without a volume behind it.
|
||||||
|
///
|
||||||
|
/// `is_echo` claims an event as this daemon's own write. The feed replays our
|
||||||
|
/// writes back at us and a `NodeUpdated` carries no revision id, so without this
|
||||||
|
/// every favorite the user toggles here would mark its own photo stale and buy
|
||||||
|
/// a resolve of a node we just wrote.
|
||||||
|
fn classify_photo_events(
|
||||||
|
events: &[DriveEvent],
|
||||||
|
mut is_echo: impl FnMut(&NodeUid) -> bool,
|
||||||
|
) -> PhotoEventPlan {
|
||||||
|
let mut plan = PhotoEventPlan::default();
|
||||||
|
for event in events {
|
||||||
|
match event {
|
||||||
|
DriveEvent::NodeDeleted { node_uid, .. } => plan.gone.push(node_uid.to_string()),
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
node_uid,
|
||||||
|
is_trashed,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if *is_trashed {
|
||||||
|
plan.gone.push(node_uid.to_string());
|
||||||
|
} else if !is_echo(node_uid) {
|
||||||
|
// An untrash, a rename, a favorite toggled on the phone: the
|
||||||
|
// event says which node changed but never what about it, so
|
||||||
|
// the uid is marked stale and the next refresh reads it.
|
||||||
|
plan.touched.push(node_uid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The daemon cannot know what it missed here, so everything it
|
||||||
|
// thinks it knows about every photo is dropped.
|
||||||
|
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
|
||||||
|
plan.full = true;
|
||||||
|
}
|
||||||
|
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply one batch of photos-volume events to the gallery's tables.
|
||||||
|
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
|
||||||
|
let plan = classify_photo_events(events, |uid| core.take_self_change(uid));
|
||||||
|
let PhotoEventPlan {
|
||||||
|
gone,
|
||||||
|
touched,
|
||||||
|
full,
|
||||||
|
} = &plan;
|
||||||
|
if !gone.is_empty() {
|
||||||
|
match core.db.photos_delete(gone) {
|
||||||
|
Ok(removed) if removed > 0 => {
|
||||||
|
debug!(removed, "photos removed by a remote event");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A stale mark is what makes the next refresh cheap: it reads these photos'
|
||||||
|
// nodes and carries every other photo's metadata as it stands.
|
||||||
|
if !touched.is_empty() {
|
||||||
|
match core.db.photos_mark_unresolved(touched) {
|
||||||
|
Ok(marked) if marked > 0 => debug!(marked, "photos changed by a remote event"),
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => warn!(%error, "marking remotely changed photos failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if *full {
|
||||||
|
match core.db.photos_mark_all_unresolved() {
|
||||||
|
Ok(marked) => debug!(marked, "the photo event stream lost continuity"),
|
||||||
|
Err(error) => warn!(%error, "marking the whole timeline unresolved failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !plan.is_empty() {
|
||||||
|
core.invalidate_photos();
|
||||||
|
core.invalidate_albums();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
/// Keep the local-file index fresh for the launcher prompt's "This computer"
|
||||||
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
|
||||||
/// then sleeps; runs on its own thread for the life of the daemon.
|
/// then sleeps; runs on its own thread for the life of the daemon.
|
||||||
@@ -487,7 +646,12 @@ fn scan_local_once(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let excludes = localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
let mut excludes =
|
||||||
|
localindex::default_excludes(mountpoint, &dirs.state_dir(), &dirs.cache_dir());
|
||||||
|
// Resolved per scan rather than once at startup: mounts come and go, and a
|
||||||
|
// mount that appeared since the daemon started is exactly the one most
|
||||||
|
// likely to be a half-alive network filesystem (see `nested_mount_points`).
|
||||||
|
excludes.extend(localindex::nested_mount_points(&home));
|
||||||
indexing.store(true, Ordering::Relaxed);
|
indexing.store(true, Ordering::Relaxed);
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
|
||||||
@@ -541,6 +705,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,4 +953,96 @@ mod tests {
|
|||||||
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
assert!(acknowledge_applied_event(&db, &event, applied).is_err());
|
||||||
assert!(db.get_event_cursor().unwrap().is_none());
|
assert!(db.get_event_cursor().unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A photo trashed or deleted elsewhere leaves the gallery; one that only
|
||||||
|
/// changed is marked stale, so the next refresh reads that node and no other.
|
||||||
|
#[test]
|
||||||
|
fn a_photo_event_names_what_it_changed() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::NodeDeleted {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("deleted"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
node_uid: uid("trashed"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: true,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e3"),
|
||||||
|
node_uid: uid("favorited"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan,
|
||||||
|
PhotoEventPlan {
|
||||||
|
gone: vec![uid("deleted").to_string(), uid("trashed").to_string()],
|
||||||
|
touched: vec![uid("favorited").to_string()],
|
||||||
|
full: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed replays this daemon's own writes. Applying one would cost a
|
||||||
|
/// resolve of a node whose changed field the daemon just wrote itself.
|
||||||
|
#[test]
|
||||||
|
fn our_own_photo_write_is_not_a_remote_change() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[DriveEvent::NodeUpdated {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
node_uid: uid("ours"),
|
||||||
|
parent_node_uid: None,
|
||||||
|
is_trashed: false,
|
||||||
|
is_shared: false,
|
||||||
|
}],
|
||||||
|
|claimed| *claimed == uid("ours"),
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gap in the stream means the daemon cannot know what it missed, so it
|
||||||
|
/// keeps nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_lost_photo_event_stream_asks_for_everything() {
|
||||||
|
for event in [
|
||||||
|
DriveEvent::ContinuityLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::ScopeAccessLost {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let plan = classify_photo_events(std::slice::from_ref(&event), |_| false);
|
||||||
|
assert!(plan.full, "{event:?}");
|
||||||
|
assert!(plan.gone.is_empty());
|
||||||
|
assert!(plan.touched.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bookkeeping events change nothing about the gallery, and must not buy a
|
||||||
|
/// refresh of it.
|
||||||
|
#[test]
|
||||||
|
fn photo_bookkeeping_events_ask_for_nothing() {
|
||||||
|
let plan = classify_photo_events(
|
||||||
|
&[
|
||||||
|
DriveEvent::CursorAdvanced {
|
||||||
|
id: DriveEventId::from("e1"),
|
||||||
|
},
|
||||||
|
DriveEvent::SharedWithMeUpdated {
|
||||||
|
id: DriveEventId::from("e2"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|_| false,
|
||||||
|
);
|
||||||
|
assert_eq!(plan, PhotoEventPlan::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+510
-41
@@ -11,24 +11,44 @@
|
|||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write as _};
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
|
||||||
TransferDirection,
|
TransferDirection, TrashFailure,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use super::transfers::CountingReader;
|
use super::transfers::CountingReader;
|
||||||
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||||
|
|
||||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// Answer a [`CtlRequest::StopSharing`]: log it, and turn a partial removal
|
||||||
|
/// into an error that names what is still shared.
|
||||||
|
fn stop_sharing_reply(
|
||||||
|
core: &Core,
|
||||||
|
target: &str,
|
||||||
|
result: CoreResult<super::sharing::StopSharingOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
match result.map(|outcome| outcome.into_result()) {
|
||||||
|
Ok(Ok(message)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, "stopped sharing", true);
|
||||||
|
CtlResponse::Ok { message }
|
||||||
|
}
|
||||||
|
Ok(Err(left)) => {
|
||||||
|
core.log_activity(ActivityKind::Unshare, target, &left, false);
|
||||||
|
CtlResponse::error(CoreError::remote(left))
|
||||||
|
}
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A client that connects, sends a request and then never reads its reply used
|
/// A client that connects, sends a request and then never reads its reply used
|
||||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
@@ -46,6 +66,32 @@ const MAX_ROW_LIMIT: usize = 1000;
|
|||||||
fn clamp_limit(limit: usize) -> usize {
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
limit.min(MAX_ROW_LIMIT)
|
limit.min(MAX_ROW_LIMIT)
|
||||||
}
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -67,6 +113,16 @@ impl Drop for ControlHandlerPermit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handler threads in use, for the diagnostics. At the limit the daemon refuses
|
||||||
|
/// connections, which a front-end cannot tell apart from a dead daemon.
|
||||||
|
pub(crate) fn active_handlers() -> usize {
|
||||||
|
ACTIVE_CONTROL_HANDLERS.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handler_limit() -> usize {
|
||||||
|
MAX_CONTROL_HANDLERS
|
||||||
|
}
|
||||||
|
|
||||||
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String>> {
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
@@ -134,17 +190,70 @@ fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Cor
|
|||||||
/// the mount root.
|
/// the mount root.
|
||||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() {
|
let relative = if p.is_absolute() {
|
||||||
p.strip_prefix(mountpoint)
|
p.strip_prefix(mountpoint)
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||||
} else {
|
} else {
|
||||||
Ok(p.to_path_buf())
|
Ok(p.to_path_buf())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(relative)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one control-socket connection: read a single JSON request line,
|
/// Handle one control-socket connection: read a single JSON request line,
|
||||||
/// dispatch it against `core`, and write back a JSON response line.
|
/// dispatch it against `core`, and write back a JSON response line.
|
||||||
|
/// Refresh scopes being served right now, one entry per running refresh.
|
||||||
|
///
|
||||||
|
/// A `Vec` because there are three scopes and at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// handlers, so a set would buy nothing.
|
||||||
|
static REFRESHING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// What makes two refreshes the same work: the scope, and for a directory the
|
||||||
|
/// folder, since refreshing one folder says nothing about another. A full
|
||||||
|
/// photos refresh is not the same work as an ordinary one — it forgets what the
|
||||||
|
/// daemon knows first — so it keys apart.
|
||||||
|
fn scope_key(scope: &RefreshScope) -> String {
|
||||||
|
match scope {
|
||||||
|
RefreshScope::Dir { path } => format!("dir:{path}"),
|
||||||
|
RefreshScope::Trash => "trash".to_string(),
|
||||||
|
RefreshScope::Photos { full: true } => "photos:full".to_string(),
|
||||||
|
RefreshScope::Photos { full: false } => "photos".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds one refresh scope for as long as it is being served. `acquire`
|
||||||
|
/// returns `None` when that scope is already in flight.
|
||||||
|
struct RefreshGuard(String);
|
||||||
|
|
||||||
|
impl RefreshGuard {
|
||||||
|
fn acquire(scope: &RefreshScope) -> Option<Self> {
|
||||||
|
let key = scope_key(scope);
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if running.contains(&key) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
running.push(key.clone());
|
||||||
|
Some(Self(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RefreshGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(at) = running.iter().position(|key| *key == self.0) {
|
||||||
|
running.swap_remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: UnixStream) {
|
||||||
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
if let Err(e) = stream.set_read_timeout(Some(CONTROL_READ_TIMEOUT)) {
|
||||||
warn!(error = %e, "control: could not set request timeout");
|
warn!(error = %e, "control: could not set request timeout");
|
||||||
@@ -169,6 +278,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Registered before the request is even parsed, so a handler that wedges
|
||||||
|
// inside the daemon shows up by name in `pdfs diagnostics`.
|
||||||
|
let _inflight = super::diagnostics::InflightGuard::new(super::diagnostics::request_kind(&line));
|
||||||
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
|
||||||
Ok(CtlRequest::Status) => {
|
Ok(CtlRequest::Status) => {
|
||||||
let pins = core.cache.list_pins();
|
let pins = core.cache.list_pins();
|
||||||
@@ -180,6 +292,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
failing_error: queued.last_error.clone(),
|
failing_error: queued.last_error.clone(),
|
||||||
staged_bytes: staging.bytes,
|
staged_bytes: staging.bytes,
|
||||||
staged_oldest_secs: staging.oldest_secs,
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
mountpoint: mountpoint.display().to_string(),
|
mountpoint: mountpoint.display().to_string(),
|
||||||
pinned: pins.len(),
|
pinned: pins.len(),
|
||||||
@@ -216,7 +330,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||||
pins: core.cache.list_pins(),
|
pins: core.list_pins(),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
@@ -225,29 +339,69 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::Refresh { scope }) => {
|
Ok(CtlRequest::Refresh { scope }) => match RefreshGuard::acquire(&scope) {
|
||||||
let result = match &scope {
|
// The same scope is already being refreshed by another handler.
|
||||||
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
// Running it again cannot make the answer newer, and the duplicate
|
||||||
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
// would spend its whole run contending for the state and database
|
||||||
RefreshScope::Trash => {
|
// locks the first one holds — which is how five stacked photo
|
||||||
core.invalidate_trash();
|
// refreshes sat in flight for twenty-three minutes at mount time.
|
||||||
Ok(())
|
None => {
|
||||||
|
debug!(scope = %scope_key(&scope), "a refresh of this scope is already running");
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "refreshing".to_string(),
|
||||||
}
|
}
|
||||||
RefreshScope::Photos => {
|
|
||||||
core.invalidate_photos();
|
|
||||||
// Whatever changed the timeline can just as easily have
|
|
||||||
// changed an album; one scope covers the whole photos view.
|
|
||||||
core.invalidate_albums();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => CtlResponse::Ok {
|
|
||||||
message: "refreshed".to_string(),
|
|
||||||
},
|
|
||||||
Err(e) => CtlResponse::error(e),
|
|
||||||
}
|
}
|
||||||
}
|
Some(_refreshing) => {
|
||||||
|
let result = match &scope {
|
||||||
|
RefreshScope::Dir { path } => route_to_mount(core, mountpoint, path)
|
||||||
|
.and_then(|(core, rel)| core.refresh_dir(&rel)),
|
||||||
|
RefreshScope::Trash => {
|
||||||
|
core.invalidate_trash();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RefreshScope::Photos { full } if !full && core.timeline_refreshing() => {
|
||||||
|
// A refresh is already running and will publish exactly the
|
||||||
|
// timeline this request asks for. Doing the work again
|
||||||
|
// cannot make it newer, and the invalidation plus the
|
||||||
|
// album read contend with the running refresh for the
|
||||||
|
// database lock — which is how a handful of stacked photo
|
||||||
|
// refreshes at mount time left five control requests
|
||||||
|
// in flight for twenty-three minutes.
|
||||||
|
debug!("a photos refresh is already running; joining it");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
RefreshScope::Photos { full } => {
|
||||||
|
core.invalidate_photos();
|
||||||
|
// A full refresh is "forget what you know", not a second code
|
||||||
|
// path: the ordinary refresh then finds every photo stale.
|
||||||
|
if let Some(Err(e)) = full.then(|| core.db.photos_mark_all_unresolved()) {
|
||||||
|
warn!(error = %e, "marking the whole timeline unresolved failed");
|
||||||
|
}
|
||||||
|
// Whatever changed the timeline can just as easily have
|
||||||
|
// changed an album; one scope covers the whole photos view.
|
||||||
|
core.invalidate_albums();
|
||||||
|
// The refresh is started here, not awaited. A library of
|
||||||
|
// twelve thousand photos is re-read in chunks of
|
||||||
|
// `TIMELINE_ENRICH_CHUNK` nodes, which takes minutes —
|
||||||
|
// longer than the client's read bound, so awaiting it
|
||||||
|
// returned `Resource temporarily unavailable` to a caller
|
||||||
|
// whose refresh was in fact running fine (B92). The front
|
||||||
|
// end follows it with `PhotosRefreshStatus`.
|
||||||
|
core.spawn_timeline_refresh();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match scope {
|
||||||
|
RefreshScope::Photos { .. } => "refreshing".to_string(),
|
||||||
|
_ => "refreshed".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
Ok(CtlRequest::PhotosTimeline {
|
Ok(CtlRequest::PhotosTimeline {
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
@@ -267,13 +421,21 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
|
||||||
|
Ok(items) => CtlResponse::Photos {
|
||||||
|
available: true,
|
||||||
|
items,
|
||||||
|
counts: None,
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
|
||||||
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
|
||||||
Ok(()) => CtlResponse::Ok {
|
Ok(()) => CtlResponse::Ok {
|
||||||
message: if favorite {
|
message: if favorite {
|
||||||
"added to favourites".to_string()
|
"added to favorites".to_string()
|
||||||
} else {
|
} else {
|
||||||
"removed from favourites".to_string()
|
"removed from favorites".to_string()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -305,6 +467,38 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
Ok(CtlRequest::CreateAlbum { name }) => match core.create_album(&name) {
|
||||||
|
Ok(uid) => CtlResponse::AlbumCreated { uid },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RenameAlbum { uid, name }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.rename_album(&album, &name) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!("renamed album to {name}"),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::DeleteAlbum { uid, delete_photos }) => match parse_uid(&uid) {
|
||||||
|
Some(album) => match core.delete_album(&album, delete_photos) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: "album deleted".to_string(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
None => CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}"))),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::AddToAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.add_to_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::RemoveFromAlbum { uid, photos }) => {
|
||||||
|
album_change(&uid, &photos, |album, photos| {
|
||||||
|
core.remove_from_album(album, photos)
|
||||||
|
})
|
||||||
|
}
|
||||||
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) {
|
||||||
Ok(months) => CtlResponse::PhotoMonths {
|
Ok(months) => CtlResponse::PhotoMonths {
|
||||||
months: months
|
months: months
|
||||||
@@ -320,6 +514,50 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
items: core.photo_thumbs(&parsed),
|
items: core.photo_thumbs(&parsed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
||||||
|
Ok(parsed) => match core.trash_photos(&parsed) {
|
||||||
|
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||||
|
trashed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::FileThumbs { items, generation }) => {
|
||||||
|
match core.file_thumbs(&items, generation) {
|
||||||
|
Some(items) => CtlResponse::Thumbs { items },
|
||||||
|
None => CtlResponse::FileThumbsStale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ReserveFileThumbGeneration) => CtlResponse::FileThumbGeneration {
|
||||||
|
generation: core.reserve_file_thumb_generation(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelFileThumbs { generation }) => {
|
||||||
|
core.cancel_file_thumbs(generation);
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "thumbnail requests cancelled".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::StartThumbnailBuild { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => match core.start_thumbnail_build(rel) {
|
||||||
|
Ok(status) => CtlResponse::ThumbnailBuild { status },
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Err(error) => CtlResponse::error(error),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::CancelThumbnailBuild) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.cancel_thumbnail_build(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::ThumbnailBuildStatus) => CtlResponse::ThumbnailBuild {
|
||||||
|
status: core.thumbnail_build_status(),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::PhotosRefreshStatus) => CtlResponse::PhotosRefresh {
|
||||||
|
running: core.timeline_refreshing(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
Ok(CtlRequest::OpenPhoto { uid }) => match parse_uid(&uid) {
|
||||||
Some(u) => match core.open_photo(&u) {
|
Some(u) => match core.open_photo(&u) {
|
||||||
Ok(p) => CtlResponse::FilePath {
|
Ok(p) => CtlResponse::FilePath {
|
||||||
@@ -329,6 +567,81 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
|
None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))),
|
||||||
},
|
},
|
||||||
|
// The scan alone walks every archive's central directory, so even the
|
||||||
|
// dry run goes to a background thread and is read back through
|
||||||
|
// `ImportStatus` rather than answered inline.
|
||||||
|
Ok(CtlRequest::ImportTakeout { archives, dry_run }) => {
|
||||||
|
if archives.is_empty() {
|
||||||
|
CtlResponse::error(CoreError::invalid("no Takeout archives given"))
|
||||||
|
} else {
|
||||||
|
let paths: Vec<PathBuf> = archives.into_iter().map(PathBuf::from).collect();
|
||||||
|
let missing: Vec<String> = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|path| !path.is_file())
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
CtlResponse::error(CoreError::not_found(format!(
|
||||||
|
"cannot read: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
let count = paths.len();
|
||||||
|
match core.spawn_takeout_import(paths, dry_run) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: format!(
|
||||||
|
"{} {count} archive(s)",
|
||||||
|
if dry_run { "scanning" } else { "importing" }
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::ImportStatus) => {
|
||||||
|
let (running, summary) = crate::takeout::import_status();
|
||||||
|
CtlResponse::ImportStatus { running, summary }
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::CancelImport) => {
|
||||||
|
if crate::takeout::cancel_import() {
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "stopping the import".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CtlResponse::error(CoreError::invalid("no import is running"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The scan reads the whole persisted timeline and every candidate is a
|
||||||
|
// download plus an upload, so this goes to a background thread and is
|
||||||
|
// read back through `RedateStatus`.
|
||||||
|
Ok(CtlRequest::RedatePhotos {
|
||||||
|
dry_run,
|
||||||
|
limit,
|
||||||
|
range,
|
||||||
|
}) => match core.spawn_redate(dry_run, limit, range) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: if dry_run {
|
||||||
|
"checking photo dates".to_string()
|
||||||
|
} else {
|
||||||
|
"fixing photo dates".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::RedateStatus) => {
|
||||||
|
let (running, summary) = crate::redate::redate_status();
|
||||||
|
CtlResponse::RedateStatus { running, summary }
|
||||||
|
}
|
||||||
|
Ok(CtlRequest::CancelRedate) => {
|
||||||
|
if crate::redate::cancel_redate() {
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "stopping the re-date".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CtlResponse::error(CoreError::invalid("no photo re-date is running"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::UploadPhoto {
|
Ok(CtlRequest::UploadPhoto {
|
||||||
name,
|
name,
|
||||||
media_type,
|
media_type,
|
||||||
@@ -396,7 +709,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
Ok(CtlRequest::Search {
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
scope,
|
||||||
|
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
@@ -545,6 +862,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::Diagnostics) => CtlResponse::Diagnostics {
|
||||||
|
diagnostics: core.diagnostics(),
|
||||||
|
},
|
||||||
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
Ok(CtlRequest::ListTrash) => match core.list_trash() {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -574,9 +894,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
let items = if n == 1 {
|
||||||
|
"1 item".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} items")
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: format!("emptied trash ({n} item(s))"),
|
message: format!("emptied trash ({items})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -656,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(CtlRequest::ListDevices) => match core.list_devices() {
|
||||||
Ok(items) => CtlResponse::Devices { items },
|
Ok(items) => CtlResponse::Devices { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
@@ -739,6 +1087,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => {
|
||||||
|
match core.set_sync_folder_paused(id, paused) {
|
||||||
|
Ok(()) => CtlResponse::Ok {
|
||||||
|
message: match paused {
|
||||||
|
true => format!("folder {id} paused"),
|
||||||
|
false => format!("folder {id} resumed"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SyncNow { id }) => {
|
Ok(CtlRequest::SyncNow { id }) => {
|
||||||
core.sync_now(id);
|
core.sync_now(id);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -748,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 },
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::RestoreSyncFolders { items }) => {
|
Ok(CtlRequest::ListDeviceRestorableFolders { device }) => {
|
||||||
|
match core.list_restorable_folders(Some(&device)) {
|
||||||
|
Ok(items) => CtlResponse::RestorableFolders { items },
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
request @ (CtlRequest::RestoreSyncFolders { .. }
|
||||||
|
| CtlRequest::RestoreDeviceFolders { .. }),
|
||||||
|
) => {
|
||||||
|
let (device, items) = match request {
|
||||||
|
CtlRequest::RestoreDeviceFolders { device, items } => (Some(device), items),
|
||||||
|
CtlRequest::RestoreSyncFolders { items } => (None, items),
|
||||||
|
_ => unreachable!("matched above"),
|
||||||
|
};
|
||||||
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
// Like AddSyncFolder: each restored folder downloads a whole tree,
|
||||||
// which outlasts the socket timeout. Ack, then work.
|
// which outlasts the socket timeout. Ack, then work.
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::spawn(move || match core.restore_sync_folders(&items) {
|
std::thread::spawn(move || {
|
||||||
Ok(message) => {
|
match core.restore_sync_folders(&items, device.as_deref()) {
|
||||||
core.touch_profile();
|
Ok(message) => {
|
||||||
core.log_activity(ActivityKind::Download, &message, "", true);
|
core.touch_profile();
|
||||||
}
|
core.log_activity(ActivityKind::Download, &message, "", true);
|
||||||
Err(e) => {
|
}
|
||||||
warn!(error = %e, "restore failed");
|
Err(e) => {
|
||||||
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
warn!(error = %e, "restore failed");
|
||||||
|
core.log_activity(ActivityKind::Download, "restore folders", &e, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
@@ -934,6 +1346,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(CtlRequest::StopSharing { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
|
Ok(rel) => stop_sharing_reply(core, &path, core.stop_sharing(&rel)),
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
},
|
||||||
|
Ok(CtlRequest::StopSharingByUid { uid }) => {
|
||||||
|
stop_sharing_reply(core, &uid, core.stop_sharing_by_uid(&uid))
|
||||||
|
}
|
||||||
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListRevisions { path }) => match rel_to_mount(mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_revisions(&rel) {
|
Ok(rel) => match core.list_revisions(&rel) {
|
||||||
Ok(items) => CtlResponse::Revisions { items },
|
Ok(items) => CtlResponse::Revisions { items },
|
||||||
@@ -1176,4 +1595,54 @@ mod request_limit_tests {
|
|||||||
std::io::ErrorKind::InvalidData
|
std::io::ErrorKind::InvalidData
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_paths_inside_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton/Photos/2026").unwrap(),
|
||||||
|
Path::new("Photos/2026")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rel_to_mount(mountpoint, "/mnt/proton").unwrap(),
|
||||||
|
Path::new("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_paths_that_escape_the_mountpoint() {
|
||||||
|
let mountpoint = Path::new("/mnt/proton");
|
||||||
|
assert!(rel_to_mount(mountpoint, "../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "Photos/../../escape").is_err());
|
||||||
|
assert!(rel_to_mount(mountpoint, "/mnt/elsewhere").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Two refreshes of the same scope are one piece of work; two refreshes of
|
||||||
|
/// different scopes are not.
|
||||||
|
#[test]
|
||||||
|
fn a_scope_refreshes_once_at_a_time() {
|
||||||
|
let photos = RefreshScope::Photos { full: false };
|
||||||
|
let held = RefreshGuard::acquire(&photos).expect("first refresh runs");
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_none());
|
||||||
|
// A full refresh forgets what the daemon knows, so it is other work.
|
||||||
|
assert!(RefreshGuard::acquire(&RefreshScope::Photos { full: true }).is_some());
|
||||||
|
assert!(
|
||||||
|
RefreshGuard::acquire(&RefreshScope::Dir {
|
||||||
|
path: "a".to_string()
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(held);
|
||||||
|
assert!(RefreshGuard::acquire(&photos).is_some());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +128,7 @@ impl Core {
|
|||||||
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
location.mounted = self.states.is_mounted_at(Path::new(&location.local_path));
|
||||||
if let MountKind::Device { sync_folder_id } = &location.kind {
|
if let MountKind::Device { sync_folder_id } = &location.kind {
|
||||||
location.progress = progress.get(sync_folder_id).cloned();
|
location.progress = progress.get(sync_folder_id).cloned();
|
||||||
|
location.paused = self.sync_folder_paused(*sync_folder_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(locations)
|
Ok(locations)
|
||||||
@@ -226,6 +227,39 @@ impl Core {
|
|||||||
Ok(format!("adopted device {name}"))
|
Ok(format!("adopted device {name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The device a restore reads from: this machine's own for `None`, else
|
||||||
|
/// the registered device with that uid.
|
||||||
|
///
|
||||||
|
/// Another device is looked up without touching the cached device row —
|
||||||
|
/// restoring its folders here must not make this machine back up as it.
|
||||||
|
/// Its folders are then attached with that device's share id, so they sync
|
||||||
|
/// against the other computer's tree.
|
||||||
|
pub(crate) fn restore_source(&self, device: Option<&str>) -> CoreResult<StoredDevice> {
|
||||||
|
let Some(uid) = device else {
|
||||||
|
return self.ensure_device();
|
||||||
|
};
|
||||||
|
let remote = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.enumerate_devices())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "enumerate devices"))?;
|
||||||
|
let d = remote
|
||||||
|
.into_iter()
|
||||||
|
.find(|d| d.uid.to_string() == uid)
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no device {uid} in this account")))?;
|
||||||
|
Ok(StoredDevice {
|
||||||
|
uid: d.uid.to_string(),
|
||||||
|
share_id: d.share_id.to_string(),
|
||||||
|
root_uid: d.root_folder_uid.to_string(),
|
||||||
|
name: d
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.ok()
|
||||||
|
.unwrap_or("(unnamed device)")
|
||||||
|
.to_string(),
|
||||||
|
created: d.creation_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
// ---- device folder sync (devices.md, Phase 1) -------------------------
|
||||||
|
|
||||||
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
/// Auto-register (or recover) this machine as a Proton Drive Device, caching
|
||||||
@@ -448,7 +482,8 @@ impl Core {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
let live = progress.get(&f.id).cloned();
|
let live = progress.get(&f.id).cloned();
|
||||||
sync_folder_info(f, live)
|
let paused = self.sync_folder_paused(f.id);
|
||||||
|
sync_folder_info(f, live, paused)
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -561,6 +596,7 @@ impl Core {
|
|||||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||||
}
|
}
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
self.clear_sync_folder_paused(id);
|
||||||
// Stop watching the folder we just dropped.
|
// Stop watching the folder we just dropped.
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
if delete_remote
|
if delete_remote
|
||||||
@@ -625,6 +661,13 @@ impl Core {
|
|||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
|
// it up front rather than queue it indefinitely.
|
||||||
|
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this folder is paused; resume it before changing its mode",
|
||||||
|
));
|
||||||
|
}
|
||||||
if current_mode == target_mode {
|
if current_mode == target_mode {
|
||||||
let mode_str = target_mode.as_str();
|
let mode_str = target_mode.as_str();
|
||||||
let lock = self.sync_lock(id);
|
let lock = self.sync_lock(id);
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! What the daemon is doing right now, for the case where it has stopped doing
|
||||||
|
//! anything at all.
|
||||||
|
//!
|
||||||
|
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
|
||||||
|
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
|
||||||
|
//! manual `systemctl --user restart`. There was no panic, so nothing in the
|
||||||
|
//! journal said what it had been waiting on — and nothing in the daemon could be
|
||||||
|
//! asked, either. This module is the answer to "what was it stuck on?".
|
||||||
|
//!
|
||||||
|
//! Everything here is built to work *while* the daemon is wedged, which rules
|
||||||
|
//! out the obvious implementations: no lock that a stuck job might hold, no
|
||||||
|
//! database query on the calling thread, no waiting on the worker pool. A
|
||||||
|
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
|
||||||
|
//! exactly the moment it matters.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// When this process started. Read at the first touch, which
|
||||||
|
/// [`super::mount::mount`] makes happen during startup so the uptime is the
|
||||||
|
/// daemon's, not the first diagnostics request's.
|
||||||
|
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
/// Control requests currently being served, keyed by a monotonic ticket.
|
||||||
|
///
|
||||||
|
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// entries, and the only operations are push, remove-by-ticket and read-all.
|
||||||
|
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
|
||||||
|
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Start the daemon's uptime clock. Called once, from the mount path.
|
||||||
|
pub(crate) fn start_clock() {
|
||||||
|
LazyLock::force(&STARTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers one in-flight control request for as long as it is held.
|
||||||
|
pub(crate) struct InflightGuard(u64);
|
||||||
|
|
||||||
|
impl InflightGuard {
|
||||||
|
pub(crate) fn new(kind: String) -> Self {
|
||||||
|
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
|
||||||
|
INFLIGHT.lock().push((ticket, kind, Instant::now()));
|
||||||
|
Self(ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InflightGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name of a serialised [`pdfs_core::control::Request`], without
|
||||||
|
/// parsing it into one.
|
||||||
|
///
|
||||||
|
/// serde's external tagging makes this a property of the text: a unit variant is
|
||||||
|
/// the bare string `"Status"`, anything else is an object whose single key is
|
||||||
|
/// the variant name. Reading the name off the line rather than matching on 80
|
||||||
|
/// variants keeps this cheap, and keeps it correct when a variant is added.
|
||||||
|
pub(crate) fn request_kind(line: &str) -> String {
|
||||||
|
let line = line.trim();
|
||||||
|
let name = if let Some(rest) = line.strip_prefix('{') {
|
||||||
|
rest.trim_start()
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
} else {
|
||||||
|
line.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
};
|
||||||
|
let name = name.unwrap_or("unknown");
|
||||||
|
// A bare `Refresh` says nothing about which view wedged, and the three
|
||||||
|
// scopes cost wildly different amounts of work: a directory refresh is two
|
||||||
|
// cheap calls, a photos refresh can pull a whole library. The scope is one
|
||||||
|
// more peek into the same line.
|
||||||
|
if name == "Refresh"
|
||||||
|
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
|
||||||
|
{
|
||||||
|
return format!("Refresh({scope})");
|
||||||
|
}
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name serde wrote for the value of `key`, whether that value is a
|
||||||
|
/// bare string (a unit variant) or an object whose single key is the name.
|
||||||
|
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
|
||||||
|
let rest = line.split_once(key)?.1.trim_start();
|
||||||
|
let rest = rest.strip_prefix(':')?.trim_start();
|
||||||
|
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
|
||||||
|
rest.strip_prefix('"')?
|
||||||
|
.split('"')
|
||||||
|
.next()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resident set size in bytes, or `0` when `/proc` did not answer.
|
||||||
|
///
|
||||||
|
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
|
||||||
|
/// need the page size, and this needs no dependency at all.
|
||||||
|
pub(crate) fn rss_bytes() -> u64 {
|
||||||
|
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
parse_vm_rss(&status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
|
||||||
|
fn parse_vm_rss(status: &str) -> u64 {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.and_then(|kb| kb.parse::<u64>().ok())
|
||||||
|
.map_or(0, |kb| kb.saturating_mul(1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Assemble a [`Diagnostics`] report.
|
||||||
|
///
|
||||||
|
/// The pending-op count is the one field that needs the database, so it is
|
||||||
|
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
|
||||||
|
/// the database must still be able to report everything else about itself.
|
||||||
|
pub(crate) fn diagnostics(&self) -> Diagnostics {
|
||||||
|
let pool = self.workers.snapshot();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut inflight: Vec<InflightRequest> = INFLIGHT
|
||||||
|
.try_lock()
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(_, kind, started)| InflightRequest {
|
||||||
|
kind: kind.clone(),
|
||||||
|
age_secs: now.duration_since(*started).as_secs(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
|
||||||
|
|
||||||
|
Diagnostics {
|
||||||
|
uptime_secs: STARTED.elapsed().as_secs(),
|
||||||
|
rss_bytes: rss_bytes(),
|
||||||
|
workers: pool
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.map(|worker| WorkerState {
|
||||||
|
name: worker.name,
|
||||||
|
busy: worker.busy,
|
||||||
|
label: worker.label,
|
||||||
|
age_secs: worker.age_ms / 1000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
meta_queued: pool.meta_queued,
|
||||||
|
transfer_queued: pool.transfer_queued,
|
||||||
|
meta_completed: pool.meta_completed,
|
||||||
|
transfer_completed: pool.transfer_completed,
|
||||||
|
inflight,
|
||||||
|
control_handlers: super::control::active_handlers(),
|
||||||
|
control_handler_limit: super::control::handler_limit(),
|
||||||
|
pending_ops: self.pending_ops_quickly(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queued mutations, or `0` if the database did not answer within
|
||||||
|
/// [`PENDING_COUNT_DEADLINE`].
|
||||||
|
fn pending_ops_quickly(&self) -> u64 {
|
||||||
|
let db = self.db.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
if std::thread::Builder::new()
|
||||||
|
.name("pdfs-diagnostics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let counts = db.pending_op_counts().unwrap_or_default();
|
||||||
|
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
|
||||||
|
let _ = tx.send(total);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the report waits for the database before reporting without it.
|
||||||
|
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The request name is read off the wire format, so it must match both
|
||||||
|
/// shapes serde produces.
|
||||||
|
#[test]
|
||||||
|
fn the_request_kind_comes_off_both_wire_shapes() {
|
||||||
|
assert_eq!(request_kind("\"Status\""), "Status");
|
||||||
|
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
|
||||||
|
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
|
||||||
|
assert_eq!(request_kind("garbage"), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stuck `Refresh` is only actionable if the report says which view it
|
||||||
|
/// was refreshing, so the scope rides along with the name.
|
||||||
|
#[test]
|
||||||
|
fn a_refresh_reports_its_scope() {
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
|
||||||
|
"Refresh(Photos)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
|
||||||
|
"Refresh(Dir)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
|
||||||
|
"Refresh(Trash)"
|
||||||
|
);
|
||||||
|
// A shape this does not recognise still names the request.
|
||||||
|
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard registers for its lifetime and nothing longer: a handler that
|
||||||
|
/// returns must not leave a permanent "in flight" entry behind.
|
||||||
|
#[test]
|
||||||
|
fn a_guard_registers_only_while_it_lives() {
|
||||||
|
let before = INFLIGHT.lock().len();
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard::new("OpenFile".into());
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before + 1);
|
||||||
|
}
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever else it reports, the resident size has to be a real reading.
|
||||||
|
#[test]
|
||||||
|
fn rss_is_readable() {
|
||||||
|
assert!(rss_bytes() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmRSS` is in kB, and a status without one must read as unknown rather
|
||||||
|
/// than as a daemon using no memory.
|
||||||
|
#[test]
|
||||||
|
fn vm_rss_is_kilobytes_and_absence_is_zero() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
|
||||||
|
2048 * 1024
|
||||||
|
);
|
||||||
|
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+242
-44
@@ -29,12 +29,16 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
use pdfs_core::batch;
|
use pdfs_core::batch;
|
||||||
use pdfs_core::cache::{Baseline, StagedWrite};
|
use pdfs_core::cache::{Baseline, StagedWrite};
|
||||||
use pdfs_core::control::{ActivityKind, TransferDirection};
|
use pdfs_core::control::{ActivityKind, TransferDirection};
|
||||||
use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, RenameMeta};
|
use pdfs_core::db::{
|
||||||
use proton_drive_rs::Node;
|
OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_EXPIRY_MS, PendingOp, RenameMeta,
|
||||||
|
};
|
||||||
|
use proton_drive_rs::proton_sdk::error::ProtonError;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{Node, NodeMoveItem};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use super::state::Intervals;
|
use super::state::Intervals;
|
||||||
@@ -281,8 +285,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let online = self.online.load(Ordering::Relaxed);
|
let online = self.online.load(Ordering::Relaxed);
|
||||||
|
let paused = self.sync_paused();
|
||||||
let op = match due {
|
let op = match due {
|
||||||
Some(op) if online => op,
|
Some(op) if online && !paused => op,
|
||||||
idle => {
|
idle => {
|
||||||
// Claimed but offline: hand it straight back, or it stays
|
// Claimed but offline: hand it straight back, or it stays
|
||||||
// invisible to every worker until this one happens to be
|
// invisible to every worker until this one happens to be
|
||||||
@@ -297,6 +302,18 @@ impl Core {
|
|||||||
// Cheap and a no-op when empty.
|
// Cheap and a no-op when empty.
|
||||||
self.cache.flush_touches();
|
self.cache.flush_touches();
|
||||||
self.recover_fsynced_writes();
|
self.recover_fsynced_writes();
|
||||||
|
// Parked rows are invisible to `claim_next_due_op`, so
|
||||||
|
// an idle queue is exactly when a park that has outlived
|
||||||
|
// its writer has to be noticed.
|
||||||
|
self.sweep_parked_creates();
|
||||||
|
}
|
||||||
|
// Paused, nothing due changes that: sleep until a resume
|
||||||
|
// wakes the worker (or the idle poll notices a timed pause
|
||||||
|
// ran out), not until the next op falls due — that is
|
||||||
|
// "now" for everything already queued.
|
||||||
|
if paused {
|
||||||
|
self.wait_for_drain_work();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
// A debounced or backed-off op may be waiting: sleep only
|
// A debounced or backed-off op may be waiting: sleep only
|
||||||
// until it becomes due rather than the full idle-poll.
|
// until it becomes due rather than the full idle-poll.
|
||||||
@@ -642,61 +659,46 @@ impl Core {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// The name half goes first, so that a collision on the move half below is
|
let landed = if node.parent_uid.as_ref() == Some(&parent) {
|
||||||
// about the name the node will actually land under rather than the one it
|
self.drain_rename_in_place(&uid, &node.name, &name)?
|
||||||
// is about to lose.
|
} else {
|
||||||
let mut landed = node.name.clone();
|
// Move and rename land as one request (`move-multiple` takes a
|
||||||
if landed != name {
|
// target name), so there is no half-applied state between them and
|
||||||
match self.rt.block_on(self.client.rename_node(&uid, &name, None)) {
|
// no window for the requirements to go stale between two calls —
|
||||||
Ok(()) => landed = name.clone(),
|
// the race B46 queued this op to get away from.
|
||||||
// Someone took the name while we were offline. Renaming to a
|
let target = (node.name != name).then_some(name.as_str());
|
||||||
|
match self.move_renaming(&uid, &parent, target) {
|
||||||
|
Ok(()) => name.clone(),
|
||||||
|
// The destination holds that name already. Landing under a
|
||||||
// *different* name is the non-destructive resolution: it neither
|
// *different* name is the non-destructive resolution: it neither
|
||||||
// clobbers their file nor drops ours, and it is visible.
|
// clobbers their file nor drops ours, and it is visible.
|
||||||
Err(e) if is_already_exists(&e) => {
|
Err(e) if is_already_exists(&e) => {
|
||||||
let alt = conflict_name(&name, now_secs());
|
let alt = conflict_name(&name, now_secs());
|
||||||
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
warn!(%uid, name, alt, "destination already holds that name; using a conflict name");
|
||||||
self.rt
|
self.move_renaming(&uid, &parent, Some(&alt))?;
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
landed = alt.clone();
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
self.adopt_drained_name(&uid, &alt);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&name,
|
&name,
|
||||||
format!("name was taken remotely; renamed to {alt}"),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if node.parent_uid.as_ref() != Some(&parent) {
|
|
||||||
match self.rt.block_on(self.client.move_node(&uid, &parent)) {
|
|
||||||
Ok(()) => {}
|
|
||||||
// The destination holds a `landed` of its own. Same resolution as
|
|
||||||
// a name collision, and it has to happen before the move: the API
|
|
||||||
// has no move-and-rename, so the node is renamed out of the way
|
|
||||||
// here and moved second.
|
|
||||||
Err(e) if is_already_exists(&e) => {
|
|
||||||
let alt = conflict_name(&landed, now_secs());
|
|
||||||
warn!(%uid, name = %landed, alt, "destination already holds that name; using a conflict name");
|
|
||||||
self.rt
|
|
||||||
.block_on(self.client.rename_node(&uid, &alt, None))?;
|
|
||||||
self.rt.block_on(self.client.move_node(&uid, &parent))?;
|
|
||||||
self.adopt_drained_name(&uid, &alt);
|
|
||||||
self.log_activity(
|
|
||||||
ActivityKind::Rename,
|
|
||||||
&landed,
|
|
||||||
format!("destination already had that name; moved as {alt}"),
|
format!("destination already had that name; moved as {alt}"),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
alt
|
||||||
}
|
}
|
||||||
// The destination folder is gone. Leaving the node in its current
|
// The destination folder is gone. Leaving the node in its current
|
||||||
// parent is the honest outcome: it is not where the user asked for
|
// parent is the honest outcome: it is not where the user asked for
|
||||||
// it, but it exists, it is where it has always been, and the
|
// it, but it exists and it is where it has always been. The name
|
||||||
// rename half above still applied. Retrying could only fail again
|
// the user chose still applies there. Retrying the move could
|
||||||
// — the folder is not coming back — and would wedge the queue.
|
// only fail again — the folder is not coming back — and would
|
||||||
|
// wedge the queue.
|
||||||
Err(e) if is_gone(&e) => {
|
Err(e) if is_gone(&e) => {
|
||||||
warn!(%uid, name = %landed, %parent, "move destination is gone; leaving the node where it is");
|
warn!(%uid, name, %parent, "move destination is gone; leaving the node where it is");
|
||||||
|
let landed = match self.drain_rename_in_place(&uid, &node.name, &name) {
|
||||||
|
Ok(landed) => landed,
|
||||||
|
// The node went with it; nothing is left to rename.
|
||||||
|
Err(e) if is_gone(&e) => node.name.clone(),
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Rename,
|
ActivityKind::Rename,
|
||||||
&landed,
|
&landed,
|
||||||
@@ -704,16 +706,73 @@ impl Core {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
landed
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
self.db.delete_op(op.id)?;
|
self.db.delete_op(op.id)?;
|
||||||
self.note_self_change(&uid);
|
self.note_self_change(&uid);
|
||||||
info!(%uid, name = %landed, "pending rename landed");
|
info!(%uid, name = %landed, "pending rename landed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename `uid` without moving it, from `current` to `name`, and return the
|
||||||
|
/// name it landed under. Someone may have taken the name while we were
|
||||||
|
/// offline; the node then lands under a conflict name instead.
|
||||||
|
fn drain_rename_in_place(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
current: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<String, ProtonError> {
|
||||||
|
if current == name {
|
||||||
|
return Ok(current.to_string());
|
||||||
|
}
|
||||||
|
match self.rt.block_on(self.client.rename_node(uid, name, None)) {
|
||||||
|
Ok(()) => Ok(name.to_string()),
|
||||||
|
Err(e) if is_already_exists(&e) => {
|
||||||
|
let alt = conflict_name(name, now_secs());
|
||||||
|
warn!(%uid, name, alt, "rename target name is taken; using a conflict name");
|
||||||
|
self.rt.block_on(self.client.rename_node(uid, &alt, None))?;
|
||||||
|
self.adopt_drained_name(uid, &alt);
|
||||||
|
self.log_activity(
|
||||||
|
ActivityKind::Rename,
|
||||||
|
name,
|
||||||
|
format!("name was taken remotely; renamed to {alt}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Ok(alt)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move `uid` under `parent` in one request, renaming it to `target_name` on
|
||||||
|
/// the way when one is given.
|
||||||
|
fn move_renaming(
|
||||||
|
&self,
|
||||||
|
uid: &NodeUid,
|
||||||
|
parent: &NodeUid,
|
||||||
|
target_name: Option<&str>,
|
||||||
|
) -> Result<(), ProtonError> {
|
||||||
|
let item = NodeMoveItem {
|
||||||
|
uid: uid.clone(),
|
||||||
|
target_name: target_name.map(str::to_string),
|
||||||
|
};
|
||||||
|
self.rt.block_on(async {
|
||||||
|
let mut outcomes =
|
||||||
|
std::pin::pin!(self.client.move_nodes_streaming(vec![item], parent.clone()));
|
||||||
|
match outcomes.next().await {
|
||||||
|
Some(Ok((_, outcome))) => outcome,
|
||||||
|
Some(Err(e)) => Err(e),
|
||||||
|
None => Err(ProtonError::invalid_operation(format!(
|
||||||
|
"move of {uid} reported no outcome"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply a queued trash to the remote.
|
/// Apply a queued trash to the remote.
|
||||||
///
|
///
|
||||||
/// A node that is already gone is a success, not a failure: the outcome the
|
/// A node that is already gone is a success, not a failure: the outcome the
|
||||||
@@ -1157,6 +1216,101 @@ impl Core {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any mount still holds an open handle on `uid`.
|
||||||
|
///
|
||||||
|
/// A transient file that is still open is still being written, however long
|
||||||
|
/// it has been parked, and the rename that finishes it has not happened yet.
|
||||||
|
fn uid_is_open(&self, uid: &NodeUid) -> bool {
|
||||||
|
let mut open = false;
|
||||||
|
self.for_each_state(|st| {
|
||||||
|
if open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open = st
|
||||||
|
.by_uid
|
||||||
|
.get(uid)
|
||||||
|
.and_then(|ino| st.entries.get(ino))
|
||||||
|
.is_some_and(|entry| entry.open_count > 0);
|
||||||
|
});
|
||||||
|
open
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retire parks that are never going to end (docs/BUGS.md B70).
|
||||||
|
///
|
||||||
|
/// A parked create waits for one event: the rename from the transient name
|
||||||
|
/// to the finished one. Nothing else ever un-parks it, and plenty of writers
|
||||||
|
/// never perform it — an editor unlinks its swap file, a download is
|
||||||
|
/// abandoned, or the file was always going to be called `index.tmp`. Such a
|
||||||
|
/// row stayed at [`PARK_UNTIL`] for the life of the database, pinning the
|
||||||
|
/// only copy of its bytes in `staging/` and counting against the queue.
|
||||||
|
///
|
||||||
|
/// Two outcomes, and neither deletes bytes a user can still see:
|
||||||
|
///
|
||||||
|
/// * The node is gone from the tree — the file was deleted while its create
|
||||||
|
/// was parked, so the create is no longer wanted. The op and its blob go,
|
||||||
|
/// which is what [`Core::discard_queued_ops`] would have done had the
|
||||||
|
/// delete path reached it.
|
||||||
|
/// * The node is still there and has been parked past
|
||||||
|
/// [`PARK_EXPIRY_MS`] with nothing holding it open — the park has outlived
|
||||||
|
/// the writer it was protecting. Un-park it, and the ordinary drain
|
||||||
|
/// uploads it like any other create.
|
||||||
|
///
|
||||||
|
/// An open node is left alone whatever its age: it is still being written.
|
||||||
|
fn sweep_parked_creates(&self) {
|
||||||
|
let parked = match self.db.parked_create_ops() {
|
||||||
|
Ok(parked) if !parked.is_empty() => parked,
|
||||||
|
Ok(_) => return,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading parked creates failed; skipping the park sweep");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let now = now_millis();
|
||||||
|
let (mut released, mut dropped) = (0usize, 0usize);
|
||||||
|
for op in &parked {
|
||||||
|
let Some(uid) = parse_node_uid(&op.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = op.name.as_deref().unwrap_or("?");
|
||||||
|
let known = match self.db.node_by_uid(&op.uid) {
|
||||||
|
Ok(known) => known.is_some(),
|
||||||
|
// Never infer "deleted" from a failed read: that would discard
|
||||||
|
// the bytes over a transient database error.
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%uid, %error, "cannot tell whether a parked node still exists");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !known {
|
||||||
|
if let Err(error) = self.discard_queued_ops(&uid) {
|
||||||
|
warn!(%uid, name, ?error, "dropping a deleted transient's parked create failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dropped += 1;
|
||||||
|
debug!(%uid, name, "dropped a parked create whose file was deleted");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if park_verdict(op.created_at, now, self.uid_is_open(&uid)) != ParkVerdict::Release {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.db.set_create_hold(&op.uid, false) {
|
||||||
|
Ok(true) => {
|
||||||
|
released += 1;
|
||||||
|
info!(%uid, name, parked_ms = now - op.created_at,
|
||||||
|
"un-parked a transient create that was never renamed; uploading it");
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(error) => warn!(%uid, name, %error, "un-parking an expired park failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if released > 0 || dropped > 0 {
|
||||||
|
info!(released, dropped, "swept parked creates");
|
||||||
|
}
|
||||||
|
if released > 0 {
|
||||||
|
self.wake_drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
||||||
///
|
///
|
||||||
/// A supersede can replace the entry while the op that owned it is in
|
/// A supersede can replace the entry while the op that owned it is in
|
||||||
@@ -1529,6 +1683,24 @@ fn is_own_self_supersede(complete: bool, remote_rev: Option<&str>, own_rev: Opti
|
|||||||
complete && remote_rev.is_some() && remote_rev == own_rev
|
complete && remote_rev.is_some() && remote_rev == own_rev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the park sweep does with one parked create whose node still exists.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ParkVerdict {
|
||||||
|
/// Leave it parked: the rename it waits for can still come.
|
||||||
|
Keep,
|
||||||
|
/// Un-park it: nothing will rename it now, so the bytes go up as they are.
|
||||||
|
Release,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Judge one parked create by age alone. An open node is kept whatever its age
|
||||||
|
/// — it is still being written, and the rename is the writer's last step.
|
||||||
|
fn park_verdict(created_at: i64, now: i64, open: bool) -> ParkVerdict {
|
||||||
|
if open || now - created_at < PARK_EXPIRY_MS {
|
||||||
|
return ParkVerdict::Keep;
|
||||||
|
}
|
||||||
|
ParkVerdict::Release
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1554,6 +1726,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The park's only ordinary exit is the transient-to-final rename. The
|
||||||
|
/// sweep is what happens when that rename never comes.
|
||||||
|
#[test]
|
||||||
|
fn a_park_expires_only_once_it_is_old_and_nobody_holds_the_file() {
|
||||||
|
let now = PARK_EXPIRY_MS * 10;
|
||||||
|
// Fresh: the writer may still rename it.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS + 1, now, false),
|
||||||
|
ParkVerdict::Keep
|
||||||
|
);
|
||||||
|
// Old, but still open: a slow write is not an abandoned one.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS, now, true),
|
||||||
|
ParkVerdict::Keep
|
||||||
|
);
|
||||||
|
// Old and closed: no rename is coming.
|
||||||
|
assert_eq!(
|
||||||
|
park_verdict(now - PARK_EXPIRY_MS, now, false),
|
||||||
|
ParkVerdict::Release
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drain_authorities_match_each_operation_kind() {
|
fn drain_authorities_match_each_operation_kind() {
|
||||||
for kind in [OP_REVISION, OP_TRASH] {
|
for kind in [OP_REVISION, OP_TRASH] {
|
||||||
@@ -1587,6 +1781,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1879,6 +2075,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ impl Filesystem for ProtonFs {
|
|||||||
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
||||||
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
||||||
let core = self.core.clone();
|
let core = self.core.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "statfs", move || {
|
||||||
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
||||||
let bsize = u64::from(REPORTED_BLKSIZE);
|
let bsize = u64::from(REPORTED_BLKSIZE);
|
||||||
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
||||||
@@ -89,7 +89,7 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "lookup", move || {
|
||||||
fs.serve_lookup(parent, &name, reply, true)
|
fs.serve_lookup(parent, &name, reply, true)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,7 +183,7 @@ impl Filesystem for ProtonFs {
|
|||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core
|
||||||
.workers
|
.workers
|
||||||
.run(Lane::Meta, move || fs.serve_opendir(ino, reply));
|
.run(Lane::Meta, "opendir", move || fs.serve_opendir(ino, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn readdir(
|
fn readdir(
|
||||||
@@ -210,9 +210,9 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "readdir", move || {
|
||||||
.workers
|
fs.serve_readdir(ino, offset, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn releasedir(
|
fn releasedir(
|
||||||
@@ -237,9 +237,9 @@ impl Filesystem for ProtonFs {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Transfer, "open", move || {
|
||||||
.workers
|
fs.serve_open(ino, flags, reply)
|
||||||
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read(
|
fn read(
|
||||||
@@ -329,7 +329,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// even read off the FUSE device. It only reads `state`, so moving it
|
// even read off the FUSE device. It only reads `state`, so moving it
|
||||||
// races with nothing. FUSE does not require replies in request order.
|
// races with nothing. FUSE does not require replies in request order.
|
||||||
let core = self.core.clone();
|
let core = self.core.clone();
|
||||||
self.core.workers.run(Lane::Transfer, move || {
|
self.core.workers.run(Lane::Transfer, "read", move || {
|
||||||
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
|
match core.read_range(&uid, mtime, fsize, offset, size as u64, cache_blocks) {
|
||||||
Ok(bytes) => reply.data(&bytes),
|
Ok(bytes) => reply.data(&bytes),
|
||||||
Err(e) => reply.error(e),
|
Err(e) => reply.error(e),
|
||||||
@@ -366,9 +366,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// one create at a time, with `ls` on the same mount timing out for as
|
// one create at a time, with `ls` on the same mount timing out for as
|
||||||
// long as the burst lasted. See [`Workers`].
|
// long as the burst lasted. See [`Workers`].
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "create", move || {
|
||||||
.workers
|
fs.serve_create(parent, &name, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_create(parent, &name, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(
|
fn write(
|
||||||
@@ -506,7 +506,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// the scratch file).
|
// the scratch file).
|
||||||
let ino = ino.0;
|
let ino = ino.0;
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Transfer, move || {
|
self.core.workers.run(Lane::Transfer, "truncate", move || {
|
||||||
if let Err(e) = fs.core.queue_truncate(ino, size) {
|
if let Err(e) = fs.core.queue_truncate(ino, size) {
|
||||||
reply.error(e);
|
reply.error(e);
|
||||||
return;
|
return;
|
||||||
@@ -620,7 +620,7 @@ impl Filesystem for ProtonFs {
|
|||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core
|
||||||
.workers
|
.workers
|
||||||
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
|
.run(Lane::Transfer, "fsync", move || fs.serve_fsync(fh, reply));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release(
|
fn release(
|
||||||
@@ -680,7 +680,7 @@ impl Filesystem for ProtonFs {
|
|||||||
};
|
};
|
||||||
// `trash_child` is a remote call; see `serve_create`.
|
// `trash_child` is a remote call; see `serve_create`.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "unlink", move || {
|
||||||
fs.serve_unlink(parent, &name_str, reply)
|
fs.serve_unlink(parent, &name_str, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -701,9 +701,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
|
// Enumerates the folder to apply rmdir's emptiness rule, then trashes
|
||||||
// it: two remote calls. See `serve_create`.
|
// it: two remote calls. See `serve_create`.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "rmdir", move || {
|
||||||
.workers
|
fs.serve_rmdir(parent, &name_str, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_rmdir(parent, &name_str, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mkdir(
|
fn mkdir(
|
||||||
@@ -731,9 +731,9 @@ impl Filesystem for ProtonFs {
|
|||||||
// remote round trip, and inline it blocks every other request on the
|
// remote round trip, and inline it blocks every other request on the
|
||||||
// mount until it answers.
|
// mount until it answers.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core
|
self.core.workers.run(Lane::Meta, "mkdir", move || {
|
||||||
.workers
|
fs.serve_mkdir(parent, &name, reply)
|
||||||
.run(Lane::Meta, move || fs.serve_mkdir(parent, &name, reply));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rename(
|
fn rename(
|
||||||
@@ -774,7 +774,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// move), and the replaced-destination path adds a trash on top. See
|
// move), and the replaced-destination path adds a trash on top. See
|
||||||
// `serve_create` for why none of that may run on the dispatch loop.
|
// `serve_create` for why none of that may run on the dispatch loop.
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "rename", move || {
|
||||||
fs.serve_rename(parent, &name, newparent, &newname, flags, reply)
|
fs.serve_rename(parent, &name, newparent, &newname, flags, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -799,7 +799,7 @@ impl Filesystem for ProtonFs {
|
|||||||
// network per call (B5).
|
// network per call (B5).
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
let name = name.to_os_string();
|
let name = name.to_os_string();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "getxattr", move || {
|
||||||
fs.serve_getxattr(ino, &name, size, reply)
|
fs.serve_getxattr(ino, &name, size, reply)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1099,7 @@ impl ProtonFs {
|
|||||||
if !off_loop {
|
if !off_loop {
|
||||||
let fs = self.clone();
|
let fs = self.clone();
|
||||||
let name = name.to_string();
|
let name = name.to_string();
|
||||||
self.core.workers.run(Lane::Meta, move || {
|
self.core.workers.run(Lane::Meta, "lookup", move || {
|
||||||
fs.serve_lookup(parent, &name, reply, true)
|
fs.serve_lookup(parent, &name, reply, true)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
+521
-57
@@ -42,7 +42,7 @@ use std::os::unix::net::UnixListener;
|
|||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use fuser::ReplyXattr;
|
use fuser::ReplyXattr;
|
||||||
@@ -60,7 +60,7 @@ use pdfs_core::config::{AppDirs, SweepMode};
|
|||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
|
||||||
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
|
||||||
TransferDirection,
|
ThumbnailBuildStatus, TransferDirection,
|
||||||
};
|
};
|
||||||
use pdfs_core::db::{
|
use pdfs_core::db::{
|
||||||
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL, PendingOp,
|
||||||
@@ -80,27 +80,35 @@ use proton_drive_rs::{
|
|||||||
|
|
||||||
mod albums;
|
mod albums;
|
||||||
mod background;
|
mod background;
|
||||||
|
mod conflicts;
|
||||||
mod control;
|
mod control;
|
||||||
use control::run_control_socket;
|
use control::run_control_socket;
|
||||||
mod devices;
|
mod devices;
|
||||||
|
mod diagnostics;
|
||||||
mod drain;
|
mod drain;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
pub use filesystem::ProtonFs;
|
pub use filesystem::ProtonFs;
|
||||||
mod mount;
|
mod mount;
|
||||||
|
mod pause;
|
||||||
mod photos;
|
mod photos;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod queue;
|
||||||
mod reads;
|
mod reads;
|
||||||
|
mod redate;
|
||||||
mod revisions;
|
mod revisions;
|
||||||
mod sharing;
|
mod sharing;
|
||||||
mod shutdown;
|
mod shutdown;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod supervisor;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
mod sync;
|
mod sync;
|
||||||
|
mod systemd;
|
||||||
|
mod takeout;
|
||||||
mod transfers;
|
mod transfers;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod r#virtual;
|
mod r#virtual;
|
||||||
mod workers;
|
mod workers;
|
||||||
use background::{run_event_sync, run_local_index};
|
use background::{run_event_sync, run_local_index, run_photos_event_sync};
|
||||||
pub(crate) use mount::is_stale_mount;
|
pub(crate) use mount::is_stale_mount;
|
||||||
pub use mount::{MountOptions, MountOutcome, mount};
|
pub use mount::{MountOptions, MountOutcome, mount};
|
||||||
use mount::{
|
use mount::{
|
||||||
@@ -192,19 +200,42 @@ const UPLOAD_TIME_MEMORY: usize = 512;
|
|||||||
/// for the SDK's own per-request concurrency.
|
/// for the SDK's own per-request concurrency.
|
||||||
const DRAIN_WORKERS: usize = 3;
|
const DRAIN_WORKERS: usize = 3;
|
||||||
|
|
||||||
|
/// How long a staged blob that names no node is kept before it is retired.
|
||||||
|
///
|
||||||
|
/// Staging is exempt from the cache budget because it holds the only copy of
|
||||||
|
/// writes that have not been uploaded, so nothing there is ever evicted to
|
||||||
|
/// reclaim space. That is right for a blob that still describes something. It is
|
||||||
|
/// wrong for one whose sidecar is missing or names a `local~` placeholder whose
|
||||||
|
/// create is gone: no future run can address it, no UI accounts for it, and it
|
||||||
|
/// occupies disk for the life of the machine. Real instance: 95 such blobs,
|
||||||
|
/// ~445 MiB, the oldest two months old, re-announced on every mount.
|
||||||
|
///
|
||||||
|
/// A month is far longer than any transfer or any plausible "I will look at that
|
||||||
|
/// later", and the retirement is announced per blob with its size, so the one
|
||||||
|
/// case that matters — bytes a human still wants — has a month of warnings and a
|
||||||
|
/// path to go and fetch them from.
|
||||||
|
const STAGING_ORPHAN_RETAIN: Duration = Duration::from_secs(30 * 24 * 60 * 60);
|
||||||
|
|
||||||
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
const ONLINE_PROBE_MIN: Duration = Duration::from_secs(5);
|
||||||
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
|
||||||
/// How long the persisted photos timeline stays good before a page request
|
/// How long the persisted photos timeline stays good before a page request
|
||||||
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
|
||||||
/// in the DB and every page is sliced from there; a stale one is still served
|
/// in the DB and every page is sliced from there; a stale one is still served
|
||||||
/// immediately and refreshed in the background.
|
/// immediately and refreshed in the background.
|
||||||
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
|
///
|
||||||
|
/// Short, because the cost of a stale timeline is a photo the user deleted on
|
||||||
|
/// their phone still sitting in the gallery, and the cost of the check is one
|
||||||
|
/// listing call that only runs when somebody is actually looking at the page.
|
||||||
|
const TIMELINE_TTL: Duration = Duration::from_secs(60);
|
||||||
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
|
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
|
||||||
/// call when enriching a refreshed timeline with names and media types (for the
|
/// call when enriching a refreshed timeline with names and media types (for the
|
||||||
/// Photos / Videos / Raw split). Batched so a large library is a handful of
|
/// Photos / Videos / Raw split). Batched so a large library is a handful of
|
||||||
/// round-trips rather than one request per photo, and bounded so a single call
|
/// round-trips rather than one request per photo, and bounded so a single call
|
||||||
/// never asks the server to decrypt the whole library at once.
|
/// never asks the server to decrypt the whole library at once.
|
||||||
const TIMELINE_ENRICH_CHUNK: usize = 200;
|
///
|
||||||
|
/// 150 because that is the SDK's own `MAX_BATCH_COUNT`: a larger chunk is split
|
||||||
|
/// there anyway, into one full request and a ragged tail.
|
||||||
|
const TIMELINE_ENRICH_CHUNK: usize = 150;
|
||||||
/// The same, for the persisted trash listing. Shorter, because the trash is the
|
/// The same, for the persisted trash listing. Shorter, because the trash is the
|
||||||
/// one listing a user changes and then immediately looks at — though our own
|
/// one listing a user changes and then immediately looks at — though our own
|
||||||
/// mutations also invalidate it outright, so this only covers other clients.
|
/// mutations also invalidate it outright, so this only covers other clients.
|
||||||
@@ -373,6 +404,45 @@ impl SearchRoots {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Negative thumbnail knowledge has two different authorities. A remote miss
|
||||||
|
/// only says Proton has no thumbnail to download; it must never suppress local
|
||||||
|
/// generation. A local miss says this exact file revision was successfully
|
||||||
|
/// inspected and could not be decoded. Both maps are bounded independently.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ThumbnailMissCaches {
|
||||||
|
remote: HashMap<(NodeUid, i32), i64>,
|
||||||
|
local: HashMap<(NodeUid, i32), i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThumbnailMissCaches {
|
||||||
|
fn remote_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.remote.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_contains(&self, key: &(NodeUid, i32), tag: i64) -> bool {
|
||||||
|
self.local.get(key) == Some(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_remote(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.remote, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_local(&mut self, key: (NodeUid, i32), tag: i64) {
|
||||||
|
Self::remember(&mut self.local, key, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forget_local(&mut self, key: &(NodeUid, i32)) {
|
||||||
|
self.local.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember(map: &mut HashMap<(NodeUid, i32), i64>, key: (NodeUid, i32), tag: i64) {
|
||||||
|
if map.len() >= MAX_THUMBNAIL_MISSES && !map.contains_key(&key) {
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
map.insert(key, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
/// `path` expressed relative to `root`, or `None` if it is not below it. The
|
||||||
/// root itself resolves to the empty path; an empty root is the mount itself
|
/// root itself resolves to the empty path; an empty root is the mount itself
|
||||||
/// and covers everything.
|
/// and covers everything.
|
||||||
@@ -441,6 +511,10 @@ struct Core {
|
|||||||
/// `Response::Status` so the UI can say so rather than leaving the user to
|
/// `Response::Status` so the UI can say so rather than leaving the user to
|
||||||
/// infer it from a wall of EIO.
|
/// infer it from a wall of EIO.
|
||||||
online: Arc<AtomicBool>,
|
online: Arc<AtomicBool>,
|
||||||
|
/// When the user paused syncing, the Unix second it resumes by itself;
|
||||||
|
/// [`pause::PAUSED_INDEFINITELY`] for "until resumed", `0` when not paused.
|
||||||
|
/// Persisted, so a pause survives a restart (see [`Core::sync_paused`]).
|
||||||
|
sync_paused_until: Arc<AtomicI64>,
|
||||||
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
/// Writes accepted from the kernel but not yet uploaded, keyed by node
|
||||||
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
/// (offline.md Phase 3). The in-memory face of the `pending_op` table, from
|
||||||
/// which it is rebuilt on mount.
|
/// which it is rebuilt on mount.
|
||||||
@@ -510,8 +584,22 @@ struct Core {
|
|||||||
/// those downloads is a full-size photo — so an in-flight uid is never started
|
/// those downloads is a full-size photo — so an in-flight uid is never started
|
||||||
/// twice.
|
/// twice.
|
||||||
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
thumb_gen: Arc<Mutex<HashSet<NodeUid>>>,
|
||||||
/// Nodes the remote has told us have *no* thumbnail of a given type, keyed by
|
/// Global permit pool for locally generated thumbnails. The old per-request
|
||||||
/// `(uid, thumbnail type)` and holding the mtime the answer was learned at.
|
/// limit multiplied when the GUI sent several batches, allowing dozens of
|
||||||
|
/// full-size images to download at once.
|
||||||
|
thumb_gen_budget: Arc<tokio::sync::Semaphore>,
|
||||||
|
/// Current ordinary-file listing generation and a wake-up for tasks waiting
|
||||||
|
/// on a permit or a download when that listing is abandoned.
|
||||||
|
file_thumb_generation: Arc<AtomicU64>,
|
||||||
|
file_thumb_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Progress of the explicit recursive build started from the Files toolbar.
|
||||||
|
thumbnail_build: Arc<Mutex<ThumbnailBuildStatus>>,
|
||||||
|
/// Cancellation for the explicit recursive build. Separate from ordinary
|
||||||
|
/// listing cancellation because the two jobs have independent lifetimes.
|
||||||
|
thumbnail_build_cancelled: Arc<AtomicBool>,
|
||||||
|
thumbnail_build_cancel: Arc<tokio::sync::Notify>,
|
||||||
|
/// Negative thumbnail knowledge, separated by whether the remote lacked a
|
||||||
|
/// thumbnail or local decoding proved this revision unsupported.
|
||||||
///
|
///
|
||||||
/// Absence has to be cached or it costs a round trip every time it is asked
|
/// Absence has to be cached or it costs a round trip every time it is asked
|
||||||
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
/// for, and it is asked for constantly: an `ls -l` from an xattr-aware lister
|
||||||
@@ -519,7 +607,7 @@ struct Core {
|
|||||||
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
/// of videos re-probed 130 times per listing at ~186 ms each (B5). The mtime
|
||||||
/// is the validity tag — a new revision may well have a thumbnail — matching
|
/// is the validity tag — a new revision may well have a thumbnail — matching
|
||||||
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
/// how [`ContentCache::read_thumbnail`] validates the positive side.
|
||||||
no_thumbnail: Arc<Mutex<HashMap<(NodeUid, i32), i64>>>,
|
thumbnail_misses: Arc<Mutex<ThumbnailMissCaches>>,
|
||||||
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
/// Last account quota answer and when it was learned, for `statfs(2)`.
|
||||||
///
|
///
|
||||||
/// `df` and the free-space preflight of every file manager and installer ask
|
/// `df` and the free-space preflight of every file manager and installer ask
|
||||||
@@ -1329,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)
|
db.has_pending_op(&uid.to_string(), OP_TRASH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The size of a staged blob that has outlived [`STAGING_ORPHAN_RETAIN`], or
|
||||||
|
/// `None` while it is younger than that — or while its age cannot be read at
|
||||||
|
/// all, which is never grounds for deleting bytes.
|
||||||
|
fn orphan_past_retention(blob: &Path) -> Option<u64> {
|
||||||
|
let meta = std::fs::metadata(blob).ok()?;
|
||||||
|
let age = meta.modified().ok()?.elapsed().ok()?;
|
||||||
|
(age >= STAGING_ORPHAN_RETAIN).then_some(meta.len())
|
||||||
|
}
|
||||||
|
|
||||||
fn release_can_discard_unlinked(db: &Db, uid: &NodeUid) -> bool {
|
fn release_can_discard_unlinked(db: &Db, uid: &NodeUid) -> bool {
|
||||||
match release_must_retain_queued_trash(db, uid) {
|
match release_must_retain_queued_trash(db, uid) {
|
||||||
Ok(retain) => !retain,
|
Ok(retain) => !retain,
|
||||||
@@ -1653,6 +1750,9 @@ impl Core {
|
|||||||
};
|
};
|
||||||
let mut queued = 0usize;
|
let mut queued = 0usize;
|
||||||
let mut stranded = 0usize;
|
let mut stranded = 0usize;
|
||||||
|
let mut retired = 0usize;
|
||||||
|
let mut retired_bytes = 0u64;
|
||||||
|
let mut example: Option<PathBuf> = None;
|
||||||
for (blob, meta) in staged {
|
for (blob, meta) in staged {
|
||||||
if claimed.contains(&blob.to_string_lossy().into_owned()) {
|
if claimed.contains(&blob.to_string_lossy().into_owned()) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1660,9 +1760,17 @@ impl Core {
|
|||||||
let addressable = meta.as_ref().and_then(|m| parse_node_uid(&m.uid));
|
let addressable = meta.as_ref().and_then(|m| parse_node_uid(&m.uid));
|
||||||
let Some((meta, uid)) = meta.zip(addressable).filter(|(_, uid)| !is_local_uid(uid))
|
let Some((meta, uid)) = meta.zip(addressable).filter(|(_, uid)| !is_local_uid(uid))
|
||||||
else {
|
else {
|
||||||
|
if let Some(bytes) = orphan_past_retention(&blob) {
|
||||||
|
retired += 1;
|
||||||
|
retired_bytes += bytes;
|
||||||
|
warn!(blob = %blob.display(), bytes,
|
||||||
|
age_days = STAGING_ORPHAN_RETAIN.as_secs() / 86_400,
|
||||||
|
"retiring staged bytes that name no node and have outlived retention");
|
||||||
|
self.cache.discard_staged(&blob);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
stranded += 1;
|
stranded += 1;
|
||||||
warn!(blob = %blob.display(),
|
example.get_or_insert_with(|| blob.clone());
|
||||||
"staged bytes belong to no queued upload and name no node; kept for recovery");
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self.pending.lock().contains_key(&uid) {
|
if self.pending.lock().contains_key(&uid) {
|
||||||
@@ -1708,8 +1816,21 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if queued > 0 || stranded > 0 {
|
if stranded > 0 {
|
||||||
info!(queued, stranded, "reconciled orphaned staged writes");
|
// One line, not one per blob. A tree of them says exactly as much as
|
||||||
|
// a single count and an example does, and said it on every mount:
|
||||||
|
// 86 identical warnings for the same 86 files, boot after boot.
|
||||||
|
warn!(
|
||||||
|
count = stranded,
|
||||||
|
example = %example.map(|b| b.display().to_string()).unwrap_or_default(),
|
||||||
|
"staged bytes belong to no queued upload and name no node; kept for recovery"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if queued > 0 || stranded > 0 || retired > 0 {
|
||||||
|
info!(
|
||||||
|
queued,
|
||||||
|
stranded, retired, retired_bytes, "reconciled orphaned staged writes"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3579,7 +3700,7 @@ impl Core {
|
|||||||
// "This file has no thumbnail" is an answer worth remembering: without
|
// "This file has no thumbnail" is an answer worth remembering: without
|
||||||
// it every listing pays a round trip per file to be told nothing (B5).
|
// it every listing pays a round trip per file to be told nothing (B5).
|
||||||
let key = (uid.clone(), ttype.as_i32());
|
let key = (uid.clone(), ttype.as_i32());
|
||||||
if self.no_thumbnail.lock().get(&key) == Some(&mtime) {
|
if self.thumbnail_misses.lock().remote_contains(&key, mtime) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let bytes = self
|
let bytes = self
|
||||||
@@ -3596,15 +3717,7 @@ impl Core {
|
|||||||
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
.store_thumbnail(&uid, ttype.as_i32(), mtime, bytes);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let mut misses = self.no_thumbnail.lock();
|
self.thumbnail_misses.lock().remember_remote(key, mtime);
|
||||||
// Bounded by clearing rather than by LRU: the entries are two
|
|
||||||
// words each, the cap is far above any plausible working set,
|
|
||||||
// and re-learning a miss costs one round trip. Not worth a
|
|
||||||
// second data structure to order them.
|
|
||||||
if misses.len() >= MAX_THUMBNAIL_MISSES {
|
|
||||||
misses.clear();
|
|
||||||
}
|
|
||||||
misses.insert(key, mtime);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
@@ -3691,6 +3804,9 @@ impl Core {
|
|||||||
// My own tree: a role only means something under a share, and
|
// My own tree: a role only means something under a share, and
|
||||||
// this listing never crosses into one.
|
// this listing never crosses into one.
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -3741,10 +3857,25 @@ impl Core {
|
|||||||
/// Full-text search node names against the local SQLite index, mapping each
|
/// Full-text search node names against the local SQLite index, mapping each
|
||||||
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
||||||
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
||||||
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
|
/// Every pin, with `cached` saying whether a pinned file's content is on
|
||||||
|
/// disk, so a front-end listing pins can badge them without a round-trip
|
||||||
|
/// per file.
|
||||||
|
fn list_pins(&self) -> Vec<pdfs_core::cache::Pin> {
|
||||||
|
let mut pins = self.cache.list_pins();
|
||||||
|
for pin in pins.iter_mut().filter(|pin| pin.is_dir == Some(false)) {
|
||||||
|
if let Ok(Some(node)) = self.db.node_by_uid(&pin.uid) {
|
||||||
|
pin.cached =
|
||||||
|
self.cache
|
||||||
|
.is_cached(&node.uid, node.modification_time, node_size(&node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pins
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(&self, query: &str, limit: usize, scope: Option<&str>) -> CoreResult<Vec<SearchHit>> {
|
||||||
let hits = self
|
let hits = self
|
||||||
.db
|
.db
|
||||||
.search(query, limit)
|
.search_in(query, limit, scope)
|
||||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||||
let roots = self.search_roots();
|
let roots = self.search_roots();
|
||||||
let pinned = self.pinned_set();
|
let pinned = self.pinned_set();
|
||||||
@@ -3756,6 +3887,12 @@ impl Core {
|
|||||||
size: node_size(&h.node),
|
size: node_size(&h.node),
|
||||||
modified: h.node.modification_time,
|
modified: h.node.modification_time,
|
||||||
pinned: pinned.contains(&h.node.uid.to_string()),
|
pinned: pinned.contains(&h.node.uid.to_string()),
|
||||||
|
cached: !h.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&h.node.uid,
|
||||||
|
h.node.modification_time,
|
||||||
|
node_size(&h.node),
|
||||||
|
),
|
||||||
uid: h.node.uid.to_string(),
|
uid: h.node.uid.to_string(),
|
||||||
mounted_path: roots.resolve(&h.path),
|
mounted_path: roots.resolve(&h.path),
|
||||||
path: h.path,
|
path: h.path,
|
||||||
@@ -3823,6 +3960,12 @@ impl Core {
|
|||||||
size: node_size(&hit.node),
|
size: node_size(&hit.node),
|
||||||
modified: hit.node.modification_time,
|
modified: hit.node.modification_time,
|
||||||
pinned: is_pinned,
|
pinned: is_pinned,
|
||||||
|
cached: !hit.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&hit.node.uid,
|
||||||
|
hit.node.modification_time,
|
||||||
|
node_size(&hit.node),
|
||||||
|
),
|
||||||
uid,
|
uid,
|
||||||
mounted_path: roots.resolve(&hit.path),
|
mounted_path: roots.resolve(&hit.path),
|
||||||
path: hit.path,
|
path: hit.path,
|
||||||
@@ -4272,6 +4415,9 @@ impl Core {
|
|||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
path: String::new(),
|
path: String::new(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -4313,6 +4459,9 @@ impl Core {
|
|||||||
is_dir: node.is_folder(),
|
is_dir: node.is_folder(),
|
||||||
size: node_size(&node) as i64,
|
size: node_size(&node) as i64,
|
||||||
mtime: node.modification_time,
|
mtime: node.modification_time,
|
||||||
|
// The folder the node will go back to, which is also what tells
|
||||||
|
// a restore whether that folder is itself in the trash.
|
||||||
|
parent_uid: node.parent_uid.as_ref().map(|uid| uid.to_string()),
|
||||||
}));
|
}));
|
||||||
// Cumulative, so the table is always a prefix of the real trash
|
// Cumulative, so the table is always a prefix of the real trash
|
||||||
// rather than a mix of this refresh and the last one.
|
// rather than a mix of this refresh and the last one.
|
||||||
@@ -4418,15 +4567,48 @@ impl Core {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restore trashed nodes to the folders they were trashed from. The parents
|
/// Restore trashed nodes to the folders they were trashed from, together
|
||||||
/// are read *before* the restore — a restored node reappears in a listing the
|
/// with the rest of the trashed tree they belong to.
|
||||||
/// daemon may already have cached, so each destination folder is invalidated
|
///
|
||||||
/// and re-enumerated on next access.
|
/// The trash is a flat list, but a user deletes a shape: a folder, and often
|
||||||
|
/// items inside it that were trashed separately beforehand. Restoring one of
|
||||||
|
/// those in isolation is not what anyone means by it — a folder would come
|
||||||
|
/// back without the contents that were deleted with it, and a file would come
|
||||||
|
/// back inside a folder that is still in the trash, which is to say nowhere
|
||||||
|
/// the user can see. So a restore takes the whole connected piece:
|
||||||
|
/// every trashed descendant of what was asked for, and every trashed ancestor
|
||||||
|
/// above it. See [`expand_restore`] for the exact rule.
|
||||||
|
///
|
||||||
|
/// The waves are restored shallowest first, because a node cannot be put back
|
||||||
|
/// under a parent that is still trashed.
|
||||||
|
///
|
||||||
|
/// The destination parents are read *before* the restore — a restored node
|
||||||
|
/// reappears in a listing the daemon may already have cached, so each one is
|
||||||
|
/// invalidated and re-enumerated on next access.
|
||||||
fn restore(&self, uids: &[String]) -> CoreResult<usize> {
|
fn restore(&self, uids: &[String]) -> CoreResult<usize> {
|
||||||
let parsed = Self::parse_uids(uids)?;
|
let requested = Self::parse_uids(uids)?;
|
||||||
|
// A listing we could not read is not a reason to refuse the restore: it
|
||||||
|
// only costs the expansion, and the requested nodes still go back.
|
||||||
|
let relations = self.db.trash_parents().unwrap_or_default();
|
||||||
|
let waves = expand_restore(uids, &relations);
|
||||||
|
let waves: Vec<Vec<NodeUid>> = if waves.is_empty() {
|
||||||
|
vec![requested.clone()]
|
||||||
|
} else {
|
||||||
|
waves
|
||||||
|
.iter()
|
||||||
|
.map(|wave| {
|
||||||
|
wave.iter()
|
||||||
|
.filter_map(|uid| parse_uid(uid))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.filter(|wave: &Vec<NodeUid>| !wave.is_empty())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let all: Vec<NodeUid> = waves.iter().flatten().cloned().collect();
|
||||||
let parents: Vec<NodeUid> = self
|
let parents: Vec<NodeUid> = self
|
||||||
.rt
|
.rt
|
||||||
.block_on(self.client.enumerate_nodes(&parsed))
|
.block_on(self.client.enumerate_nodes(&all))
|
||||||
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
.map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|n| n.parent_uid)
|
.filter_map(|n| n.parent_uid)
|
||||||
@@ -4437,27 +4619,29 @@ impl Core {
|
|||||||
// half way through a large restore leaves local state describing what the
|
// half way through a large restore leaves local state describing what the
|
||||||
// server actually did. Only a batch that restored nothing is an error the
|
// server actually did. Only a batch that restored nothing is an error the
|
||||||
// caller can act on.
|
// caller can act on.
|
||||||
let mut restored: Vec<NodeUid> = Vec::with_capacity(parsed.len());
|
let mut restored: Vec<NodeUid> = Vec::with_capacity(all.len());
|
||||||
let mut first_error: Option<ProtonError> = None;
|
let mut first_error: Option<ProtonError> = None;
|
||||||
self.rt
|
for wave in &waves {
|
||||||
.block_on(async {
|
self.rt
|
||||||
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(&parsed));
|
.block_on(async {
|
||||||
while let Some(item) = outcomes.next().await {
|
let mut outcomes = std::pin::pin!(self.client.restore_nodes_streaming(wave));
|
||||||
let (uid, outcome) = item?;
|
while let Some(item) = outcomes.next().await {
|
||||||
match outcome {
|
let (uid, outcome) = item?;
|
||||||
Ok(()) => {
|
match outcome {
|
||||||
self.hidden.lock().remove(&uid);
|
Ok(()) => {
|
||||||
restored.push(uid);
|
self.hidden.lock().remove(&uid);
|
||||||
}
|
restored.push(uid);
|
||||||
Err(e) => {
|
}
|
||||||
warn!(%uid, error = %e, "restore failed for a node");
|
Err(e) => {
|
||||||
first_error.get_or_insert(e);
|
warn!(%uid, error = %e, "restore failed for a node");
|
||||||
|
first_error.get_or_insert(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Ok::<(), ProtonError>(())
|
||||||
Ok::<(), ProtonError>(())
|
})
|
||||||
})
|
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
||||||
.map_err(|e| CoreError::from_api(&e, "restore"))?;
|
}
|
||||||
if restored.is_empty()
|
if restored.is_empty()
|
||||||
&& let Some(error) = first_error
|
&& let Some(error) = first_error
|
||||||
{
|
{
|
||||||
@@ -4810,7 +4994,7 @@ fn public_link_info(link: proton_drive_rs::PublicLink) -> PublicLinkInfo {
|
|||||||
|
|
||||||
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
/// Parse a `volume~link` uid display string back into a [`NodeUid`]. Front-ends
|
||||||
/// receive uids as strings over the control socket and pass them back verbatim.
|
/// receive uids as strings over the control socket and pass them back verbatim.
|
||||||
fn parse_uid(s: &str) -> Option<NodeUid> {
|
pub(crate) fn parse_uid(s: &str) -> Option<NodeUid> {
|
||||||
let (vol, link) = s.split_once('~')?;
|
let (vol, link) = s.split_once('~')?;
|
||||||
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
if vol.is_empty() || link.is_empty() || link.contains('~') {
|
||||||
return None;
|
return None;
|
||||||
@@ -4947,6 +5131,8 @@ fn local_node(uid: NodeUid, parent_uid: NodeUid, name: String, is_dir: bool) ->
|
|||||||
album: None,
|
album: None,
|
||||||
// Nothing signed it: it has never been near the crypto layer.
|
// Nothing signed it: it has never been near the crypto layer.
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4982,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
|
/// Convert a stored synced folder into its wire form for the front-ends, with
|
||||||
/// the live progress of its pass when one is running.
|
/// the live progress of its pass when one is running.
|
||||||
fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> SyncFolderInfo {
|
fn sync_folder_info(
|
||||||
|
f: StoredSyncFolder,
|
||||||
|
progress: Option<SyncProgress>,
|
||||||
|
paused: bool,
|
||||||
|
) -> SyncFolderInfo {
|
||||||
SyncFolderInfo {
|
SyncFolderInfo {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
local_path: f.local_path,
|
local_path: f.local_path,
|
||||||
@@ -4992,6 +5182,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
|||||||
state: f.state,
|
state: f.state,
|
||||||
last_sync: f.last_sync,
|
last_sync: f.last_sync,
|
||||||
progress,
|
progress,
|
||||||
|
paused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5230,6 +5421,96 @@ fn node_size(node: &Node) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand a restore request across the trashed tree, and order it so a parent is
|
||||||
|
/// always restored before its children.
|
||||||
|
///
|
||||||
|
/// `relations` is the persisted trash listing as `(uid, parent_uid)` pairs; a
|
||||||
|
/// parent that is not itself in the listing is a live folder, which ends the
|
||||||
|
/// walk upward. The rule is:
|
||||||
|
///
|
||||||
|
/// - every trashed descendant of a requested node joins the restore, so a folder
|
||||||
|
/// comes back with the items that were trashed inside it;
|
||||||
|
/// - every trashed ancestor of a requested node joins it too, so a file never
|
||||||
|
/// lands in a folder that is still in the trash;
|
||||||
|
/// - a requested uid the listing does not know about is restored on its own,
|
||||||
|
/// because a stale or half-materialised listing must not silently drop it.
|
||||||
|
///
|
||||||
|
/// The result is one wave per depth, shallowest first. Waves are disjoint and
|
||||||
|
/// together contain each uid exactly once.
|
||||||
|
fn expand_restore(
|
||||||
|
requested: &[String],
|
||||||
|
relations: &[(String, Option<String>)],
|
||||||
|
) -> Vec<Vec<String>> {
|
||||||
|
let parents: HashMap<&str, Option<&str>> = relations
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, parent)| (uid.as_str(), parent.as_deref()))
|
||||||
|
.collect();
|
||||||
|
let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||||
|
for (uid, parent) in relations {
|
||||||
|
// Only a trashed parent: a live one keeps its child as a root here.
|
||||||
|
if let Some(parent) = parent.as_deref()
|
||||||
|
&& parents.contains_key(parent)
|
||||||
|
{
|
||||||
|
children.entry(parent).or_default().push(uid.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected: HashSet<&str> = HashSet::new();
|
||||||
|
for uid in requested {
|
||||||
|
let uid = uid.as_str();
|
||||||
|
if !parents.contains_key(uid) {
|
||||||
|
selected.insert(uid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Upward: every trashed ancestor, so the destination folder exists again.
|
||||||
|
let mut cursor = Some(uid);
|
||||||
|
while let Some(current) = cursor {
|
||||||
|
// A parent outside the listing is live: the walk stops under it.
|
||||||
|
if !parents.contains_key(current) || !selected.insert(current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = parents.get(current).copied().flatten();
|
||||||
|
}
|
||||||
|
// Downward: the whole trashed subtree, so the folder comes back full.
|
||||||
|
let mut stack = vec![uid];
|
||||||
|
while let Some(current) = stack.pop() {
|
||||||
|
for child in children.get(current).into_iter().flatten() {
|
||||||
|
if selected.insert(child) {
|
||||||
|
stack.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth within the *trash*, not within the drive: a node whose parent is live
|
||||||
|
// (or unknown) is at depth 0 and can be restored straight away.
|
||||||
|
let mut waves: Vec<Vec<String>> = Vec::new();
|
||||||
|
for uid in &selected {
|
||||||
|
let mut depth = 0usize;
|
||||||
|
let mut cursor = parents.get(uid).copied().flatten();
|
||||||
|
// Only trashed ancestors count, and the walk is bounded by the listing's
|
||||||
|
// size: server data that somehow describes a cycle must not spin here.
|
||||||
|
while let Some(current) = cursor
|
||||||
|
&& parents.contains_key(current)
|
||||||
|
&& depth < parents.len()
|
||||||
|
{
|
||||||
|
depth += 1;
|
||||||
|
cursor = parents.get(current).copied().flatten();
|
||||||
|
}
|
||||||
|
if waves.len() <= depth {
|
||||||
|
waves.resize(depth + 1, Vec::new());
|
||||||
|
}
|
||||||
|
waves[depth].push((*uid).to_string());
|
||||||
|
}
|
||||||
|
// Stable output, so a restore of the same trash is the same sequence of calls
|
||||||
|
// every time — the batches are what shows up in the log and in the tests.
|
||||||
|
for wave in &mut waves {
|
||||||
|
wave.sort();
|
||||||
|
}
|
||||||
|
waves.retain(|wave| !wave.is_empty());
|
||||||
|
waves
|
||||||
|
}
|
||||||
|
|
||||||
/// The server revision id of a node's active revision, if it is a file that has
|
/// The server revision id of a node's active revision, if it is a file that has
|
||||||
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
/// one. The stable identity the drain conflict-checks against (see [`Baseline`]).
|
||||||
fn node_revision_id(node: &Node) -> Option<String> {
|
fn node_revision_id(node: &Node) -> Option<String> {
|
||||||
@@ -5871,6 +6152,8 @@ mod pending_size_tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5928,15 +6211,159 @@ mod tests {
|
|||||||
use super::{
|
use super::{
|
||||||
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
Access, AccessFlags, Errno, HashMap, Intervals, PendingRevision, RootListingSnapshot,
|
||||||
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
SELF_CHANGE_TTL_MS, ShareId, SharedWithMeItem, StateRegistry, VirtualRootPlan,
|
||||||
accepted_share_provenance, conflict_name, copy_pending_for_truncate, fuse_name,
|
accepted_share_provenance, conflict_name, copy_pending_for_truncate, expand_restore,
|
||||||
is_stale_mount, node_visible, note_self_change, parse_node_uid, prepare_shared_roots,
|
fuse_name, is_stale_mount, node_visible, note_self_change, orphan_past_retention,
|
||||||
preserve_on_access_denied, publish_virtual_root_in_listing,
|
parse_node_uid, prepare_shared_roots, preserve_on_access_denied,
|
||||||
reconcile_virtual_root_in_listing, release_can_discard_unlinked,
|
publish_virtual_root_in_listing, reconcile_virtual_root_in_listing,
|
||||||
release_must_retain_queued_trash, release_unlinked_entry, rename_needs_queue,
|
release_can_discard_unlinked, release_must_retain_queued_trash, release_unlinked_entry,
|
||||||
require_node_parent_access, require_rename_access, resolve_anywhere_with,
|
rename_needs_queue, require_node_parent_access, require_rename_access,
|
||||||
shared_with_me_uid, take_self_change, uid_write_authority, virtual_node,
|
resolve_anywhere_with, shared_with_me_uid, take_self_change, uid_write_authority,
|
||||||
|
virtual_node,
|
||||||
};
|
};
|
||||||
use super::{Db, WriteAuthority};
|
use super::{Db, WriteAuthority};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Staged bytes that name no node are kept for a month, because the only
|
||||||
|
/// copy of a write must outlive the bug that stranded it. Past that, one
|
||||||
|
/// boot retires them instead of re-announcing them every boot forever.
|
||||||
|
#[test]
|
||||||
|
fn a_stranded_blob_is_retired_only_once_it_outlives_retention() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("pdfs-orphan-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let blob = dir.join("blob");
|
||||||
|
std::fs::write(&blob, b"twelve bytes").unwrap();
|
||||||
|
|
||||||
|
// Written just now: still recoverable, still kept.
|
||||||
|
assert_eq!(orphan_past_retention(&blob), None);
|
||||||
|
|
||||||
|
set_mtime_ago(
|
||||||
|
&blob,
|
||||||
|
super::STAGING_ORPHAN_RETAIN + Duration::from_secs(60),
|
||||||
|
);
|
||||||
|
assert_eq!(orphan_past_retention(&blob), Some(12));
|
||||||
|
|
||||||
|
// A blob that is not there is not a blob to retire.
|
||||||
|
std::fs::remove_file(&blob).unwrap();
|
||||||
|
assert_eq!(orphan_past_retention(&blob), None);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backdate a file's mtime, which is what the retention check reads.
|
||||||
|
fn set_mtime_ago(path: &std::path::Path, ago: Duration) {
|
||||||
|
let when = std::time::SystemTime::now() - ago;
|
||||||
|
let secs = when
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as libc::time_t;
|
||||||
|
let times = [
|
||||||
|
libc::timeval {
|
||||||
|
tv_sec: secs,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
|
libc::timeval {
|
||||||
|
tv_sec: secs,
|
||||||
|
tv_usec: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
|
||||||
|
// SAFETY: both pointers are valid for the call and nothing keeps them.
|
||||||
|
let rc = unsafe { libc::utimes(c_path.as_ptr(), times.as_ptr()) };
|
||||||
|
assert_eq!(rc, 0, "utimes: {}", std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(uid, parent)` pairs in the shape [`Db::trash_parents`] returns.
|
||||||
|
fn relations(pairs: &[(&str, Option<&str>)]) -> Vec<(String, Option<String>)> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(uid, parent)| (uid.to_string(), parent.map(str::to_string)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uids(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|v| v.to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restoring a folder brings back what was trashed inside it, and the folder
|
||||||
|
/// goes first: the server has nowhere to put a child whose parent is trashed.
|
||||||
|
#[test]
|
||||||
|
fn restoring_a_folder_restores_its_trashed_contents_parents_first() {
|
||||||
|
let trash = relations(&[
|
||||||
|
("dir", Some("live")),
|
||||||
|
("file", Some("dir")),
|
||||||
|
("deep", Some("sub")),
|
||||||
|
("sub", Some("dir")),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["dir"]), &trash),
|
||||||
|
vec![
|
||||||
|
vec!["dir".to_string()],
|
||||||
|
vec!["file".to_string(), "sub".to_string()],
|
||||||
|
vec!["deep".to_string()],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restoring a file inside a trashed folder restores the folder too —
|
||||||
|
/// otherwise the file comes back somewhere the user cannot reach.
|
||||||
|
#[test]
|
||||||
|
fn restoring_content_restores_the_trashed_parents_above_it() {
|
||||||
|
let trash = relations(&[
|
||||||
|
("dir", Some("live")),
|
||||||
|
("sub", Some("dir")),
|
||||||
|
("file", Some("sub")),
|
||||||
|
("other", Some("dir")),
|
||||||
|
]);
|
||||||
|
// `other` is a sibling of `sub`, not an ancestor of `file`: it stays put.
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["file"]), &trash),
|
||||||
|
vec![
|
||||||
|
vec!["dir".to_string()],
|
||||||
|
vec!["sub".to_string()],
|
||||||
|
vec!["file".to_string()],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A node whose parent is not in the trash is already at the top: one wave,
|
||||||
|
/// no ancestors invented.
|
||||||
|
#[test]
|
||||||
|
fn a_node_under_a_live_parent_restores_on_its_own() {
|
||||||
|
let trash = relations(&[("file", Some("live")), ("elsewhere", None)]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["file"]), &trash),
|
||||||
|
vec![vec!["file".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listing is materialised in chunks and can be stale, so a uid it does
|
||||||
|
/// not know about must still be restored rather than dropped.
|
||||||
|
#[test]
|
||||||
|
fn an_unlisted_uid_is_still_restored() {
|
||||||
|
let trash = relations(&[("dir", None)]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["missing"]), &trash),
|
||||||
|
vec![vec!["missing".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asking for both a folder and something inside it restores each node once.
|
||||||
|
#[test]
|
||||||
|
fn overlapping_requests_do_not_duplicate_nodes() {
|
||||||
|
let trash = relations(&[("dir", None), ("file", Some("dir"))]);
|
||||||
|
assert_eq!(
|
||||||
|
expand_restore(&uids(&["dir", "file"]), &trash),
|
||||||
|
vec![vec!["dir".to_string()], vec!["file".to_string()]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent chain that loops back on itself must terminate.
|
||||||
|
#[test]
|
||||||
|
fn a_cyclic_listing_terminates() {
|
||||||
|
let trash = relations(&[("a", Some("b")), ("b", Some("a"))]);
|
||||||
|
let waves = expand_restore(&uids(&["a"]), &trash);
|
||||||
|
let total: usize = waves.iter().map(Vec::len).sum();
|
||||||
|
assert_eq!(total, 2);
|
||||||
|
}
|
||||||
|
|
||||||
/// The collapse [`Core::require_uid_writable`] performs, without a `Core`:
|
/// The collapse [`Core::require_uid_writable`] performs, without a `Core`:
|
||||||
/// every non-writable authority is one `EACCES` on the syscall path.
|
/// every non-writable authority is one `EACCES` on the syscall path.
|
||||||
@@ -7081,6 +7508,9 @@ mod tests {
|
|||||||
share_id,
|
share_id,
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
});
|
});
|
||||||
node
|
node
|
||||||
};
|
};
|
||||||
@@ -7541,10 +7971,44 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod thumbnail_miss_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_misses_never_suppress_local_generation() {
|
||||||
|
let uid = parse_uid("volume~node").expect("uid");
|
||||||
|
let key = (uid, ThumbnailType::Thumbnail.as_i32());
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
|
||||||
|
misses.remember_remote(key.clone(), 42);
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
|
||||||
|
misses.remember_local(key.clone(), 42);
|
||||||
|
assert!(misses.local_contains(&key, 42));
|
||||||
|
misses.forget_local(&key);
|
||||||
|
assert!(!misses.local_contains(&key, 42));
|
||||||
|
assert!(misses.remote_contains(&key, 42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_miss_cache_is_bounded_on_gui_only_paths() {
|
||||||
|
let mut misses = ThumbnailMissCaches::default();
|
||||||
|
for index in 0..=MAX_THUMBNAIL_MISSES {
|
||||||
|
let uid = parse_uid(&format!("volume~node-{index}")).expect("uid");
|
||||||
|
misses.remember_local((uid, ThumbnailType::Thumbnail.as_i32()), 42);
|
||||||
|
}
|
||||||
|
assert_eq!(misses.local.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod search_root_tests {
|
mod search_root_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
pub sweep_mode: SweepMode,
|
pub sweep_mode: SweepMode,
|
||||||
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
|
pub upload_limit: u64,
|
||||||
|
pub download_limit: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -340,7 +343,12 @@ pub fn mount(
|
|||||||
let MountOptions {
|
let MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
upload_limit,
|
||||||
|
download_limit,
|
||||||
} = options;
|
} = options;
|
||||||
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
|
// so the age it reports is the daemon's own.
|
||||||
|
super::diagnostics::start_clock();
|
||||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||||
@@ -354,6 +362,7 @@ pub fn mount(
|
|||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
|
|
||||||
|
let paused_until = pause::load_paused_until(&db);
|
||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
@@ -374,6 +383,7 @@ pub fn mount(
|
|||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
@@ -389,7 +399,15 @@ pub fn mount(
|
|||||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
|
super::photos::THUMB_GEN_CONCURRENCY,
|
||||||
|
)),
|
||||||
|
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||||
|
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||||
quota: Arc::new(Mutex::new(None)),
|
quota: Arc::new(Mutex::new(None)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
size_waiters: Arc::new(Default::default()),
|
size_waiters: Arc::new(Default::default()),
|
||||||
@@ -406,6 +424,7 @@ pub fn mount(
|
|||||||
// Before anything can queue work against it: the drain thread below reaches
|
// Before anything can queue work against it: the drain thread below reaches
|
||||||
// every mount's inode space through this registry, not through `core.state`.
|
// every mount's inode space through this registry, not through `core.state`.
|
||||||
core.register_state(mountpoint);
|
core.register_state(mountpoint);
|
||||||
|
core.transfers.set_limits(upload_limit, download_limit);
|
||||||
|
|
||||||
// Writes queued by a previous run (or left behind by a crash) are still owed
|
// Writes queued by a previous run (or left behind by a crash) are still owed
|
||||||
// an upload, and reads must be served from their staged blobs until they land.
|
// an upload, and reads must be served from their staged blobs until they land.
|
||||||
@@ -440,6 +459,10 @@ pub fn mount(
|
|||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
workers.extend(sync::spawn(core.clone(), sync_rx));
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
// A timed pause persisted by an earlier run still has to end on time.
|
||||||
|
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||||
|
core.arm_pause_timer(paused_until);
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -543,6 +566,26 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
|
// socket.
|
||||||
|
{
|
||||||
|
let supervisor_core = core.clone();
|
||||||
|
let socket = control_socket.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-supervisor".into())
|
||||||
|
.spawn(move || supervisor::run(supervisor_core, socket))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: the mount works without a supervisor, it just goes back
|
||||||
|
// to being undiagnosable if it wedges.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The mount is live and the control socket answers: this is the moment the
|
||||||
|
// unit is actually usable, which is what `Type=notify` reports.
|
||||||
|
systemd::ready();
|
||||||
|
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
// fetch to observe that closure, and then drain a stable registry.
|
// fetch to observe that closure, and then drain a stable registry.
|
||||||
@@ -560,6 +603,9 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
rt.spawn(run_event_sync(client, scope, core.clone()));
|
rt.spawn(run_event_sync(client, scope, core.clone()));
|
||||||
|
// The photos volume has its own event stream; without it the gallery only
|
||||||
|
// learns about a phone-side deletion when the timeline goes stale.
|
||||||
|
rt.spawn(run_photos_event_sync(core.clone()));
|
||||||
if online {
|
if online {
|
||||||
let repair_core = core.clone();
|
let repair_core = core.clone();
|
||||||
let root_uid = core.primary_root_uid.clone();
|
let root_uid = core.primary_root_uid.clone();
|
||||||
@@ -655,6 +701,11 @@ pub fn mount(
|
|||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long teardown waits for the FUSE worker pool before proceeding without
|
||||||
|
/// it. Long enough for an in-flight block to finish, short enough that
|
||||||
|
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
||||||
|
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Signal every background loop, wake the ones that are blocked, and join them.
|
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||||
///
|
///
|
||||||
/// The mount used to return with its drain workers, sync engine, sweep, online
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
@@ -669,6 +720,9 @@ pub fn mount(
|
|||||||
/// expected and correct: a worker part-way through an upload finishes it rather
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
/// than abandoning the user's bytes mid-flight.
|
/// than abandoning the user's bytes mid-flight.
|
||||||
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
|
// Say so before the joins: a teardown that takes a while must not be read as
|
||||||
|
// a missed watchdog ping and turned into a kill.
|
||||||
|
systemd::stopping();
|
||||||
core.shutdown.stop();
|
core.shutdown.stop();
|
||||||
core.wake_drain();
|
core.wake_drain();
|
||||||
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
@@ -683,6 +737,12 @@ fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::Jo
|
|||||||
warn!("a background worker panicked before shutdown");
|
warn!("a background worker panicked before shutdown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||||
|
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||||
|
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||||
|
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||||
|
// network cannot hold the stop the user asked for.
|
||||||
|
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
||||||
debug!("background workers stopped");
|
debug!("background workers stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! Pausing sync: the user's "stop uploading for a while".
|
||||||
|
//!
|
||||||
|
//! A pause holds back the two things that push local state to the remote — the
|
||||||
|
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||||
|
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||||
|
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||||
|
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||||
|
//!
|
||||||
|
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||||
|
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||||
|
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||||
|
//! deadline that passed while the daemon was down is already over on mount.
|
||||||
|
//!
|
||||||
|
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||||
|
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||||
|
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||||
|
//! go through the shared queue, which only the global pause holds back.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `state` key holding the resume time, in Unix seconds.
|
||||||
|
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||||
|
|
||||||
|
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||||
|
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||||
|
|
||||||
|
fn folder_paused_key(id: i64) -> String {
|
||||||
|
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||||
|
/// counts as running, like the global pause.
|
||||||
|
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||||
|
match db.state_i64(&folder_paused_key(id)) {
|
||||||
|
Ok(value) => value.is_some_and(|v| v != 0),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist synced folder `id`'s own pause, or clear it.
|
||||||
|
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||||
|
match paused {
|
||||||
|
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||||
|
false => db.clear_state(&folder_paused_key(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pause with no end time: it lasts until the user resumes.
|
||||||
|
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||||
|
|
||||||
|
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||||
|
/// broken state row must not keep sync paused forever).
|
||||||
|
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||||
|
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||||
|
Ok(Some(until)) => until,
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||||
|
/// seconds). `0` is "not paused".
|
||||||
|
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||||
|
until != 0 && now < until
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// True while the user has sync paused.
|
||||||
|
pub(crate) fn sync_paused(&self) -> bool {
|
||||||
|
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pause's end, for status reporting: `None` when not paused,
|
||||||
|
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||||
|
/// Unix second `t`.
|
||||||
|
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||||
|
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||||
|
match paused_at(until, now_secs()) {
|
||||||
|
false => None,
|
||||||
|
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||||
|
true => Some(Some(until)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||||
|
/// it when `paused` is false.
|
||||||
|
pub(crate) fn set_sync_paused(
|
||||||
|
&self,
|
||||||
|
paused: bool,
|
||||||
|
until: Option<i64>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let value = match (paused, until) {
|
||||||
|
(false, _) => 0,
|
||||||
|
(true, None) => PAUSED_INDEFINITELY,
|
||||||
|
(true, Some(until)) if until <= now_secs() => {
|
||||||
|
return Err(CoreError::invalid("the pause would already be over"));
|
||||||
|
}
|
||||||
|
(true, Some(until)) => until,
|
||||||
|
};
|
||||||
|
let persisted = match value {
|
||||||
|
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||||
|
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||||
|
};
|
||||||
|
if let Err(error) = persisted {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the sync pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||||
|
match value {
|
||||||
|
0 => {
|
||||||
|
info!("sync resumed");
|
||||||
|
self.resume_sync_work();
|
||||||
|
}
|
||||||
|
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||||
|
until => {
|
||||||
|
info!(until, "sync paused");
|
||||||
|
self.arm_pause_timer(until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the user has this one synced folder paused. A row that cannot
|
||||||
|
/// be read counts as running, like the global pause.
|
||||||
|
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||||
|
load_folder_paused(&self.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||||
|
/// straight away to catch up on what the watcher saw meanwhile.
|
||||||
|
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||||
|
let folder = self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if paused && folder.mode != "mirror" {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||||
|
through the shared queue, so pause all sync instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the folder pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync paused");
|
||||||
|
} else {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||||
|
self.sync_now(Some(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||||
|
/// not start out paused.
|
||||||
|
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||||
|
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake everything a pause held back: the drain worker, and a full
|
||||||
|
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||||
|
fn resume_sync_work(&self) {
|
||||||
|
self.wake_drain();
|
||||||
|
self.sync_now(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||||
|
/// the stored deadline, which the timer checks before acting, so a stale
|
||||||
|
/// timer does nothing.
|
||||||
|
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||||
|
let core = self.clone();
|
||||||
|
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-pause".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if !core.shutdown.sleep(wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if core
|
||||||
|
.sync_paused_until
|
||||||
|
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||||
|
warn!(%error, "clearing an expired sync pause failed");
|
||||||
|
}
|
||||||
|
info!("sync pause ended");
|
||||||
|
core.resume_sync_work();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
// The drain and the reconcile still read the clock, so the pause
|
||||||
|
// still ends — just at the next idle poll instead of on time.
|
||||||
|
warn!(%error, "starting the sync pause timer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pause_is_in_force_until_its_deadline() {
|
||||||
|
assert!(!paused_at(0, 100));
|
||||||
|
assert!(paused_at(200, 100));
|
||||||
|
assert!(!paused_at(200, 200));
|
||||||
|
assert!(!paused_at(200, 300));
|
||||||
|
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
store_folder_paused(&db, 3, true).unwrap();
|
||||||
|
assert!(load_folder_paused(&db, 3));
|
||||||
|
assert!(!load_folder_paused(&db, 4));
|
||||||
|
assert!(!load_folder_paused(&db, 33));
|
||||||
|
store_folder_paused(&db, 3, false).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1280
-63
File diff suppressed because it is too large.
Load diff
@@ -248,15 +248,19 @@ impl Core {
|
|||||||
|
|
||||||
// ---- restore (features.md 5.2) ----------------------------------------
|
// ---- restore (features.md 5.2) ----------------------------------------
|
||||||
|
|
||||||
/// The folders under this machine's device that could be synced here, each
|
/// The folders under a device that could be synced here, each carrying a
|
||||||
/// carrying a proposed local path.
|
/// proposed local path. `device` names another registered device by uid;
|
||||||
|
/// `None` is this machine's own.
|
||||||
///
|
///
|
||||||
/// The proposal comes from the profile when one exists and its path is
|
/// The proposal comes from the profile when one exists and its path is
|
||||||
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
/// usable on this machine, and from `~/<name>` otherwise — a restored
|
||||||
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
/// `/home/other-user/Documents` would be wrong on a machine where that user
|
||||||
/// does not exist, and silently creating it would be worse.
|
/// does not exist, and silently creating it would be worse.
|
||||||
pub(crate) fn list_restorable_folders(&self) -> CoreResult<Vec<RestorableFolder>> {
|
pub(crate) fn list_restorable_folders(
|
||||||
let device = self.ensure_device()?;
|
&self,
|
||||||
|
device: Option<&str>,
|
||||||
|
) -> CoreResult<Vec<RestorableFolder>> {
|
||||||
|
let device = self.restore_source(device)?;
|
||||||
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
let root_uid = parse_uid(&device.root_uid).ok_or_else(|| {
|
||||||
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
CoreError::internal(format!("bad device root uid: {}", device.root_uid))
|
||||||
})?;
|
})?;
|
||||||
@@ -334,11 +338,12 @@ impl Core {
|
|||||||
pub(crate) fn restore_sync_folders(
|
pub(crate) fn restore_sync_folders(
|
||||||
&self,
|
&self,
|
||||||
items: &[pdfs_core::control::RestoreItem],
|
items: &[pdfs_core::control::RestoreItem],
|
||||||
|
device: Option<&str>,
|
||||||
) -> CoreResult<String> {
|
) -> CoreResult<String> {
|
||||||
if items.is_empty() {
|
if items.is_empty() {
|
||||||
return Err(CoreError::invalid("nothing to restore"));
|
return Err(CoreError::invalid("nothing to restore"));
|
||||||
}
|
}
|
||||||
let device = self.ensure_device()?;
|
let device = self.restore_source(device)?;
|
||||||
let existing = self
|
let existing = self
|
||||||
.db
|
.db
|
||||||
.sync_folder_list()
|
.sync_folder_list()
|
||||||
@@ -499,6 +504,19 @@ mod tests {
|
|||||||
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
assert_before(add, "PROFILE_DIR_NAME", "ensure_device()");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restoring another computer's folders reads that device, but must not
|
||||||
|
/// rewrite the cached device row: this machine would then back up as the
|
||||||
|
/// other computer.
|
||||||
|
#[test]
|
||||||
|
fn restoring_from_another_device_keeps_this_machines_identity() {
|
||||||
|
let source = function_source(include_str!("devices.rs"), "fn restore_source(");
|
||||||
|
assert!(source.contains("ensure_device()"), "None is this machine");
|
||||||
|
assert!(
|
||||||
|
!source.contains("device_set"),
|
||||||
|
"the cached row is untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
fn function_source<'a>(source: &'a str, signature: &str) -> &'a str {
|
||||||
let start = source.find(signature).expect("function signature exists");
|
let start = source.find(signature).expect("function signature exists");
|
||||||
let body = &source[start..];
|
let body = &source[start..];
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//! The user's view of the pending-op queue: what has not reached the remote
|
||||||
|
//! yet, why, and a way to stop waiting out a backoff.
|
||||||
|
//!
|
||||||
|
//! Read-only apart from "retry now", which only moves a row's next-attempt time.
|
||||||
|
//! Nothing here drops a queued op: its staged blob is the only copy of the
|
||||||
|
//! user's write (see `docs/RECOVERY.md`).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::PendingOpInfo;
|
||||||
|
use pdfs_core::db::{FAILING_ATTEMPTS, PARK_UNTIL};
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Every queued op, oldest first, with a path a person can read.
|
||||||
|
pub(crate) fn pending_op_infos(&self) -> CoreResult<Vec<PendingOpInfo>> {
|
||||||
|
let ops = self
|
||||||
|
.db
|
||||||
|
.pending_ops()
|
||||||
|
.map_err(|e| CoreError::internal(format!("listing the queue: {e}")))?;
|
||||||
|
Ok(ops
|
||||||
|
.into_iter()
|
||||||
|
.map(|op| {
|
||||||
|
let parked = op.next_attempt_at >= PARK_UNTIL;
|
||||||
|
let path = self
|
||||||
|
.db
|
||||||
|
.node_path(&op.uid)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.filter(|path| !path.is_empty())
|
||||||
|
.or_else(|| op.name.clone())
|
||||||
|
.unwrap_or_else(|| op.uid.clone());
|
||||||
|
PendingOpInfo {
|
||||||
|
id: op.id,
|
||||||
|
kind: op.kind,
|
||||||
|
path,
|
||||||
|
attempts: op.attempts,
|
||||||
|
last_error: op.last_error,
|
||||||
|
queued_at: op.created_at / 1000,
|
||||||
|
next_attempt_at: (!parked).then_some(op.next_attempt_at / 1000),
|
||||||
|
parked,
|
||||||
|
failing: op.attempts >= FAILING_ATTEMPTS,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry one backed-off op now, or every failed one when `id` is `None`.
|
||||||
|
/// Returns how many ops were moved up.
|
||||||
|
pub(crate) fn retry_pending_ops(&self, id: Option<i64>) -> CoreResult<usize> {
|
||||||
|
let now = now_millis();
|
||||||
|
let moved = match id {
|
||||||
|
Some(id) => self.db.retry_op_now(id, now).map(usize::from),
|
||||||
|
None => self.db.retry_failed_ops_now(now),
|
||||||
|
}
|
||||||
|
.map_err(|e| CoreError::internal(format!("retrying the queue: {e}")))?;
|
||||||
|
if moved > 0 {
|
||||||
|
self.wake_drain();
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Repairing photos whose capture time is when they were *imported* rather than
|
||||||
|
//! when they were taken.
|
||||||
|
//!
|
||||||
|
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
|
||||||
|
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
|
||||||
|
//! and the SDK then seals "now" into every revision. A decade of photos lands on
|
||||||
|
//! one day.
|
||||||
|
//!
|
||||||
|
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||||
|
//! rewrite: download the photo, upload it again with the capture time its file
|
||||||
|
//! name implies, put the new copy back into the albums and favorites the old
|
||||||
|
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||||
|
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||||
|
//!
|
||||||
|
//! A photo is a candidate only when its name carries a date that disagrees with
|
||||||
|
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
|
||||||
|
//! a photo whose name says nothing about its date is left alone, because there
|
||||||
|
//! is nothing better to set it to.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{RedateSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::parse_uid as parse_node_uid;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// How far a stored capture time may sit from the one the file name implies
|
||||||
|
/// before the photo counts as mis-dated. A day, because a name usually carries
|
||||||
|
/// only the date and the stored time carries a clock — and because a timezone
|
||||||
|
/// can legitimately move a photo by hours.
|
||||||
|
const DRIFT: i64 = 86_400;
|
||||||
|
|
||||||
|
/// How many examples a report carries.
|
||||||
|
const SAMPLES: usize = 10;
|
||||||
|
|
||||||
|
/// The one re-date this daemon may be running, and how the last one ended.
|
||||||
|
/// Process-global for the same reasons as the Takeout import's state: one run
|
||||||
|
/// per daemon however many `Core` clones exist, and a front-end asking after it
|
||||||
|
/// finished must still find the report.
|
||||||
|
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RedateState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<RedateSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a re-date is running, and the last one's report.
|
||||||
|
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||||
|
let state = REDATE.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running re-date to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_redate() -> bool {
|
||||||
|
match &REDATE.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to repair.
|
||||||
|
struct Candidate {
|
||||||
|
uid: NodeUid,
|
||||||
|
name: String,
|
||||||
|
/// The capture time it carries now.
|
||||||
|
stored: i64,
|
||||||
|
/// The capture time its name implies.
|
||||||
|
wanted: i64,
|
||||||
|
favorite: bool,
|
||||||
|
/// Album node uids the original is filed under.
|
||||||
|
albums: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Re-date mis-dated photos. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary. A photo is only ever
|
||||||
|
/// trashed after its replacement has uploaded, so a cancel — or a crash —
|
||||||
|
/// can leave a duplicate, never a hole.
|
||||||
|
pub(crate) fn redate_photos(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<RedateSummary> {
|
||||||
|
let job = self.transfers.begin_job("Fixing photo dates");
|
||||||
|
job.detail("looking for mis-dated photos");
|
||||||
|
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.photos_all()
|
||||||
|
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
|
||||||
|
let mut summary = RedateSummary {
|
||||||
|
examined: stored.len(),
|
||||||
|
dry_run,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for photo in stored {
|
||||||
|
if let Some((from, to)) = range
|
||||||
|
&& !(from..to).contains(&photo.capture_time)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(wanted) = capture_time_from_name(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if (wanted - photo.capture_time).abs() <= DRIFT {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(uid) = parse_node_uid(&photo.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
candidates.push(Candidate {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
stored: photo.capture_time,
|
||||||
|
wanted,
|
||||||
|
favorite: photo.favorite,
|
||||||
|
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
candidates.truncate(limit);
|
||||||
|
}
|
||||||
|
summary.candidates = candidates.len();
|
||||||
|
// Sampled after the limit, so the examples are of photos the run would
|
||||||
|
// actually touch.
|
||||||
|
summary.samples = candidates
|
||||||
|
.iter()
|
||||||
|
.take(SAMPLES)
|
||||||
|
.map(|c| (c.name.clone(), c.stored, c.wanted))
|
||||||
|
.collect();
|
||||||
|
job.set_total(candidates.len() as u64);
|
||||||
|
info!(
|
||||||
|
examined = summary.examined,
|
||||||
|
candidates = summary.candidates,
|
||||||
|
dry_run,
|
||||||
|
"photo re-date scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
if dry_run || candidates.is_empty() {
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
job.detail(candidate.name.clone());
|
||||||
|
if let Err(e) = self.redate_one(&candidate, &mut summary) {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-date photo");
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite one photo at the capture time its name implies.
|
||||||
|
///
|
||||||
|
/// Order is load-bearing: upload first, file the new copy, and only then
|
||||||
|
/// trash the original. Every earlier step failing leaves the account exactly
|
||||||
|
/// as it was.
|
||||||
|
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let path = self.open_photo(&candidate.uid)?;
|
||||||
|
let size = std::fs::metadata(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||||
|
.len();
|
||||||
|
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
let guard =
|
||||||
|
self.transfers
|
||||||
|
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
|
||||||
|
let file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
|
||||||
|
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: Some(candidate.wanted),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&candidate.name,
|
||||||
|
media_type,
|
||||||
|
reader,
|
||||||
|
size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
drop(guard);
|
||||||
|
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||||
|
|
||||||
|
// Favorite and albums before the trash: a failure here leaves both
|
||||||
|
// copies on the account, which is recoverable by hand. Losing the
|
||||||
|
// original first would not be.
|
||||||
|
if candidate.favorite {
|
||||||
|
let update = PhotoTagsUpdate {
|
||||||
|
node_uid: new_uid.clone(),
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
};
|
||||||
|
match self.rt.block_on(photos.update_photos(&[update])) {
|
||||||
|
// As in `set_photo_favorite`: claim our own echo, so the tag we
|
||||||
|
// just set does not come back as a remote change.
|
||||||
|
Ok(_) => self.note_self_change(&new_uid),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for album in &candidate.albums {
|
||||||
|
let Some(album_uid) = parse_node_uid(album) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
|
||||||
|
{
|
||||||
|
Ok(_) => summary.album_links += 1,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||||
|
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||||
|
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.redated += 1;
|
||||||
|
summary.bytes += size;
|
||||||
|
info!(
|
||||||
|
name = candidate.name,
|
||||||
|
from = candidate.stored,
|
||||||
|
to = candidate.wanted,
|
||||||
|
"photo re-dated"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a re-date on its own thread. Errors when one is already running.
|
||||||
|
pub(crate) fn spawn_redate(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"a photo re-date is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.redate_photos(dry_run, limit, range, cancel);
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
match &outcome {
|
||||||
|
Ok(summary) => info!(
|
||||||
|
candidates = summary.candidates,
|
||||||
|
redated = summary.redated,
|
||||||
|
failed = summary.failed,
|
||||||
|
"photo re-date finished"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(error = %e, "photo re-date failed"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_uid_without_both_halves_is_not_a_node() {
|
||||||
|
assert!(parse_node_uid("vol~link").is_some());
|
||||||
|
assert!(parse_node_uid("vol~").is_none());
|
||||||
|
assert!(parse_node_uid("~link").is_none());
|
||||||
|
assert!(parse_node_uid("nothing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_disagreement_larger_than_a_day_counts() {
|
||||||
|
// The name says 2023-02-19; a stored time inside that day is fine.
|
||||||
|
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
|
||||||
|
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
|
||||||
|
// An import three years later is not.
|
||||||
|
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use pdfs_core::{CoreError, CoreResult};
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::QUOTA_TTL;
|
use crate::QUOTA_TTL;
|
||||||
|
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{MemberRole, NodeKind};
|
use proton_drive_rs::{MemberRole, NodeKind};
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
.map(role_to_str)
|
.map(role_to_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let invitation = n.membership.as_ref();
|
||||||
|
let shared_by = invitation
|
||||||
|
.and_then(|m| m.inviter_email.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||||
|
// Only a signature that is present and wrong is a warning. An invitation
|
||||||
|
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||||
|
// is not evidence of forgery, and flagging every such share would teach the
|
||||||
|
// user to ignore the flag.
|
||||||
|
let shared_by_unverified = invitation
|
||||||
|
.and_then(|m| m.inviter_verification)
|
||||||
|
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -57,6 +70,9 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
path: core.rel_path_for_uid(&n.uid).unwrap_or_default(),
|
||||||
uid: n.uid.to_string(),
|
uid: n.uid.to_string(),
|
||||||
role,
|
role,
|
||||||
|
shared_by,
|
||||||
|
shared_at,
|
||||||
|
shared_by_unverified,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +388,66 @@ impl Core {
|
|||||||
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
.map_err(|e| CoreError::from_api(&e, "remove public link"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop sharing the node at `rel`: remove its public link, then every
|
||||||
|
/// external invitation, Proton invitation and member. The SDK has no call
|
||||||
|
/// that deletes a share outright, so this composes the per-entry ones.
|
||||||
|
pub(crate) fn stop_sharing(&self, rel: &Path) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let (_ino, uid) = self.resolve(rel)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Core::stop_sharing`] for a node addressed through any daemon location.
|
||||||
|
pub(crate) fn stop_sharing_by_uid(&self, uid: &str) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let uid = self.resolve_anywhere(uid)?;
|
||||||
|
self.stop_sharing_for_uid(&uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each step runs even when an earlier one failed, so a single stuck
|
||||||
|
/// member does not leave the public link up. The link goes first: it is the
|
||||||
|
/// widest exposure. Only a failure to list what is there fails the whole
|
||||||
|
/// request; a failed removal is counted in the outcome.
|
||||||
|
fn stop_sharing_for_uid(&self, uid: &NodeUid) -> CoreResult<StopSharingOutcome> {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
let link = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.get_public_link(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "get public link"))?;
|
||||||
|
if let Some(link) = link {
|
||||||
|
let result = self.rt.block_on(self.client.remove_public_link(&link));
|
||||||
|
outcome.record("the public link", result);
|
||||||
|
}
|
||||||
|
let external = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_external_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list external invitations"))?;
|
||||||
|
for ext in external {
|
||||||
|
let result = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.delete_external_invitation(&ext));
|
||||||
|
outcome.record(&ext.invitee_email, result);
|
||||||
|
}
|
||||||
|
let invitations = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_invitations(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list invitations"))?;
|
||||||
|
for inv in invitations {
|
||||||
|
let result = self.rt.block_on(self.client.delete_invitation(&inv));
|
||||||
|
outcome.record(&inv.invitee_email, result);
|
||||||
|
}
|
||||||
|
let members = self
|
||||||
|
.rt
|
||||||
|
.block_on(self.client.list_share_members(uid))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list members"))?;
|
||||||
|
for member in members {
|
||||||
|
let result = self.rt.block_on(self.client.remove_member(&member));
|
||||||
|
outcome.record(&member.email, result);
|
||||||
|
}
|
||||||
|
if !outcome.failed.is_empty() {
|
||||||
|
debug!(%uid, failed = ?outcome.failed, "stop sharing: some entries remain");
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared with me ---------------------------------------------------
|
// ---- shared with me ---------------------------------------------------
|
||||||
|
|
||||||
/// List nodes shared with me that I have accepted.
|
/// List nodes shared with me that I have accepted.
|
||||||
@@ -609,6 +685,7 @@ impl Core {
|
|||||||
items.push(SharedItem {
|
items.push(SharedItem {
|
||||||
uid: uid.to_string(),
|
uid: uid.to_string(),
|
||||||
is_dir: n.is_folder(),
|
is_dir: n.is_folder(),
|
||||||
|
modified: n.modification_time,
|
||||||
name: n.name,
|
name: n.name,
|
||||||
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
path: self.rel_path_for_uid(&uid).unwrap_or_default(),
|
||||||
member_count: members,
|
member_count: members,
|
||||||
@@ -640,6 +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
|
/// Walk one inode space from `uid` up to its root, building the path. `None` if
|
||||||
/// that mount has never interned the node, or if the chain breaks partway —
|
/// that mount has never interned the node, or if the chain breaks partway —
|
||||||
/// a broken chain is a missing path, never a partial one.
|
/// a broken chain is a missing path, never a partial one.
|
||||||
@@ -654,3 +772,37 @@ fn walk_to_root(st: &super::State, uid: &NodeUid) -> Option<String> {
|
|||||||
parts.reverse();
|
parts.reverse();
|
||||||
Some(parts.join("/"))
|
Some(parts.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stopping_a_share_that_is_gone_says_so() {
|
||||||
|
let outcome = StopSharingOutcome::default();
|
||||||
|
assert_eq!(outcome.into_result().unwrap(), "it was not shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_removal_names_what_is_still_shared() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record("bob@example.com", Err("forbidden"));
|
||||||
|
outcome.record::<String>("carol@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap_err(),
|
||||||
|
"removed 2 of 3; still shared: bob@example.com (forbidden)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_full_removal_counts_the_entries() {
|
||||||
|
let mut outcome = StopSharingOutcome::default();
|
||||||
|
outcome.record::<String>("the public link", Ok(()));
|
||||||
|
outcome.record::<String>("bob@example.com", Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.into_result().unwrap(),
|
||||||
|
"stopped sharing: removed 2 entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1120,6 +1120,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1128,6 +1130,9 @@ mod tests {
|
|||||||
share_id: ShareId::from("share"),
|
share_id: ShareId::from("share"),
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! The thread that watches the daemon for the thing the daemon cannot report
|
||||||
|
//! about itself: that it has stopped making progress.
|
||||||
|
//!
|
||||||
|
//! Three jobs, all cheap, all on one timer:
|
||||||
|
//!
|
||||||
|
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
|
||||||
|
//! queue that is not draining, a control request that never returned — each
|
||||||
|
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
|
||||||
|
//! all; this is what turns the next one into a one-line diagnosis.
|
||||||
|
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
|
||||||
|
//! control socket. Pinging unconditionally from a thread that does nothing
|
||||||
|
//! else would keep the unit "healthy" through exactly the hang it is meant to
|
||||||
|
//! catch: the probe is what makes the ping mean "a client can still talk to
|
||||||
|
//! this daemon".
|
||||||
|
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
|
||||||
|
//! until it was over.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pdfs_core::control::Request as CtlRequest;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use super::systemd;
|
||||||
|
|
||||||
|
/// How often the supervisor looks. Fast enough that a stall is named while the
|
||||||
|
/// user is still watching it, slow enough to cost nothing.
|
||||||
|
const TICK: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// How long one job may hold a worker, or one control request may run, before it
|
||||||
|
/// is called out. Generous: a large upload chunk or a cold folder listing on a
|
||||||
|
/// slow link is minutes of legitimate work, and a false WARN every few minutes
|
||||||
|
/// would train everyone to ignore the real one.
|
||||||
|
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// How often resident size goes into the log.
|
||||||
|
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// How long the liveness probe waits for the daemon to answer its own control
|
||||||
|
/// socket. Well under the watchdog period, so a probe that times out still
|
||||||
|
/// leaves time for the manager to hear nothing and act.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
|
||||||
|
pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
||||||
|
let watchdog = systemd::watchdog_period();
|
||||||
|
if let Some(period) = watchdog {
|
||||||
|
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_rss = Instant::now();
|
||||||
|
let mut last_ping = Instant::now();
|
||||||
|
// Per-lane completion counts from the previous tick, to tell a queue that is
|
||||||
|
// being worked through from one that is not moving at all.
|
||||||
|
let mut last_completed = (0_u64, 0_u64);
|
||||||
|
|
||||||
|
while core.shutdown.sleep(TICK) {
|
||||||
|
let report = core.diagnostics();
|
||||||
|
let stalled = report_stalls(&report, last_completed);
|
||||||
|
last_completed = (report.meta_completed, report.transfer_completed);
|
||||||
|
|
||||||
|
if last_rss.elapsed() >= RSS_EVERY {
|
||||||
|
info!(
|
||||||
|
rss_bytes = report.rss_bytes,
|
||||||
|
pending_ops = report.pending_ops,
|
||||||
|
meta_queued = report.meta_queued,
|
||||||
|
transfer_queued = report.transfer_queued,
|
||||||
|
stalled,
|
||||||
|
"daemon resource sample"
|
||||||
|
);
|
||||||
|
last_rss = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stalled lane is deliberately *not* a reason to withhold the ping:
|
||||||
|
// the mount may still be answering everything else, and a restart would
|
||||||
|
// throw away in-flight uploads. The probe decides that; a stall only
|
||||||
|
// gets said out loud.
|
||||||
|
if let Some(period) = watchdog
|
||||||
|
&& last_ping.elapsed() >= period
|
||||||
|
{
|
||||||
|
if probe(&control_socket) {
|
||||||
|
systemd::watchdog_ping();
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"daemon did not answer its own control socket; withholding the watchdog ping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last_ping = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
|
||||||
|
/// anything was reported, so the caller can weigh it.
|
||||||
|
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
|
||||||
|
let threshold = STALL_AFTER.as_secs();
|
||||||
|
let mut stalled = false;
|
||||||
|
|
||||||
|
for worker in &report.workers {
|
||||||
|
if worker.busy && worker.age_secs >= threshold {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
worker = %worker.name,
|
||||||
|
job = %worker.label,
|
||||||
|
age_secs = worker.age_secs,
|
||||||
|
"a fuse worker has held the same job for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(oldest) = report.inflight.first()
|
||||||
|
&& oldest.age_secs >= threshold
|
||||||
|
{
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
request = %oldest.kind,
|
||||||
|
age_secs = oldest.age_secs,
|
||||||
|
in_flight = report.inflight.len(),
|
||||||
|
"a control request has been running for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A backed-up lane is normal; a backed-up lane that finished nothing since
|
||||||
|
// the last tick is not.
|
||||||
|
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.meta_queued,
|
||||||
|
"the metadata lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.transfer_queued,
|
||||||
|
"the transfer lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stalled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round trip over the control socket: connect, ask for the cheapest
|
||||||
|
/// request there is, read the answer.
|
||||||
|
///
|
||||||
|
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
|
||||||
|
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
|
||||||
|
/// depends on — without touching the network or the database.
|
||||||
|
fn probe(control_socket: &Path) -> bool {
|
||||||
|
let Ok(stream) = UnixStream::connect(control_socket) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut stream = stream;
|
||||||
|
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut line = String::new();
|
||||||
|
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||||
|
WorkerState {
|
||||||
|
name: "pdfs-fuse-3".into(),
|
||||||
|
busy: true,
|
||||||
|
label: "read".into(),
|
||||||
|
age_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker doing normal work must not be reported: a WARN every tick is a
|
||||||
|
/// WARN nobody reads.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_work_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(5)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker on the same job past the threshold is the shape of the hang.
|
||||||
|
#[test]
|
||||||
|
fn a_long_held_job_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// So is a control request that never returned.
|
||||||
|
#[test]
|
||||||
|
fn a_long_running_request_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
inflight: vec![InflightRequest {
|
||||||
|
kind: "OpenFile".into(),
|
||||||
|
age_secs: STALL_AFTER.as_secs() + 1,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queue that is draining is busy, not stalled — that is what comparing
|
||||||
|
/// the completion counts is for.
|
||||||
|
#[test]
|
||||||
|
fn a_draining_queue_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
transfer_queued: 12,
|
||||||
|
transfer_completed: 40,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 30)));
|
||||||
|
assert!(report_stalls(&report, (0, 40)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,7 +261,7 @@ impl Core {
|
|||||||
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
/// is tracked in that fork's `State` — so consulting `self.state` alone
|
||||||
/// answered "idle" for every such file and let the sweep delete one with a
|
/// answered "idle" for every such file and let the sweep delete one with a
|
||||||
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
/// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy.
|
||||||
fn is_busy(&self, uid: &NodeUid) -> bool {
|
pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool {
|
||||||
let mut busy = false;
|
let mut busy = false;
|
||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
let Some(&ino) = st.by_uid.get(uid) else {
|
let Some(&ino) = st.by_uid.get(uid) else {
|
||||||
@@ -368,6 +368,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ impl Core {
|
|||||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||||
/// pass that may run for minutes.
|
/// pass that may run for minutes.
|
||||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||||
|
// A paused folder skips the settle too: a queued switch to on-demand
|
||||||
|
// evicts the local tree, which is only safe after a pass uploaded it.
|
||||||
|
if self.sync_folder_paused(folder.id) {
|
||||||
|
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||||
// so the settle below is not skipped with it.
|
// so the settle below is not skipped with it.
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(&core);
|
reconcile_all(&core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,9 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -230,8 +236,14 @@ pub(super) fn classify(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||||
|
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||||
|
/// watcher reported meanwhile.
|
||||||
fn reconcile_all(core: &Core) {
|
fn reconcile_all(core: &Core) {
|
||||||
|
if core.sync_paused() {
|
||||||
|
debug!("sync: paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match core.db.sync_folder_list() {
|
let folders = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! The `sd_notify` half of the service contract, without linking libsystemd.
|
||||||
|
//!
|
||||||
|
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
|
||||||
|
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
|
||||||
|
//! directly keeps the daemon buildable on a machine without systemd headers —
|
||||||
|
//! everything here degrades to "no socket, do nothing" off systemd.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixDatagram;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Send one notification. `false` when there is no manager to send it to, or the
|
||||||
|
/// send failed — a caller has nothing useful to do about either.
|
||||||
|
pub(crate) fn notify(message: &str) -> bool {
|
||||||
|
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if socket_path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(socket) = UnixDatagram::unbound() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(name) = socket_path.strip_prefix('@') {
|
||||||
|
// Abstract namespace: the address is a NUL byte followed by the name,
|
||||||
|
// which `std`'s path-based API cannot express.
|
||||||
|
send_abstract(&socket, name, message)
|
||||||
|
} else {
|
||||||
|
socket.send_to(message.as_bytes(), &socket_path).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sendto` on an abstract-namespace address, which needs a hand-built
|
||||||
|
/// `sockaddr_un` because the first byte of the name is NUL.
|
||||||
|
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||||
|
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
// One byte for the leading NUL, and the path must still be addressable.
|
||||||
|
if bytes.len() + 1 > addr.sun_path.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
|
||||||
|
*slot = *byte as libc::c_char;
|
||||||
|
}
|
||||||
|
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
|
||||||
|
let sent = unsafe {
|
||||||
|
libc::sendto(
|
||||||
|
socket.as_raw_fd(),
|
||||||
|
message.as_ptr().cast(),
|
||||||
|
message.len(),
|
||||||
|
0,
|
||||||
|
std::ptr::from_ref(&addr).cast(),
|
||||||
|
len as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sent >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is up and serving. Called once the mount is live,
|
||||||
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
|
/// when the process exists.
|
||||||
|
pub(crate) fn ready() {
|
||||||
|
if notify("READY=1\n") {
|
||||||
|
debug!("notified systemd: ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is still healthy.
|
||||||
|
pub(crate) fn watchdog_ping() {
|
||||||
|
let _ = notify("WATCHDOG=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
|
/// for a hang by the watchdog it is racing.
|
||||||
|
pub(crate) fn stopping() {
|
||||||
|
let _ = notify("STOPPING=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
|
||||||
|
/// the watchdog is off or belongs to another process.
|
||||||
|
///
|
||||||
|
/// Reports *half* the configured interval, the interval systemd documents for
|
||||||
|
/// pinging: a ping period equal to the deadline would restart the daemon on the
|
||||||
|
/// first late scheduling.
|
||||||
|
pub(crate) fn watchdog_period() -> Option<Duration> {
|
||||||
|
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
|
||||||
|
// in that case a ping from anyone else is not the one it is waiting for.
|
||||||
|
if let Ok(pid) = std::env::var("WATCHDOG_PID")
|
||||||
|
&& pid.parse::<u32>().ok() != Some(std::process::id())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
|
||||||
|
(usec > 0).then(|| Duration::from_micros(usec / 2))
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Importing a Google Photos Takeout export into Proton Photos.
|
||||||
|
//!
|
||||||
|
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
|
||||||
|
//! talks to Proton. One import runs on its own thread and moves through four
|
||||||
|
//! phases, reporting into the transfer registry as a job so the GUI's transfer
|
||||||
|
//! list shows it without a bespoke widget:
|
||||||
|
//!
|
||||||
|
//! 1. **Scan** — read the archives' central directories and sidecars.
|
||||||
|
//! 2. **Deduplicate against the account** — ask the photos volume which of the
|
||||||
|
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
|
||||||
|
//! those files, then confirm with the content digest. A library that is
|
||||||
|
//! already half-synced by Proton Photos on a phone therefore costs one cheap
|
||||||
|
//! request per 150 names and reads no media at all for the misses.
|
||||||
|
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
|
||||||
|
//! no extraction to disk, so the import needs no scratch space.
|
||||||
|
//! 4. **Albums** — create the albums the export names that the account lacks,
|
||||||
|
//! then add every photo of each album, the ones that were already on the
|
||||||
|
//! account included.
|
||||||
|
//!
|
||||||
|
//! Re-running an interrupted import is the resume story: phase 2 finds
|
||||||
|
//! everything the previous run uploaded and skips it. Nothing is persisted
|
||||||
|
//! between runs.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
|
///
|
||||||
|
/// Process-global rather than a [`Core`] field because it must be *one* import
|
||||||
|
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
|
||||||
|
/// hold one), and because a front-end asking for the result after the import
|
||||||
|
/// finished must still find it.
|
||||||
|
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ImportState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<ImportSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an import is running, and the last one's report.
|
||||||
|
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
|
||||||
|
let state = IMPORT.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running import to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_import() -> bool {
|
||||||
|
match &IMPORT.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to import, after the copies Google keeps per album are folded into
|
||||||
|
/// a single upload that joins several albums.
|
||||||
|
struct Planned {
|
||||||
|
photo: TakeoutPhoto,
|
||||||
|
/// Every album this photo belongs to, by export title.
|
||||||
|
albums: Vec<String>,
|
||||||
|
/// Filled in phase 2/3: where the photo ended up on the account.
|
||||||
|
uid: Option<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the scan into one entry per distinct photo.
|
||||||
|
///
|
||||||
|
/// Google stores a full copy of a photo in every album folder it appears in, so
|
||||||
|
/// the same file is in the export two or three times over. They are folded on
|
||||||
|
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
|
||||||
|
/// and the album memberships of the copies are unioned onto the survivor. Two
|
||||||
|
/// genuinely different photos sharing both a name and an exact size would be
|
||||||
|
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
|
||||||
|
/// filed under an extra album rather than lost data.
|
||||||
|
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
|
||||||
|
let mut order: Vec<(String, u64)> = Vec::new();
|
||||||
|
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
|
||||||
|
|
||||||
|
for photo in &scan.photos {
|
||||||
|
let key = (photo.name.clone(), photo.size);
|
||||||
|
let entry = by_key.entry(key.clone()).or_insert_with(|| {
|
||||||
|
order.push(key.clone());
|
||||||
|
Planned {
|
||||||
|
photo: photo.clone(),
|
||||||
|
albums: Vec::new(),
|
||||||
|
uid: None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Bucket::Album(title) = &photo.bucket
|
||||||
|
&& !entry.albums.iter().any(|existing| existing == title)
|
||||||
|
{
|
||||||
|
entry.albums.push(title.clone());
|
||||||
|
}
|
||||||
|
// A copy that carries a capture time wins over one that does not: the
|
||||||
|
// album copy and the year-folder copy do not always both have a sidecar.
|
||||||
|
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
|
||||||
|
entry.photo.capture_time = photo.capture_time;
|
||||||
|
}
|
||||||
|
entry.photo.favorite |= photo.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| by_key.remove(&key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one archive and stream `entry` through `sink`.
|
||||||
|
fn read_entry<R>(
|
||||||
|
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||||
|
entry: &str,
|
||||||
|
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
|
||||||
|
) -> CoreResult<R> {
|
||||||
|
let mut file = archive
|
||||||
|
.by_name(entry)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
|
||||||
|
Ok(sink(&mut file))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
|
/// the wire is allowed to finish, so a cancelled import never leaves a
|
||||||
|
/// half-written revision behind.
|
||||||
|
pub(crate) fn import_takeout(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<ImportSummary> {
|
||||||
|
let job = self.transfers.begin_job("Importing Google Photos");
|
||||||
|
job.detail("reading archives");
|
||||||
|
|
||||||
|
let scan = scan_archives(&archives)?;
|
||||||
|
let mut planned = plan(&scan);
|
||||||
|
let mut summary = ImportSummary {
|
||||||
|
found: planned.len(),
|
||||||
|
skipped_trashed: scan.skipped_trashed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
job.set_total(planned.len() as u64);
|
||||||
|
info!(
|
||||||
|
archives = archives.len(),
|
||||||
|
photos = summary.found,
|
||||||
|
albums = scan.albums.len(),
|
||||||
|
trashed = summary.skipped_trashed,
|
||||||
|
"takeout scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
let photos = self.photos();
|
||||||
|
if self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.get_photos_root())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: which of these are already on the account.
|
||||||
|
job.detail("checking for photos you already have");
|
||||||
|
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: upload what is left, one archive at a time so each zip's
|
||||||
|
// central directory is parsed once.
|
||||||
|
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
|
||||||
|
|
||||||
|
// Phase 4: albums. Attempted even after a cancel — the photos that did
|
||||||
|
// upload should still land in their albums.
|
||||||
|
if !scan.albums.is_empty() {
|
||||||
|
job.detail("sorting photos into albums");
|
||||||
|
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
|
||||||
|
warn!(error = %e, "takeout album linking failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in `uid` for every planned photo the account already holds.
|
||||||
|
///
|
||||||
|
/// Two passes on purpose: the name-only query is one request per 150 photos
|
||||||
|
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
|
||||||
|
/// handful of names that actually collide.
|
||||||
|
fn mark_existing(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
|
||||||
|
let collisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.name_collisions(&names))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
// Hash the colliding files, grouped by archive so each zip opens once.
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, planned) in planned.iter().enumerate() {
|
||||||
|
if collisions.get(index).copied().unwrap_or(false) {
|
||||||
|
by_archive
|
||||||
|
.entry(planned.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept as a pair list, not a map: the duplicate lookup answers in input
|
||||||
|
// order, so the order these were built in is what maps answers back to
|
||||||
|
// planned photos.
|
||||||
|
let mut digests: Vec<(usize, String)> = Vec::new();
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
for index in indices {
|
||||||
|
let entry = planned[index].photo.entry.clone();
|
||||||
|
match read_entry(&mut archive, &entry, sha1_of) {
|
||||||
|
Ok(Ok(digest)) => digests.push((index, digest)),
|
||||||
|
Ok(Err(e)) | Err(e) => {
|
||||||
|
warn!(entry, error = %e, "cannot hash takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digests.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<(String, String)> = digests
|
||||||
|
.iter()
|
||||||
|
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
|
||||||
|
.collect();
|
||||||
|
let matches = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.find_duplicates_many(&items))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
for (slot, (index, _)) in digests.iter().enumerate() {
|
||||||
|
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
|
||||||
|
planned[*index].uid = Some(uid.clone());
|
||||||
|
summary.duplicates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload every planned photo that is not already on the account.
|
||||||
|
fn upload_planned(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
job: &crate::transfers::JobGuard,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let mut favorites: Vec<NodeUid> = Vec::new();
|
||||||
|
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, item) in planned.iter().enumerate() {
|
||||||
|
if item.uid.is_none() {
|
||||||
|
by_archive
|
||||||
|
.entry(item.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
|
||||||
|
for index in indices {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let photo = planned[index].photo.clone();
|
||||||
|
job.detail(photo.name.clone());
|
||||||
|
|
||||||
|
let guard = self.transfers.begin(
|
||||||
|
photo.name.clone(),
|
||||||
|
"",
|
||||||
|
TransferDirection::Upload,
|
||||||
|
photo.size,
|
||||||
|
);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||||
|
let counting = CountingReader::new(reader, &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
drop(guard);
|
||||||
|
job.step();
|
||||||
|
|
||||||
|
match uploaded {
|
||||||
|
Ok(Ok(uid)) => {
|
||||||
|
summary.uploaded += 1;
|
||||||
|
summary.bytes += photo.size;
|
||||||
|
if photo.favorite {
|
||||||
|
favorites.push(uid.clone());
|
||||||
|
}
|
||||||
|
planned[index].uid = Some(uid);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "takeout photo upload failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "cannot read takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
|
// volume's dedicated favorite endpoint.
|
||||||
|
if !favorites.is_empty() {
|
||||||
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
|
.into_iter()
|
||||||
|
.map(|node_uid| PhotoTagsUpdate {
|
||||||
|
node_uid,
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the albums the export names that the account lacks, then add each
|
||||||
|
/// album's photos to it.
|
||||||
|
fn link_albums(
|
||||||
|
&self,
|
||||||
|
planned: &[Planned],
|
||||||
|
albums: &[String],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
|
||||||
|
// Existing albums by decrypted name, so a second import of the same
|
||||||
|
// export adds to the album it made the first time instead of a twin.
|
||||||
|
let mut existing: HashMap<String, NodeUid> = HashMap::new();
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_album_node_uids())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
|
||||||
|
if !uids.is_empty() {
|
||||||
|
let albums_nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
|
||||||
|
for node in albums_nodes {
|
||||||
|
existing.entry(node.name.clone()).or_insert(node.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for title in albums {
|
||||||
|
let members: Vec<NodeUid> = planned
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.albums.iter().any(|album| album == title))
|
||||||
|
.filter_map(|item| item.uid.clone())
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_uid = match existing.get(title) {
|
||||||
|
Some(uid) => uid.clone(),
|
||||||
|
None => match self.rt.block_on(photos.create_album(title)) {
|
||||||
|
Ok(uid) => {
|
||||||
|
summary.albums_created += 1;
|
||||||
|
existing.insert(title.clone(), uid.clone());
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, error = %e, "cannot create album");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, &members))
|
||||||
|
{
|
||||||
|
Ok(outcomes) => {
|
||||||
|
for (uid, outcome) in outcomes {
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => summary.album_links += 1,
|
||||||
|
// A photo already in the album is the expected
|
||||||
|
// outcome of a re-run, not a failure worth counting.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, %uid, error = %e, "cannot add photo to album")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(album = title, error = %e, "album addition failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an import on a background thread, logging its outcome to the activity
|
||||||
|
/// feed. Returns immediately.
|
||||||
|
///
|
||||||
|
/// Refuses while one is already running: two imports of the same export
|
||||||
|
/// would race each other's duplicate detection and upload everything twice.
|
||||||
|
pub(crate) fn spawn_takeout_import(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"an import is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.import_takeout(archives, dry_run, cancel);
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(summary) => {
|
||||||
|
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
parts.push(format!("{} already there", summary.duplicates));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
parts.push(format!("{} albums", summary.albums_created));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
parts.push(format!("{} failed", summary.failed));
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
parts.push("cancelled".to_string());
|
||||||
|
}
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"Google Photos import",
|
||||||
|
parts.join(" · "),
|
||||||
|
summary.failed == 0 && !summary.cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "takeout import failed");
|
||||||
|
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one of the import's archives by index.
|
||||||
|
fn open_archive(
|
||||||
|
archives: &[PathBuf],
|
||||||
|
index: usize,
|
||||||
|
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
|
||||||
|
let path = archives
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
|
/// photo duplicate detection is keyed on.
|
||||||
|
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(hex_lower(&hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::takeout::TakeoutPhoto;
|
||||||
|
|
||||||
|
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
|
||||||
|
TakeoutPhoto {
|
||||||
|
archive: 0,
|
||||||
|
entry: format!("Takeout/Google Photos/x/{name}"),
|
||||||
|
name: name.to_string(),
|
||||||
|
size,
|
||||||
|
media_type: "image/jpeg".to_string(),
|
||||||
|
capture_time: None,
|
||||||
|
favorite: false,
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_copies_of_one_photo_fold_into_a_single_upload() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
|
||||||
|
photo("IMG_2.jpg", 200, Bucket::Timeline),
|
||||||
|
],
|
||||||
|
albums: vec!["Iceland".into(), "Best of".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let planned = plan(&scan);
|
||||||
|
assert_eq!(planned.len(), 2, "the three copies are one upload");
|
||||||
|
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
|
||||||
|
assert!(planned[1].albums.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_name_different_size_stays_two_photos() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(plan(&scan).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_from_any_copy_survives_the_fold() {
|
||||||
|
// Only the album copy carries a sidecar; the fold must not lose it.
|
||||||
|
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
|
||||||
|
bare.capture_time = None;
|
||||||
|
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
|
||||||
|
rich.capture_time = Some(1_560_000_000);
|
||||||
|
rich.favorite = true;
|
||||||
|
|
||||||
|
let planned = plan(&TakeoutScan {
|
||||||
|
photos: vec![bare, rich],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
|
||||||
|
assert!(planned[0].photo.favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_matches_the_known_digest_of_abc() {
|
||||||
|
let mut reader: &[u8] = b"abc";
|
||||||
|
assert_eq!(
|
||||||
|
sha1_of(&mut reader).unwrap(),
|
||||||
|
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
//! always reflects exactly what is in flight — even if the work fails partway
|
//! always reflects exactly what is in flight — even if the work fails partway
|
||||||
//! and unwinds.
|
//! and unwinds.
|
||||||
//!
|
//!
|
||||||
|
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||||
|
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||||
|
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||||
|
//!
|
||||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
@@ -20,7 +24,7 @@ use std::collections::HashMap;
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
|
|
||||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
|||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
|
upload_limit: RateLimit,
|
||||||
|
download_limit: RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||||
|
/// small file still goes out at full speed.
|
||||||
|
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||||
|
/// cap.
|
||||||
|
///
|
||||||
|
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||||
|
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||||
|
/// transfers split the cap between them instead of each getting all of it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RateLimit {
|
||||||
|
rate: AtomicU64,
|
||||||
|
next_free: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
fn set(&self, rate: u64) {
|
||||||
|
self.rate.store(rate, Ordering::Relaxed);
|
||||||
|
// A new cap starts from now, not from the debt the old one ran up.
|
||||||
|
*self.next_free.lock() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||||
|
/// to stay under the cap.
|
||||||
|
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||||
|
let rate = self.rate.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 || n == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let mut next_free = self.next_free.lock();
|
||||||
|
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||||
|
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||||
|
*next_free = Some(done);
|
||||||
|
done.saturating_duration_since(now + RATE_BURST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||||
|
/// the sleep is announced to the runtime there rather than silently holding a
|
||||||
|
/// worker other tasks — FUSE reads among them — may need.
|
||||||
|
fn throttle(wait: Duration) {
|
||||||
|
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||||
|
match Handle::try_current() {
|
||||||
|
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||||
|
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||||
|
}
|
||||||
|
_ => std::thread::sleep(wait),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransferRegistry {
|
impl TransferRegistry {
|
||||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||||
|
/// Applies to transfers already running from their next block.
|
||||||
|
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||||
|
self.upload_limit.set(upload);
|
||||||
|
self.download_limit.set(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
|
match direction {
|
||||||
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
TransferDirection::Download => &self.download_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||||
pub fn begin(
|
pub fn begin(
|
||||||
@@ -169,9 +240,17 @@ pub struct TransferGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransferGuard {
|
impl TransferGuard {
|
||||||
/// Record `n` more bytes moved.
|
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||||
|
/// its cap.
|
||||||
pub fn add(&self, n: u64) {
|
pub fn add(&self, n: u64) {
|
||||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
let wait = self
|
||||||
|
.reg
|
||||||
|
.limit(self.entry.direction)
|
||||||
|
.charge(n, Instant::now());
|
||||||
|
if !wait.is_zero() {
|
||||||
|
throttle(wait);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,3 +400,37 @@ impl<R: Read> Read for CountingReader<'_, R> {
|
|||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_direction_never_waits() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_direction_waits_once_past_its_burst() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
// One second of bytes is the burst: no wait yet.
|
||||||
|
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||||
|
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||||
|
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||||
|
// Lifting the cap drops the debt with it.
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_transfers_share_one_cap() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+333
-33
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use parking_lot::{Condvar, Mutex};
|
use parking_lot::{Condvar, Mutex};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
@@ -51,8 +53,25 @@ pub(crate) const FUSE_WORKERS: usize = 11;
|
|||||||
/// exactly the blocking this split exists to prevent.
|
/// exactly the blocking this split exists to prevent.
|
||||||
const META_WORKERS: usize = 3;
|
const META_WORKERS: usize = 3;
|
||||||
|
|
||||||
|
/// Queue depth that gets a line in the log, and the step at which it repeats.
|
||||||
|
/// Every job holds a `Reply` and a few fields, so depth is cheap — but a lane
|
||||||
|
/// this far behind is worth knowing about before the user reports a slow mount.
|
||||||
|
const QUEUE_WARN_STEP: usize = 256;
|
||||||
|
|
||||||
|
/// Whether a queue reaching `depth` is one of the depths worth logging.
|
||||||
|
fn crosses_warn_step(depth: usize) -> bool {
|
||||||
|
depth >= QUEUE_WARN_STEP && depth.is_multiple_of(QUEUE_WARN_STEP)
|
||||||
|
}
|
||||||
|
|
||||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||||
|
|
||||||
|
/// A queued job and the name its caller gave it, so a job that never finishes
|
||||||
|
/// can be named in the diagnostics instead of showing up as "a worker is busy".
|
||||||
|
struct Queued {
|
||||||
|
label: &'static str,
|
||||||
|
job: Job,
|
||||||
|
}
|
||||||
|
|
||||||
/// Which lane a job belongs in.
|
/// Which lane a job belongs in.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum Lane {
|
pub(crate) enum Lane {
|
||||||
@@ -65,12 +84,63 @@ pub(crate) enum Lane {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Queues {
|
struct Queues {
|
||||||
meta: VecDeque<Job>,
|
meta: VecDeque<Queued>,
|
||||||
transfer: VecDeque<Job>,
|
transfer: VecDeque<Queued>,
|
||||||
/// Set when the pool is dropped, to wake and retire every worker.
|
/// Set when the pool is dropped, to wake and retire every worker.
|
||||||
closed: bool,
|
closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one worker thread is doing, published for [`Workers::snapshot`].
|
||||||
|
///
|
||||||
|
/// Every field is an atomic (or a lock the worker only ever holds for the length
|
||||||
|
/// of a pointer write) because the whole point of the snapshot is to answer
|
||||||
|
/// *while the daemon is wedged*: a diagnostic that waits on the same lock the
|
||||||
|
/// stuck job holds reports nothing at exactly the moment it is needed.
|
||||||
|
struct Slot {
|
||||||
|
name: String,
|
||||||
|
/// True between picking a job up and finishing it.
|
||||||
|
busy: AtomicBool,
|
||||||
|
/// When the current state (busy or idle) began, as milliseconds since the
|
||||||
|
/// pool started. Monotonic, so an age is always a real duration.
|
||||||
|
since_ms: AtomicU64,
|
||||||
|
/// The label of the job in hand. Behind a mutex because a `&'static str` is
|
||||||
|
/// two words and there is no atomic for that; the worker holds it only to
|
||||||
|
/// swap the pointer, and the snapshot never blocks on it (see
|
||||||
|
/// [`Workers::snapshot`]).
|
||||||
|
label: Mutex<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
fn new(name: String, now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
busy: AtomicBool::new(false),
|
||||||
|
since_ms: AtomicU64::new(now_ms),
|
||||||
|
label: Mutex::new(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One worker's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct WorkerSnapshot {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) busy: bool,
|
||||||
|
/// How long the worker has been in that state.
|
||||||
|
pub(crate) age_ms: u64,
|
||||||
|
/// The job in hand, or `""` when idle. `"?"` when the label could not be
|
||||||
|
/// read without blocking.
|
||||||
|
pub(crate) label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pool's state at the moment [`Workers::snapshot`] read it.
|
||||||
|
pub(crate) struct PoolSnapshot {
|
||||||
|
pub(crate) workers: Vec<WorkerSnapshot>,
|
||||||
|
pub(crate) meta_queued: usize,
|
||||||
|
pub(crate) transfer_queued: usize,
|
||||||
|
pub(crate) meta_completed: u64,
|
||||||
|
pub(crate) transfer_completed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// The queues and the two wait sets over them.
|
/// The queues and the two wait sets over them.
|
||||||
///
|
///
|
||||||
/// Two condvars, not one, because the workers are not interchangeable: a
|
/// Two condvars, not one, because the workers are not interchangeable: a
|
||||||
@@ -86,6 +156,22 @@ struct Pool {
|
|||||||
meta_cv: Condvar,
|
meta_cv: Condvar,
|
||||||
/// Waited on by the general workers.
|
/// Waited on by the general workers.
|
||||||
general_cv: Condvar,
|
general_cv: Condvar,
|
||||||
|
/// Queue depths, mirrored outside the lock so a snapshot can read them
|
||||||
|
/// without waiting on whatever is holding it.
|
||||||
|
meta_queued: AtomicUsize,
|
||||||
|
transfer_queued: AtomicUsize,
|
||||||
|
/// Jobs finished per lane. A depth that stays high while this stays still is
|
||||||
|
/// a stalled lane rather than a busy one.
|
||||||
|
meta_completed: AtomicU64,
|
||||||
|
transfer_completed: AtomicU64,
|
||||||
|
slots: Vec<Slot>,
|
||||||
|
started: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pool {
|
||||||
|
fn now_ms(&self) -> u64 {
|
||||||
|
self.started.elapsed().as_millis() as u64
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bounded thread pool behind the network-touching FUSE handlers.
|
/// Bounded thread pool behind the network-touching FUSE handlers.
|
||||||
@@ -94,25 +180,46 @@ struct Pool {
|
|||||||
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
/// on-demand sync folder), so the bound is per daemon rather than per mount.
|
||||||
pub(crate) struct Workers {
|
pub(crate) struct Workers {
|
||||||
inner: Arc<Pool>,
|
inner: Arc<Pool>,
|
||||||
|
/// Kept so teardown can *join* the workers, not merely flag them. A worker
|
||||||
|
/// still inside a job when the process drops its tokio runtime panics with
|
||||||
|
/// "A Tokio 1.x context was found, but it is being shutdown" (seen
|
||||||
|
/// 2026-09-18 09:58:35), which is a teardown ordering bug rather than a real
|
||||||
|
/// failure.
|
||||||
|
handles: Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Workers {
|
impl Workers {
|
||||||
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
pub(crate) fn new(n: usize) -> std::io::Result<Self> {
|
||||||
|
// Never leave the pool without a general worker, however small `n` is.
|
||||||
|
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
||||||
|
let names: Vec<String> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
format!(
|
||||||
|
"pdfs-fuse-{}{i}",
|
||||||
|
if i < meta_workers { "meta-" } else { "" }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let inner = Arc::new(Pool {
|
let inner = Arc::new(Pool {
|
||||||
queues: Mutex::new(Queues::default()),
|
queues: Mutex::new(Queues::default()),
|
||||||
meta_cv: Condvar::new(),
|
meta_cv: Condvar::new(),
|
||||||
general_cv: Condvar::new(),
|
general_cv: Condvar::new(),
|
||||||
|
meta_queued: AtomicUsize::new(0),
|
||||||
|
transfer_queued: AtomicUsize::new(0),
|
||||||
|
meta_completed: AtomicU64::new(0),
|
||||||
|
transfer_completed: AtomicU64::new(0),
|
||||||
|
slots: names
|
||||||
|
.iter()
|
||||||
|
.map(|name| Slot::new(name.clone(), 0))
|
||||||
|
.collect(),
|
||||||
|
started: Instant::now(),
|
||||||
});
|
});
|
||||||
// Never leave the pool without a general worker, however small `n` is.
|
let mut handles = Vec::with_capacity(n);
|
||||||
let meta_workers = META_WORKERS.min(n.saturating_sub(1));
|
for (i, name) in names.iter().enumerate() {
|
||||||
for i in 0..n {
|
|
||||||
let inner = inner.clone();
|
let inner = inner.clone();
|
||||||
let meta_only = i < meta_workers;
|
let meta_only = i < meta_workers;
|
||||||
std::thread::Builder::new()
|
let handle = std::thread::Builder::new()
|
||||||
.name(format!(
|
.name(name.clone())
|
||||||
"pdfs-fuse-{}{i}",
|
|
||||||
if meta_only { "meta-" } else { "" }
|
|
||||||
))
|
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let cv = if meta_only {
|
let cv = if meta_only {
|
||||||
&inner.meta_cv
|
&inner.meta_cv
|
||||||
@@ -121,17 +228,20 @@ impl Workers {
|
|||||||
};
|
};
|
||||||
loop {
|
loop {
|
||||||
let mut q = inner.queues.lock();
|
let mut q = inner.queues.lock();
|
||||||
let job = loop {
|
let picked = loop {
|
||||||
// A general worker takes transfers first: metadata
|
// A general worker takes transfers first: metadata
|
||||||
// already has threads of its own, so draining the
|
// already has threads of its own, so draining the
|
||||||
// bulk queue is the useful thing for it to do.
|
// bulk queue is the useful thing for it to do.
|
||||||
let picked = if meta_only {
|
let taken = if meta_only {
|
||||||
q.meta.pop_front()
|
q.meta.pop_front().map(|job| (Lane::Meta, job))
|
||||||
} else {
|
} else {
|
||||||
q.transfer.pop_front().or_else(|| q.meta.pop_front())
|
q.transfer
|
||||||
|
.pop_front()
|
||||||
|
.map(|job| (Lane::Transfer, job))
|
||||||
|
.or_else(|| q.meta.pop_front().map(|job| (Lane::Meta, job)))
|
||||||
};
|
};
|
||||||
if let Some(job) = picked {
|
if let Some(taken) = taken {
|
||||||
break Some(job);
|
break Some(taken);
|
||||||
}
|
}
|
||||||
if q.closed {
|
if q.closed {
|
||||||
break None;
|
break None;
|
||||||
@@ -139,7 +249,15 @@ impl Workers {
|
|||||||
cv.wait(&mut q);
|
cv.wait(&mut q);
|
||||||
};
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
let Some(job) = job else { break };
|
let Some((lane, queued)) = picked else { break };
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => inner.transfer_queued.fetch_sub(1, Ordering::Relaxed),
|
||||||
|
};
|
||||||
|
let slot = &inner.slots[i];
|
||||||
|
*slot.label.lock() = queued.label;
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
slot.busy.store(true, Ordering::Relaxed);
|
||||||
// A panicking handler must not cost the pool a worker
|
// A panicking handler must not cost the pool a worker
|
||||||
// for the rest of the run. The dropped `Reply` answers
|
// for the rest of the run. The dropped `Reply` answers
|
||||||
// EIO on its own, so the caller of the failed op is
|
// EIO on its own, so the caller of the failed op is
|
||||||
@@ -164,11 +282,70 @@ impl Workers {
|
|||||||
// poisoning at all. Anything new reached from a job owes
|
// poisoning at all. Anything new reached from a job owes
|
||||||
// the same check — an SDK-side `.lock().unwrap()`
|
// the same check — an SDK-side `.lock().unwrap()`
|
||||||
// silently invalidates this comment.
|
// silently invalidates this comment.
|
||||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job));
|
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(queued.job));
|
||||||
|
slot.busy.store(false, Ordering::Relaxed);
|
||||||
|
slot.since_ms.store(inner.now_ms(), Ordering::Relaxed);
|
||||||
|
*slot.label.lock() = "";
|
||||||
|
match lane {
|
||||||
|
Lane::Meta => inner.meta_completed.fetch_add(1, Ordering::Relaxed),
|
||||||
|
Lane::Transfer => {
|
||||||
|
inner.transfer_completed.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
handles.push(handle);
|
||||||
}
|
}
|
||||||
Ok(Self { inner })
|
Ok(Self {
|
||||||
|
inner,
|
||||||
|
handles: Mutex::new(handles),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting work, wake every worker, and wait up to `deadline` for the
|
||||||
|
/// jobs in flight to finish.
|
||||||
|
///
|
||||||
|
/// Bounded because a join is only worth what it costs: a worker wedged on a
|
||||||
|
/// network call would otherwise hold the whole teardown, and the user asked
|
||||||
|
/// for the daemon to stop. What is stuck gets named in the log, and the
|
||||||
|
/// process continues shutting down around it.
|
||||||
|
pub(crate) fn stop_and_join(&self, deadline: std::time::Duration) {
|
||||||
|
self.close();
|
||||||
|
let handles: Vec<_> = std::mem::take(&mut *self.handles.lock());
|
||||||
|
if handles.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let joiner = std::thread::Builder::new()
|
||||||
|
.name("pdfs-fuse-join".into())
|
||||||
|
.spawn(move || {
|
||||||
|
for handle in handles {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
let _ = tx.send(());
|
||||||
|
});
|
||||||
|
if joiner.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if rx.recv_timeout(deadline).is_err() {
|
||||||
|
let busy: Vec<String> = self
|
||||||
|
.snapshot()
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|worker| worker.busy)
|
||||||
|
.map(|worker| format!("{}={}", worker.name, worker.label))
|
||||||
|
.collect();
|
||||||
|
warn!(
|
||||||
|
stuck = busy.join(","),
|
||||||
|
"fuse workers did not finish in time; shutting down around them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&self) {
|
||||||
|
self.inner.queues.lock().closed = true;
|
||||||
|
self.inner.meta_cv.notify_all();
|
||||||
|
self.inner.general_cv.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue `job` in `lane`.
|
/// Queue `job` in `lane`.
|
||||||
@@ -179,7 +356,15 @@ impl Workers {
|
|||||||
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
/// a pending job holds a `Reply` and a few fields, while the 4 MiB block
|
||||||
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
/// buffer is allocated inside the job once it runs, and the SDK's in-flight
|
||||||
/// semaphore is what bounds how many of those exist at once.
|
/// semaphore is what bounds how many of those exist at once.
|
||||||
pub(crate) fn run(&self, lane: Lane, job: impl FnOnce() + Send + 'static) {
|
/// The queue is *not* bounded, and the depth warning above is deliberately
|
||||||
|
/// all this does about it: the only caller able to feel backpressure here is
|
||||||
|
/// fuser's dispatch loop, and blocking that is the stall the whole module
|
||||||
|
/// exists to avoid. A depth that keeps climbing is a symptom to read in the
|
||||||
|
/// log, not something to fix by stopping the mount.
|
||||||
|
///
|
||||||
|
/// `label` names the work for the diagnostics (`pdfs diagnostics`) and for
|
||||||
|
/// the stall warnings: it is what a hung daemon reports instead of "busy".
|
||||||
|
pub(crate) fn run(&self, lane: Lane, label: &'static str, job: impl FnOnce() + Send + 'static) {
|
||||||
let mut q = self.inner.queues.lock();
|
let mut q = self.inner.queues.lock();
|
||||||
if q.closed {
|
if q.closed {
|
||||||
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
// Pre-pool behaviour: a shut-down pool degrades to a slow mount
|
||||||
@@ -189,11 +374,31 @@ impl Workers {
|
|||||||
job();
|
job();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match lane {
|
let queued = Queued {
|
||||||
Lane::Meta => q.meta.push_back(Box::new(job)),
|
label,
|
||||||
Lane::Transfer => q.transfer.push_back(Box::new(job)),
|
job: Box::new(job),
|
||||||
}
|
};
|
||||||
|
let depth = match lane {
|
||||||
|
Lane::Meta => {
|
||||||
|
q.meta.push_back(queued);
|
||||||
|
self.inner.meta_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
Lane::Transfer => {
|
||||||
|
q.transfer.push_back(queued);
|
||||||
|
self.inner.transfer_queued.fetch_add(1, Ordering::Relaxed) + 1
|
||||||
|
}
|
||||||
|
};
|
||||||
drop(q);
|
drop(q);
|
||||||
|
if crosses_warn_step(depth) {
|
||||||
|
warn!(
|
||||||
|
lane = if matches!(lane, Lane::Meta) {
|
||||||
|
"meta"
|
||||||
|
} else {
|
||||||
|
"transfer"
|
||||||
|
},
|
||||||
|
depth, label, "fuse worker queue is deep"
|
||||||
|
);
|
||||||
|
}
|
||||||
match lane {
|
match lane {
|
||||||
// Either class can serve metadata, and only one of them needs to:
|
// Either class can serve metadata, and only one of them needs to:
|
||||||
// whichever wakes first takes it, and the other finds the queue
|
// whichever wakes first takes it, and the other finds the queue
|
||||||
@@ -209,13 +414,50 @@ impl Workers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What every worker is doing right now, without taking the queue lock.
|
||||||
|
///
|
||||||
|
/// A diagnostic is worth having exactly when the daemon is stuck, so this
|
||||||
|
/// reads atomics only, and `try_lock`s each label rather than waiting for
|
||||||
|
/// it: a label is held for one pointer write, so failing to get it means a
|
||||||
|
/// racing worker, not a wedged one, and `"?"` is a better answer than a
|
||||||
|
/// diagnostics request that hangs too.
|
||||||
|
pub(crate) fn snapshot(&self) -> PoolSnapshot {
|
||||||
|
let now = self.inner.now_ms();
|
||||||
|
let workers = self
|
||||||
|
.inner
|
||||||
|
.slots
|
||||||
|
.iter()
|
||||||
|
.map(|slot| {
|
||||||
|
let label = slot
|
||||||
|
.label
|
||||||
|
.try_lock()
|
||||||
|
.map_or_else(|| "?".to_string(), |label| (*label).to_string());
|
||||||
|
WorkerSnapshot {
|
||||||
|
name: slot.name.clone(),
|
||||||
|
busy: slot.busy.load(Ordering::Relaxed),
|
||||||
|
age_ms: now.saturating_sub(slot.since_ms.load(Ordering::Relaxed)),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
PoolSnapshot {
|
||||||
|
workers,
|
||||||
|
meta_queued: self.inner.meta_queued.load(Ordering::Relaxed),
|
||||||
|
transfer_queued: self.inner.transfer_queued.load(Ordering::Relaxed),
|
||||||
|
meta_completed: self.inner.meta_completed.load(Ordering::Relaxed),
|
||||||
|
transfer_completed: self.inner.transfer_completed.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Workers {
|
impl Drop for Workers {
|
||||||
|
/// A backstop for the paths that never call
|
||||||
|
/// [`stop_and_join`](Workers::stop_and_join) — tests, and a mount that fails
|
||||||
|
/// before teardown is wired up. Flag and wake only: a `Drop` that blocks on
|
||||||
|
/// a network call is worse than a worker outliving its pool by a moment.
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.inner.queues.lock().closed = true;
|
self.close();
|
||||||
self.inner.meta_cv.notify_all();
|
|
||||||
self.inner.general_cv.notify_all();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +467,64 @@ mod tests {
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// The depth warning fires on the step, and only on the step: a lane that
|
||||||
|
/// oscillates around the threshold must not log on every single job.
|
||||||
|
#[test]
|
||||||
|
fn queue_depth_warns_on_each_step() {
|
||||||
|
assert!(!crosses_warn_step(1));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP - 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP));
|
||||||
|
assert!(!crosses_warn_step(QUEUE_WARN_STEP + 1));
|
||||||
|
assert!(crosses_warn_step(QUEUE_WARN_STEP * 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown must return even while a job is running, and must not leave the
|
||||||
|
/// pool accepting work.
|
||||||
|
#[test]
|
||||||
|
fn stopping_is_bounded_and_closes_the_pool() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5)).expect("it runs");
|
||||||
|
|
||||||
|
pool.stop_and_join(Duration::from_secs(5));
|
||||||
|
|
||||||
|
// A job queued after the stop runs inline rather than disappearing.
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("a closed pool serves inline");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A diagnostic is only useful if it names the work. A running job must show
|
||||||
|
/// up under its label, and the queue behind it must show up as depth.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_names_the_running_job_and_the_queue_behind_it() {
|
||||||
|
let pool = Workers::new(2).unwrap();
|
||||||
|
let (release_tx, release_rx) = mpsc::channel::<()>();
|
||||||
|
let release_rx = Arc::new(Mutex::new(release_rx));
|
||||||
|
let (started_tx, started_rx) = mpsc::channel();
|
||||||
|
{
|
||||||
|
let release_rx = release_rx.clone();
|
||||||
|
pool.run(Lane::Transfer, "read", move || {
|
||||||
|
started_tx.send(()).unwrap();
|
||||||
|
let _ = release_rx.lock().recv();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
started_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.expect("the job starts");
|
||||||
|
|
||||||
|
let snapshot = pool.snapshot();
|
||||||
|
assert!(
|
||||||
|
snapshot.workers.iter().any(|w| w.busy && w.label == "read"),
|
||||||
|
"the running job is named"
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.transfer_completed, 0);
|
||||||
|
|
||||||
|
let _ = release_tx.send(());
|
||||||
|
}
|
||||||
|
|
||||||
/// Audit A6. Transfers filling every general worker must not delay a
|
/// Audit A6. Transfers filling every general worker must not delay a
|
||||||
/// metadata job: that is what the reserved threads are for.
|
/// metadata job: that is what the reserved threads are for.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -240,7 +540,7 @@ mod tests {
|
|||||||
for _ in 0..general {
|
for _ in 0..general {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
let started_tx = started_tx.clone();
|
let started_tx = started_tx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
started_tx.send(()).unwrap();
|
started_tx.send(()).unwrap();
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
@@ -255,13 +555,13 @@ mod tests {
|
|||||||
// in the way that used to starve metadata.
|
// in the way that used to starve metadata.
|
||||||
for _ in 0..16 {
|
for _ in 0..16 {
|
||||||
let release_rx = release_rx.clone();
|
let release_rx = release_rx.clone();
|
||||||
pool.run(Lane::Transfer, move || {
|
pool.run(Lane::Transfer, "test", move || {
|
||||||
let _ = release_rx.lock().recv();
|
let _ = release_rx.lock().recv();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let (meta_tx, meta_rx) = mpsc::channel();
|
let (meta_tx, meta_rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || meta_tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || meta_tx.send(()).unwrap());
|
||||||
meta_rx
|
meta_rx
|
||||||
.recv_timeout(Duration::from_secs(5))
|
.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a metadata job runs while every transfer thread is stuck");
|
.expect("a metadata job runs while every transfer thread is stuck");
|
||||||
@@ -279,7 +579,7 @@ mod tests {
|
|||||||
// must have been served by the general worker.
|
// must have been served by the general worker.
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("a general worker takes metadata when no transfer is waiting");
|
.expect("a general worker takes metadata when no transfer is waiting");
|
||||||
}
|
}
|
||||||
@@ -288,9 +588,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_panicking_job_does_not_retire_its_worker() {
|
fn a_panicking_job_does_not_retire_its_worker() {
|
||||||
let pool = Workers::new(1).unwrap();
|
let pool = Workers::new(1).unwrap();
|
||||||
pool.run(Lane::Meta, || panic!("boom"));
|
pool.run(Lane::Meta, "test", || panic!("boom"));
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
pool.run(Lane::Meta, move || tx.send(()).unwrap());
|
pool.run(Lane::Meta, "test", move || tx.send(()).unwrap());
|
||||||
rx.recv_timeout(Duration::from_secs(5))
|
rx.recv_timeout(Duration::from_secs(5))
|
||||||
.expect("the pool still serves work after a panic");
|
.expect("the pool still serves work after a panic");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
description = "GTK4 desktop app, tray, and search launcher for the Proton Drive Linux client"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "pdfs-tray"
|
name = "pdfs-tray"
|
||||||
@@ -24,10 +27,11 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
ksni = "0.2"
|
ksni = { version = "0.3", features = ["blocking"] }
|
||||||
gtk4 = { version = "0.9", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
adw = { package = "libadwaita", version = "0.7", features = ["v1_5"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
|
||||||
async-channel = "2"
|
async-channel = "2"
|
||||||
exif = { version = "0.6", package = "kamadak-exif" }
|
exif = { version = "0.6", package = "kamadak-exif" }
|
||||||
webkit6 = "0.4"
|
webkit6 = "0.6"
|
||||||
@@ -9,6 +9,8 @@ fn main() {
|
|||||||
&format!("{}/pdfs.gresource", out_dir),
|
&format!("{}/pdfs.gresource", out_dir),
|
||||||
"--sourcedir",
|
"--sourcedir",
|
||||||
"resources/icons",
|
"resources/icons",
|
||||||
|
"--sourcedir",
|
||||||
|
"resources",
|
||||||
"resources/pdfs.gresource.xml",
|
"resources/pdfs.gresource.xml",
|
||||||
])
|
])
|
||||||
.status()
|
.status()
|
||||||
@@ -22,7 +24,6 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
println!("cargo:rerun-if-changed=resources/style.css");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Generate-thumbnails action: a picture frame with a sparkle. A single
|
||||||
|
symbolic fill lets GTK recolour it with the surrounding control. -->
|
||||||
|
<path fill="#222222" d="M 2,3 C 1.45,3 1,3.45 1,4 V 14 C 1,14.55 1.45,15 2,15 H 14 C 14.55,15 15,14.55 15,14 V 7 H 13 V 13 H 3 V 5 H 9 V 3 Z M 5,6 C 4.45,6 4,6.45 4,7 C 4,7.55 4.45,8 5,8 C 5.55,8 6,7.55 6,7 C 6,6.45 5.55,6 5,6 Z M 10,7 L 7.5,10 L 6,9 L 4,11.5 V 12 H 12 V 9 Z M 13,0 L 12.4,2.4 L 10,3 L 12.4,3.6 L 13,6 L 13.6,3.6 L 16,3 L 13.6,2.4 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 598 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||||
|
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||||
|
Replaces the star, which every desktop uses for "favourite". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||||
|
folder full of cloud-only files does not shout. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Upload-files action: an up arrow over a tray, the pair to
|
||||||
|
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
|
||||||
|
which reads as "send by mail". -->
|
||||||
|
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -4,6 +4,16 @@
|
|||||||
icon-theme resource path at startup, so they resolve by name (and recolour
|
icon-theme resource path at startup, so they resolve by name (and recolour
|
||||||
with the theme) exactly like a stock symbolic icon. -->
|
with the theme) exactly like a stock symbolic icon. -->
|
||||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-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>
|
</gresource>
|
||||||
</gresources>
|
</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); }
|
||||||
+942
-229
File diff suppressed because it is too large.
Load diff
@@ -1,48 +1,132 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct ActivityState {
|
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) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) attention: adw::PreferencesGroup,
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
pub(crate) days: gtk4::Box,
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) inflight: Cell<bool>,
|
||||||
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
|
/// Fingerprint of what is on screen (see [`activity_key`]). The page polls
|
||||||
/// page polls every couple of seconds and usually gets back exactly what it
|
/// every couple of seconds and usually gets back exactly what it is already
|
||||||
/// is already showing; rebuilding all 200 rows for that would throw away the
|
/// showing; rebuilding every row for that would throw away the user's
|
||||||
/// user's scroll position several times a minute.
|
/// scroll position several times a minute.
|
||||||
pub(crate) key: RefCell<Option<String>>,
|
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.
|
/// Widgets the Activity page's load/repaint touch.
|
||||||
pub(crate) struct ActivityWidgets {
|
pub(crate) struct ActivityWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
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) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
/// Which slice of the feed the filter bar shows.
|
||||||
/// daemon performed this session (uploads, deletes, shares, …).
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
pub(crate) enum ActivityFilter {
|
||||||
let title = gtk4::Label::builder()
|
All,
|
||||||
.label("Activity")
|
Transfers,
|
||||||
.halign(gtk4::Align::Start)
|
Changes,
|
||||||
.hexpand(true)
|
Sharing,
|
||||||
.build();
|
Problems,
|
||||||
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();
|
impl ActivityFilter {
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
const ALL: [ActivityFilter; 5] = [
|
||||||
|
ActivityFilter::All,
|
||||||
|
ActivityFilter::Transfers,
|
||||||
|
ActivityFilter::Changes,
|
||||||
|
ActivityFilter::Sharing,
|
||||||
|
ActivityFilter::Problems,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ActivityFilter::All => "All",
|
||||||
|
ActivityFilter::Transfers => "Transfers",
|
||||||
|
ActivityFilter::Changes => "Changes",
|
||||||
|
ActivityFilter::Sharing => "Sharing",
|
||||||
|
ActivityFilter::Problems => "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("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()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.child(&clamp)
|
.child(&clamp)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -66,20 +150,17 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
|||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&scroll, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let (frame, header, _) = page_frame("Activity", &content);
|
||||||
inner.set_margin_top(12);
|
header.pack_end(&refresh);
|
||||||
inner.set_margin_bottom(12);
|
|
||||||
inner.set_margin_start(12);
|
|
||||||
inner.set_margin_end(12);
|
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
ActivityWidgets {
|
ActivityWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
group,
|
attention,
|
||||||
|
days,
|
||||||
|
filters,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
refresh,
|
||||||
},
|
},
|
||||||
@@ -87,12 +168,26 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Activity page's retry button.
|
/// 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();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_activity(&ui_retry);
|
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.
|
/// 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
|
/// 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.
|
/// other than a good answer leaves the rows alone until the next tick.
|
||||||
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||||
|
refresh_activity_conflicts(ui, false);
|
||||||
if ui.activity.inflight.get() {
|
if ui.activity.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -141,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||||
|
refresh_activity_conflicts(ui, true);
|
||||||
if ui.activity.inflight.get() {
|
if ui.activity.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -210,28 +307,143 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
|
/// Poll the conflicts still waiting for a decision while the Activity page is
|
||||||
/// The log is append-only and newest-first, so a feed with the same length and
|
/// on screen. `force` skips the TTL, for navigation and right after a
|
||||||
/// the same newest entry is the same feed.
|
/// resolution. Listing walks every node the daemon knows, so the tick asks far
|
||||||
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
|
/// less often than it does for the feed.
|
||||||
match items.first() {
|
pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
let state = &ui.activity;
|
||||||
None => String::new(),
|
if state.conflicts_inflight.get()
|
||||||
}
|
|| (!force
|
||||||
}
|
&& state
|
||||||
|
.conflicts_at
|
||||||
/// Rebuild the Activity section from a fresh log, unless it is already showing
|
.get()
|
||||||
/// exactly this feed.
|
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||||
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()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*ui.activity.key.borrow_mut() = Some(key);
|
state.conflicts_inflight.set(true);
|
||||||
for row in ui.activity.rows.borrow_mut().drain(..) {
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts);
|
||||||
ui.activity.group.remove(&row);
|
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 "Today".to_string();
|
||||||
|
}
|
||||||
|
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||||
|
return "Yesterday".to_string();
|
||||||
|
}
|
||||||
|
let format = if now.difference(at).as_seconds() < 6 * 86_400 {
|
||||||
|
"%A"
|
||||||
|
} else if at.year() == now.year() {
|
||||||
|
"%A, %B %-d"
|
||||||
|
} else {
|
||||||
|
"%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(
|
activity_status(
|
||||||
ui,
|
ui,
|
||||||
"document-open-recent-symbolic",
|
"document-open-recent-symbolic",
|
||||||
@@ -241,28 +453,149 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.activity.content.set_visible_child_name("list");
|
*state.key.borrow_mut() = Some(key);
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
state.content.set_visible_child_name("list");
|
||||||
for a in items {
|
|
||||||
let mut subtitle = activity_time(a.time);
|
// Needs attention: the live conflicts, each opening the resolve dialog.
|
||||||
if !a.detail.is_empty() {
|
for row in state.attention_rows.borrow_mut().drain(..) {
|
||||||
subtitle = format!("{subtitle} · {}", a.detail);
|
state.attention.remove(&row);
|
||||||
}
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(format!("{} {}", activity_verb(a.kind), a.target))
|
|
||||||
.subtitle(subtitle)
|
|
||||||
.build();
|
|
||||||
let icon = if a.ok {
|
|
||||||
activity_icon(a.kind)
|
|
||||||
} else {
|
|
||||||
"dialog-warning-symbolic"
|
|
||||||
};
|
|
||||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
|
||||||
row.set_activatable(false);
|
|
||||||
ui.activity.group.add(&row);
|
|
||||||
rows.push(row.upcast());
|
|
||||||
}
|
}
|
||||||
*ui.activity.rows.borrow_mut() = rows;
|
state.attention.set_visible(!open.is_empty());
|
||||||
|
state.attention.set_description(Some(&match open.len() {
|
||||||
|
1 => "A file was changed in two places at once. Choose which version to keep.".to_string(),
|
||||||
|
n => {
|
||||||
|
format!("{n} files were changed in two places at once. Choose which versions to keep.")
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
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("No matching activity")
|
||||||
|
.description("Nothing in the recent log fits this filter.")
|
||||||
|
.build();
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 = match a.kind {
|
||||||
|
// The target of an empty is a count, not a name to lead with a verb.
|
||||||
|
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
|
||||||
|
kind => format!("{} {}", activity_verb(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 {
|
||||||
|
subtitle.push(format!(
|
||||||
|
"{} times since {}",
|
||||||
|
row.count,
|
||||||
|
activity_time(row.first)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
subtitle.push("Resolved".to_string());
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
let time = gtk4::Label::new(Some(
|
||||||
|
&at.and_then(|at| at.format("%H:%M").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("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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A human verb for an activity kind, used as the row title's lead word.
|
/// A human verb for an activity kind, used as the row title's lead word.
|
||||||
@@ -277,9 +610,9 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Trash => "Trashed",
|
ActivityKind::Trash => "Trashed",
|
||||||
ActivityKind::Restore => "Restored",
|
ActivityKind::Restore => "Restored",
|
||||||
ActivityKind::DeleteForever => "Deleted",
|
ActivityKind::DeleteForever => "Deleted",
|
||||||
ActivityKind::EmptyTrash => "Emptied trash —",
|
ActivityKind::EmptyTrash => "Emptied Trash",
|
||||||
ActivityKind::Share => "Shared",
|
ActivityKind::Share => "Shared",
|
||||||
ActivityKind::PublicLink => "Linked",
|
ActivityKind::PublicLink => "Created a link to",
|
||||||
ActivityKind::Unshare => "Unshared",
|
ActivityKind::Unshare => "Unshared",
|
||||||
ActivityKind::Conflict => "Conflict",
|
ActivityKind::Conflict => "Conflict",
|
||||||
}
|
}
|
||||||
@@ -288,15 +621,14 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
/// A themed icon for an activity kind.
|
/// A themed icon for an activity kind.
|
||||||
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
ActivityKind::Upload => "document-send-symbolic",
|
ActivityKind::Upload => "pdfs-upload-symbolic",
|
||||||
ActivityKind::Download => "document-save-symbolic",
|
ActivityKind::Download => "document-save-symbolic",
|
||||||
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
||||||
ActivityKind::Rename => "document-edit-symbolic",
|
ActivityKind::Rename => "document-edit-symbolic",
|
||||||
ActivityKind::Move => "go-jump-symbolic",
|
ActivityKind::Move => "go-jump-symbolic",
|
||||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||||
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
|
ActivityKind::Trash => "user-trash-symbolic",
|
||||||
"user-trash-symbolic"
|
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||||
}
|
|
||||||
ActivityKind::Restore => "edit-undo-symbolic",
|
ActivityKind::Restore => "edit-undo-symbolic",
|
||||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||||
@@ -311,3 +643,99 @@ pub(crate) fn activity_time(secs: i64) -> String {
|
|||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_default()
|
.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.
|
/// Show the Albums grid and (re)load it. Called when the Albums toggle goes on.
|
||||||
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
pub(crate) fn show_albums(ui: &Rc<Ui>) {
|
||||||
close_album(ui);
|
show_album_grid(ui);
|
||||||
ui.gallery.content.set_visible_child_name("albums");
|
|
||||||
load_albums(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.
|
/// Ask the daemon for the album listing and rebuild the grid.
|
||||||
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
||||||
if ui.gallery.albums_loading.get() {
|
if ui.gallery.albums_loading.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.albums_loading.set(true);
|
ui.gallery.albums_loading.set(true);
|
||||||
albums_status(
|
// A grid already on screen stays there while it refreshes, rather than
|
||||||
ui,
|
// blinking to a loading page and losing its scroll position.
|
||||||
"view-grid-symbolic",
|
if ui.gallery.albums_stack.visible_child_name().as_deref() != Some("grid") {
|
||||||
"Loading albums…",
|
albums_status(
|
||||||
"Reading your Proton Drive albums.",
|
ui,
|
||||||
);
|
"view-grid-symbolic",
|
||||||
|
"Loading albums…",
|
||||||
|
"Reading your Proton Drive albums.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoAlbums);
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
@@ -52,7 +63,7 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
&ui,
|
&ui,
|
||||||
"view-grid-symbolic",
|
"view-grid-symbolic",
|
||||||
"No albums",
|
"No albums",
|
||||||
"Albums you create in Proton Photos appear here.",
|
"Create one with New Album, or in Proton Photos.",
|
||||||
),
|
),
|
||||||
Ok(Ok(Response::Albums { items, .. })) => {
|
Ok(Ok(Response::Albums { items, .. })) => {
|
||||||
fill_albums(&ui, &items);
|
fill_albums(&ui, &items);
|
||||||
@@ -80,15 +91,31 @@ pub(crate) fn load_albums(ui: &Rc<Ui>) {
|
|||||||
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
/// Replace the grid's cards with `albums`, in the order the daemon gave them
|
||||||
/// (newest activity first).
|
/// (newest activity first).
|
||||||
fn fill_albums(ui: &Rc<Ui>, albums: &[AlbumInfo]) {
|
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() {
|
while let Some(child) = ui.gallery.albums.first_child() {
|
||||||
ui.gallery.albums.remove(&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(&count_noun(albums.len(), "album", "albums"));
|
||||||
|
}
|
||||||
for album in albums {
|
for album in albums {
|
||||||
ui.gallery.albums.append(&album_card(ui, album));
|
ui.gallery.albums.append(&album_card(ui, album));
|
||||||
}
|
}
|
||||||
// The covers are ordinary photos as far as the daemon is concerned, so the
|
// The covers are ordinary photos as far as the daemon is concerned, so the
|
||||||
// batch that fills them is the gallery's own.
|
// batch that fills them is the gallery's own.
|
||||||
schedule_thumbs(ui);
|
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
|
/// One album as a clickable card: its cover, its name, and how many photos it
|
||||||
@@ -150,12 +177,354 @@ fn album_card(ui: &Rc<Ui>, album: &AlbumInfo) -> gtk4::Button {
|
|||||||
want_cover(ui, uid, &picture);
|
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 ui_open = ui.clone();
|
||||||
let album = album.clone();
|
let album = album.clone();
|
||||||
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
button.connect_clicked(move |_| open_album(&ui_open, album.clone()));
|
||||||
button
|
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("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("Rename…", move || prompt_rename_album(&ui_c, &album_c));
|
||||||
|
menu.section();
|
||||||
|
let (ui_c, album_c) = (ui.clone(), album.clone());
|
||||||
|
menu.item("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("Album 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("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();
|
||||||
|
prompt_album_name(ui, "New Album", "Create", "", 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, "Album created");
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
} else {
|
||||||
|
add_to_album(&ui, uid, photos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't create the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't create the album",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_rename_album(ui: &Rc<Ui>, album: &AlbumInfo) {
|
||||||
|
let (ui_c, uid) = (ui.clone(), album.uid.clone());
|
||||||
|
prompt_album_name(ui, "Rename Album", "Rename", &album.name, move |name| {
|
||||||
|
run_album_request(
|
||||||
|
&ui_c,
|
||||||
|
Request::RenameAlbum {
|
||||||
|
uid: uid.clone(),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
"Album renamed",
|
||||||
|
"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,
|
||||||
|
"Delete Album?",
|
||||||
|
&format!(
|
||||||
|
"“{}” is deleted. Its photos stay in your timeline.",
|
||||||
|
album.name
|
||||||
|
),
|
||||||
|
"Delete",
|
||||||
|
move || {
|
||||||
|
run_album_request(
|
||||||
|
&ui_c,
|
||||||
|
Request::DeleteAlbum {
|
||||||
|
uid: uid.clone(),
|
||||||
|
delete_photos: false,
|
||||||
|
},
|
||||||
|
"Album deleted",
|
||||||
|
"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: &'static str, failed: &'static str) {
|
||||||
|
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, "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, "Couldn't load albums", &message, kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't load albums",
|
||||||
|
"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("Add to Album")
|
||||||
|
.body(match photos.len() {
|
||||||
|
1 => "Choose an album for this photo.".to_string(),
|
||||||
|
n => format!("Choose an album for {n} photos."),
|
||||||
|
})
|
||||||
|
.extra_child(&scroll)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("new", "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, "Some photos couldn't be added", &failure.message);
|
||||||
|
} else {
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
&format!(
|
||||||
|
"Added {} to the album",
|
||||||
|
count_noun(changed.len(), "photo", "photos")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
reload_album_grid(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't add to the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't add to the album",
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
&format!(
|
||||||
|
"Removed {} from the album",
|
||||||
|
count_noun(changed.len(), "photo", "photos")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
restore_photos(&ui, removed);
|
||||||
|
toast_failure(&ui, "Couldn't remove from the album", &message, kind)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
restore_photos(&ui, removed);
|
||||||
|
toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't remove from the album",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// "12 photos", plus where the album came from when it isn't ours.
|
/// "12 photos", plus where the album came from when it isn't ours.
|
||||||
fn album_subtitle(album: &AlbumInfo) -> String {
|
fn album_subtitle(album: &AlbumInfo) -> String {
|
||||||
let photos = match album.photo_count {
|
let photos = match album.photo_count {
|
||||||
@@ -192,10 +561,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
|
/// 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.
|
/// instead, with the filters that don't apply to it out of the way.
|
||||||
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
pub(crate) fn open_album(ui: &Rc<Ui>, album: AlbumInfo) {
|
||||||
ui.gallery.title.set_label(&album.name);
|
ui.gallery.title.set_title(&album.name);
|
||||||
ui.gallery.subtitle.set_label(&album_subtitle(&album));
|
ui.gallery.title.set_subtitle(&album_subtitle(&album));
|
||||||
ui.gallery.subtitle.set_visible(true);
|
|
||||||
*ui.gallery.album.borrow_mut() = Some(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
|
// 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
|
// timeline rather than an album — hide those rather than offer controls that
|
||||||
@@ -219,8 +588,7 @@ pub(crate) fn close_album(ui: &Rc<Ui>) {
|
|||||||
if ui.gallery.album.borrow_mut().take().is_none() {
|
if ui.gallery.album.borrow_mut().take().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.gallery.title.set_label("Gallery");
|
ui.gallery.title.set_title("Photos");
|
||||||
ui.gallery.filters.set_visible(true);
|
|
||||||
ui.gallery.view_switch.set_visible(true);
|
ui.gallery.view_switch.set_visible(true);
|
||||||
ui.gallery.upload.set_visible(true);
|
ui.gallery.upload.set_visible(true);
|
||||||
ui.gallery.back.set_visible(false);
|
ui.gallery.back.set_visible(false);
|
||||||
@@ -251,15 +619,28 @@ pub(crate) fn wire_albums(ui: &Rc<Ui>) {
|
|||||||
if !btn.is_active() {
|
if !btn.is_active() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The timeline is reloaded rather than restored, because an open album
|
|
||||||
// left the model holding its own photos.
|
|
||||||
close_album(&ui_photos);
|
close_album(&ui_photos);
|
||||||
|
ui_photos.gallery.filters.set_visible(true);
|
||||||
ui_photos.gallery.content.set_visible_child_name("timeline");
|
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();
|
let ui_back = ui.clone();
|
||||||
ui.gallery.back.clone().connect_clicked(move |_| {
|
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(&count_noun(count, "album", "albums"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -36,46 +36,24 @@ pub(crate) struct DevicesWidgets {
|
|||||||
/// machine's own device is deliberately not among them; see
|
/// machine's own device is deliberately not among them; see
|
||||||
/// [`repaint_devices`].
|
/// [`repaint_devices`].
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
/// "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) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Computers page: a "This computer" section listing the local folders synced
|
/// The Computers page: a "This computer" section naming the device this machine
|
||||||
/// to this machine's device (each row offering a mode toggle and Remove), plus an
|
/// backs up to (renamable from its header), plus an "Other computers" section
|
||||||
/// "Other devices" section listing the account's other registered devices.
|
/// 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) {
|
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 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
|
// 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
|
// 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.
|
// second copy of that list here would be two places to change the same mode.
|
||||||
let sync_group = adw::PreferencesGroup::builder()
|
let sync_group = adw::PreferencesGroup::builder()
|
||||||
.title("This computer")
|
.title("This computer")
|
||||||
.description("The device this machine backs up to.")
|
.description("The computer this machine backs up as.")
|
||||||
.build();
|
.build();
|
||||||
// Rename control for *this* machine's device, in the section header. The
|
// Rename control for *this* machine's device, in the section header. The
|
||||||
// current device is filtered out of "Other computers", so this is the only
|
// current device is filtered out of "Other computers", so this is the only
|
||||||
@@ -90,7 +68,7 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
sync_group.set_header_suffix(Some(&rename_this));
|
sync_group.set_header_suffix(Some(&rename_this));
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Other computers")
|
.title("Other computers")
|
||||||
.description("Other devices backing up to this account.")
|
.description("Other computers backing up to this account.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
@@ -123,37 +101,35 @@ pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
|||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(18);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(18);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(18);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(18);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
let (frame, header, _) = page_frame("Computers", &inner);
|
||||||
|
header.pack_end(&refresh);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
DevicesWidgets {
|
DevicesWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
sync_group,
|
sync_group,
|
||||||
rename_this,
|
rename_this,
|
||||||
group,
|
group,
|
||||||
restore,
|
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
refresh,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Devices page's retry button and the "Restore Folders" action.
|
/// Install the Devices page's retry button and the Rename action.
|
||||||
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button, restore: >k4::Button) {
|
pub(crate) fn wire_devices(ui: &Rc<Ui>, retry: >k4::Button) {
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_devices(&ui_retry);
|
load_devices(&ui_retry);
|
||||||
});
|
});
|
||||||
let ui_restore = ui.clone();
|
|
||||||
restore.connect_clicked(move |_| prompt_restore_folders(&ui_restore));
|
|
||||||
let ui_ren = ui.clone();
|
let ui_ren = ui.clone();
|
||||||
ui.devices.rename_this.connect_clicked(move |_| {
|
ui.devices.rename_this.connect_clicked(move |_| {
|
||||||
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
if let Some((uid, name)) = ui_ren.devices.this_device.borrow().clone() {
|
||||||
@@ -183,7 +159,7 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
|||||||
ui,
|
ui,
|
||||||
"computer-symbolic",
|
"computer-symbolic",
|
||||||
"Loading…",
|
"Loading…",
|
||||||
"Reading your devices.",
|
"Reading your computers.",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
ui.busy_begin();
|
ui.busy_begin();
|
||||||
@@ -271,6 +247,9 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
|||||||
match devices.iter().find(|d| d.this_device) {
|
match devices.iter().find(|d| d.this_device) {
|
||||||
Some(me) => {
|
Some(me) => {
|
||||||
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
*ui.devices.this_device.borrow_mut() = Some((me.uid.clone(), me.name.clone()));
|
||||||
|
ui.devices
|
||||||
|
.sync_group
|
||||||
|
.set_description(Some(&format!("Backing up as “{}”.", me.name)));
|
||||||
ui.devices.rename_this.set_sensitive(true);
|
ui.devices.rename_this.set_sensitive(true);
|
||||||
ui.devices
|
ui.devices
|
||||||
.rename_this
|
.rename_this
|
||||||
@@ -299,43 +278,34 @@ pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo]) {
|
|||||||
.subtitle(device_subtitle(dev))
|
.subtitle(device_subtitle(dev))
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
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
|
// Adoption is how a reinstalled or renamed machine re-attaches to the
|
||||||
// device it used to be, instead of registering a duplicate. It only
|
// 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.
|
// loop paints.
|
||||||
let adopt = gtk4::Button::builder()
|
let (ui_ren, uid_ren, name_ren) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.icon_name("insert-object-symbolic")
|
let (ui_ad, uid_ad, name_ad) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.tooltip_text("Use this computer's identity for this machine")
|
let (ui_rm, uid_rm, name_rm) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.valign(gtk4::Align::Center)
|
let (ui_rs, uid_rs, name_rs) = (ui.clone(), dev.uid.clone(), dev.name.clone());
|
||||||
.build();
|
row.add_suffix(&more_menu_button(vec![
|
||||||
adopt.add_css_class("flat");
|
(
|
||||||
let ui_ad = ui.clone();
|
"Restore to This Computer…",
|
||||||
let uid_ad = dev.uid.clone();
|
Box::new(move || {
|
||||||
let name_ad = dev.name.clone();
|
prompt_restore_folders(&ui_rs, Some((uid_rs.clone(), name_rs.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();
|
"Rename…",
|
||||||
let name_ren = dev.name.clone();
|
Box::new(move || prompt_rename_device(&ui_ren, &uid_ren, &name_ren)),
|
||||||
rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren));
|
),
|
||||||
let ui_rm = ui.clone();
|
(
|
||||||
let uid_rm = dev.uid.clone();
|
"Use This Computer's Identity…",
|
||||||
let name_rm = dev.name.clone();
|
Box::new(move || prompt_adopt_device(&ui_ad, &uid_ad, &name_ad)),
|
||||||
remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm));
|
),
|
||||||
row.add_suffix(&rename);
|
(
|
||||||
row.add_suffix(&remove);
|
"Remove Computer…",
|
||||||
|
Box::new(move || prompt_remove_device(&ui_rm, &uid_rm, &name_rm)),
|
||||||
|
),
|
||||||
|
]));
|
||||||
ui.devices.group.add(&row);
|
ui.devices.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
}
|
}
|
||||||
@@ -370,7 +340,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
|||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
row.add_prefix(>k4::Image::from_icon_name("computer-symbolic"));
|
||||||
let manage = gtk4::Button::builder()
|
let manage = gtk4::Button::builder()
|
||||||
.label("Locations")
|
.label("Open Sync")
|
||||||
.tooltip_text("Manage this computer's folders and mountpoint")
|
.tooltip_text("Manage this computer's folders and mountpoint")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
@@ -378,6 +348,11 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
|||||||
let ui_go = ui.clone();
|
let ui_go = ui.clone();
|
||||||
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
manage.connect_clicked(move |_| ui_go.stack.set_visible_child_name("locations"));
|
||||||
row.add_suffix(&manage);
|
row.add_suffix(&manage);
|
||||||
|
let ui_rs = ui.clone();
|
||||||
|
row.add_suffix(&more_menu_button(vec![(
|
||||||
|
"Restore Folders…",
|
||||||
|
Box::new(move || prompt_restore_folders(&ui_rs, None)),
|
||||||
|
)]));
|
||||||
ui.devices.sync_group.add(&row);
|
ui.devices.sync_group.add(&row);
|
||||||
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
*ui.devices.sync_rows.borrow_mut() = vec![row.upcast()];
|
||||||
}
|
}
|
||||||
@@ -386,7 +361,7 @@ pub(crate) fn repaint_this_computer(ui: &Rc<Ui>, folders: &[SyncFolderInfo]) {
|
|||||||
/// attention — the one fact worth surfacing away from the folder list itself.
|
/// attention — the one fact worth surfacing away from the folder list itself.
|
||||||
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
||||||
if folders.is_empty() {
|
if folders.is_empty() {
|
||||||
return "No folders backed up yet — add one under Locations.".to_string();
|
return "No folders backed up yet — add one on the Sync page.".to_string();
|
||||||
}
|
}
|
||||||
let count = match folders.len() {
|
let count = match folders.len() {
|
||||||
1 => "1 folder backed up".to_string(),
|
1 => "1 folder backed up".to_string(),
|
||||||
@@ -397,7 +372,7 @@ pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
|
|||||||
.filter(|f| f.state == "error" || f.state == "conflict")
|
.filter(|f| f.state == "error" || f.state == "conflict")
|
||||||
.count();
|
.count();
|
||||||
match attention {
|
match attention {
|
||||||
0 => format!("{count} · manage them under Locations"),
|
0 => format!("{count} · manage them on the Sync page"),
|
||||||
1 => format!("{count} · 1 needs attention"),
|
1 => format!("{count} · 1 needs attention"),
|
||||||
n => format!("{count} · {n} need attention"),
|
n => format!("{count} · {n} need attention"),
|
||||||
}
|
}
|
||||||
@@ -481,21 +456,30 @@ pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the daemon what this machine's device holds, then show a picker mapping
|
/// Ask the daemon what a device holds, then show a picker mapping each remote
|
||||||
/// each remote folder onto a local directory.
|
/// 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
|
/// 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
|
/// makes sense here, else `~/<name>` — so every one of them is editable and
|
||||||
/// nothing is restored without being ticked.
|
/// 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();
|
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();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let result = rx.recv().await;
|
let result = rx.recv().await;
|
||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::RestorableFolders { items })) => show_restore_picker(&ui, items),
|
Ok(Ok(Response::RestorableFolders { items })) => {
|
||||||
|
show_restore_picker(&ui, items, device)
|
||||||
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
|
toast_failure(&ui, "Couldn't list folders to restore", &message, kind)
|
||||||
}
|
}
|
||||||
@@ -509,14 +493,18 @@ pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The restore picker itself: one editable row per restorable folder.
|
/// 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 win = ui_window(ui);
|
||||||
let candidates: Vec<RestorableFolder> =
|
let candidates: Vec<RestorableFolder> =
|
||||||
items.into_iter().filter(|f| !f.already_synced).collect();
|
items.into_iter().filter(|f| !f.already_synced).collect();
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
toast(
|
toast(
|
||||||
ui,
|
ui,
|
||||||
"Nothing to restore — this computer's folders are all synced here.",
|
¬hing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -547,7 +535,9 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
|||||||
.build();
|
.build();
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Restore folders")
|
.heading("Restore folders")
|
||||||
.body("These folders are backed up under this computer in Proton Drive.")
|
.body(restore_picker_body(
|
||||||
|
device.as_ref().map(|(_, n)| n.as_str()),
|
||||||
|
))
|
||||||
.extra_child(&scroll)
|
.extra_child(&scroll)
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
@@ -577,7 +567,13 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
|||||||
// rows appear as the periodic refresh picks them up.
|
// rows appear as the periodic refresh picks them up.
|
||||||
run_devices_mutation(
|
run_devices_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::RestoreSyncFolders { items },
|
match &device {
|
||||||
|
Some((uid, _)) => Request::RestoreDeviceFolders {
|
||||||
|
device: uid.clone(),
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
None => Request::RestoreSyncFolders { items },
|
||||||
|
},
|
||||||
"Restoring folders…",
|
"Restoring folders…",
|
||||||
"Couldn't restore folders",
|
"Couldn't restore folders",
|
||||||
);
|
);
|
||||||
@@ -585,10 +581,29 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
|||||||
dialog.present(win.as_ref());
|
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 {
|
||||||
|
Some(name) => format!(
|
||||||
|
"These folders are backed up from “{name}”. Restored folders keep syncing with \
|
||||||
|
that computer's copy."
|
||||||
|
),
|
||||||
|
None => "These folders are backed up under this computer in Proton Drive.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The toast when every folder of a device is already synced here.
|
||||||
|
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
|
||||||
|
match device {
|
||||||
|
Some(name) => format!("Nothing to restore — “{name}”'s folders are all synced here."),
|
||||||
|
None => "Nothing to restore — this computer's folders are all synced here.".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
|
/// 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
|
/// 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).
|
/// 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(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
Request::SetSyncFolderMode {
|
Request::SetSyncFolderMode {
|
||||||
@@ -599,7 +614,17 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
|||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
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" {
|
||||||
|
"Folder will switch to on-demand"
|
||||||
|
} else {
|
||||||
|
"Folder will download and stay synced"
|
||||||
|
},
|
||||||
|
),
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
||||||
}
|
}
|
||||||
@@ -609,12 +634,22 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
|||||||
"The mount service didn't respond.",
|
"The mount service didn't respond.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
reload_sync_pages(&ui);
|
||||||
load_devices(&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.
|
/// 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) {
|
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
@@ -679,7 +714,7 @@ 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) {
|
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Rename device")
|
.heading("Rename Computer")
|
||||||
.body(format!("Rename “{current}”."))
|
.body(format!("Rename “{current}”."))
|
||||||
.build();
|
.build();
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
@@ -703,7 +738,7 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
|||||||
}
|
}
|
||||||
let name = row.text().trim().to_string();
|
let name = row.text().trim().to_string();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
toast_error(&ui, "Couldn't rename device", "A name is required.");
|
toast_error(&ui, "Couldn't rename the computer", "A name is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
run_devices_mutation(
|
run_devices_mutation(
|
||||||
@@ -712,29 +747,42 @@ pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
|
|||||||
uid: uid.clone(),
|
uid: uid.clone(),
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
"Device renamed",
|
"Computer renamed",
|
||||||
"Couldn't rename the device",
|
"Couldn't rename the computer",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
dialog.present(win.as_ref());
|
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) {
|
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
|
let confirm = adw::EntryRow::builder()
|
||||||
|
.title(format!("Type “{name}” to confirm"))
|
||||||
|
.build();
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
group.add(&confirm);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Remove computer")
|
.heading("Remove Computer")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \
|
"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 \
|
deleted along with it. The files on that computer itself are not touched — but \
|
||||||
this cannot be undone from here."
|
this cannot be undone from here."
|
||||||
))
|
))
|
||||||
|
.extra_child(&group)
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
dialog.add_response("remove", "Remove");
|
dialog.add_response("remove", "Remove");
|
||||||
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_response_enabled("remove", false);
|
||||||
dialog.set_default_response(Some("cancel"));
|
dialog.set_default_response(Some("cancel"));
|
||||||
dialog.set_close_response("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 ui = ui.clone();
|
||||||
let uid = uid.to_string();
|
let uid = uid.to_string();
|
||||||
dialog.connect_response(None, move |_, resp| {
|
dialog.connect_response(None, move |_, resp| {
|
||||||
@@ -742,8 +790,8 @@ pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
run_devices_mutation(
|
run_devices_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::DeleteDevice { uid: uid.clone() },
|
Request::DeleteDevice { uid: uid.clone() },
|
||||||
"Device removed",
|
"Computer removed",
|
||||||
"Couldn't remove the device",
|
"Couldn't remove the computer",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -790,7 +838,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a mutation raised from the Devices page and reload the page on success.
|
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||||
pub(crate) fn run_devices_mutation(
|
pub(crate) fn run_devices_mutation(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -806,7 +854,7 @@ pub(crate) fn run_devices_mutation(
|
|||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
load_devices(&ui);
|
reload_sync_pages(&ui);
|
||||||
toast(&ui, &done);
|
toast(&ui, &done);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||||
@@ -829,6 +877,7 @@ mod tests {
|
|||||||
last_sync: 0,
|
last_sync: 0,
|
||||||
pending_mode: None,
|
pending_mode: None,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,7 +885,7 @@ mod tests {
|
|||||||
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
fn a_machine_with_no_folders_is_told_where_to_add_one() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[]),
|
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 +895,7 @@ mod tests {
|
|||||||
// the Computers page can surface that something is wrong.
|
// the Computers page can surface that something is wrong.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("idle")]),
|
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!(
|
assert_eq!(
|
||||||
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
this_computer_subtitle(&[folder("idle"), folder("error")]),
|
||||||
@@ -857,4 +906,11 @@ mod tests {
|
|||||||
"2 folders backed up · 2 need attention"
|
"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”"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! The Locations page: every local place Proton Drive occupies on this machine.
|
//! The Sync page (formerly Locations): what is moving right now, and every local
|
||||||
|
//! place Proton Drive occupies on this machine.
|
||||||
//!
|
//!
|
||||||
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
||||||
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
||||||
@@ -21,6 +22,46 @@ pub(crate) struct LocationsState {
|
|||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) inflight: Cell<bool>,
|
||||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||||
|
pub(crate) card: SyncCard,
|
||||||
|
pub(crate) queue: QueueState,
|
||||||
|
pub(crate) conflicts: ConflictsState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status card heading the Sync page: what sync is doing, and Pause/Resume.
|
||||||
|
pub(crate) struct SyncCard {
|
||||||
|
pub(crate) icon: gtk4::Image,
|
||||||
|
pub(crate) row: adw::ActionRow,
|
||||||
|
pub(crate) pause: adw::SplitButton,
|
||||||
|
/// Whether the last status said paused, so the button knows which way to go.
|
||||||
|
pub(crate) paused: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
||||||
|
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
||||||
|
|
||||||
|
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||||
|
pub(crate) struct QueueState {
|
||||||
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) retry_all: gtk4::Button,
|
||||||
|
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
/// What the rows were built from, so an unchanged queue is not rebuilt on
|
||||||
|
/// every tick.
|
||||||
|
pub(crate) painted: RefCell<Vec<QueueKey>>,
|
||||||
|
pub(crate) inflight: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a conflict row shows that can change: path, both sizes, both times.
|
||||||
|
pub(crate) type ConflictKey = (String, u64, i64, Option<u64>, Option<i64>);
|
||||||
|
|
||||||
|
/// The "Conflicts" list: `(sync-conflict …)` copies waiting for a decision.
|
||||||
|
pub(crate) struct ConflictsState {
|
||||||
|
pub(crate) group: adw::PreferencesGroup,
|
||||||
|
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
pub(crate) painted: RefCell<Vec<ConflictKey>>,
|
||||||
|
pub(crate) inflight: Cell<bool>,
|
||||||
|
/// When the list was last fetched. Listing walks every node the daemon
|
||||||
|
/// knows, so the tick asks far less often than it does for the queue.
|
||||||
|
pub(crate) fetched_at: Cell<Option<Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Locations page's load/repaint touch.
|
/// Widgets the Locations page's load/repaint touch.
|
||||||
@@ -31,33 +72,38 @@ pub(crate) struct LocationsWidgets {
|
|||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
pub(crate) add_folder: gtk4::Button,
|
pub(crate) add_folder: gtk4::Button,
|
||||||
|
/// Live transfers, above the folder list; painted by the refresh loop.
|
||||||
|
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) card_icon: gtk4::Image,
|
||||||
|
pub(crate) card_row: adw::ActionRow,
|
||||||
|
pub(crate) pause: adw::SplitButton,
|
||||||
|
pub(crate) queue_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) retry_all: gtk4::Button,
|
||||||
|
pub(crate) conflicts_group: adw::PreferencesGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||||
let title = gtk4::Label::builder()
|
|
||||||
.label("Locations")
|
|
||||||
.halign(gtk4::Align::Start)
|
|
||||||
.build();
|
|
||||||
title.add_css_class("title-2");
|
|
||||||
|
|
||||||
let add_folder = gtk4::Button::builder()
|
let add_folder = gtk4::Button::builder()
|
||||||
.label("Add Folder")
|
.child(
|
||||||
|
&adw::ButtonContent::builder()
|
||||||
|
.label("Add Folder")
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
.tooltip_text("Back a local folder up to this computer's Proton Drive device")
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
.build();
|
||||||
add_folder.add_css_class("flat");
|
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
let transfers_group = build_transfers_group();
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
let (card, card_icon, card_row, pause) = build_sync_card();
|
||||||
let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
let (queue_group, retry_all) = build_queue_group();
|
||||||
titles.set_hexpand(true);
|
let conflicts_group = adw::PreferencesGroup::builder()
|
||||||
titles.append(&title);
|
.title("Conflicts")
|
||||||
header.append(&titles);
|
.description(
|
||||||
header.append(&refresh);
|
"Files changed in two places at once. Both versions were kept; choose which \
|
||||||
header.append(&add_folder);
|
one stays.",
|
||||||
// Clamped to the same width as the rows below, or the page title floats off
|
)
|
||||||
// to the left of the list it names.
|
.visible(false)
|
||||||
let header_clamp = adw::Clamp::builder().child(&header).build();
|
.build();
|
||||||
|
|
||||||
// Same warning the Computers page carried, for the same reason: the
|
// Same warning the Computers page carried, for the same reason: the
|
||||||
// on-demand switch removes the local copy, which is not a thing to discover
|
// on-demand switch removes the local copy, which is not a thing to discover
|
||||||
@@ -72,6 +118,10 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
groups.append(&card);
|
||||||
|
groups.append(&conflicts_group);
|
||||||
|
groups.append(&queue_group);
|
||||||
|
groups.append(&transfers_group);
|
||||||
groups.append(&group);
|
groups.append(&group);
|
||||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||||
// Never scroll sideways: a location's title is a full path, and letting the
|
// Never scroll sideways: a location's title is a full path, and letting the
|
||||||
@@ -114,8 +164,10 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
|||||||
page.set_margin_bottom(18);
|
page.set_margin_bottom(18);
|
||||||
page.set_margin_start(18);
|
page.set_margin_start(18);
|
||||||
page.set_margin_end(18);
|
page.set_margin_end(18);
|
||||||
page.append(&header_clamp);
|
|
||||||
page.append(&content);
|
page.append(&content);
|
||||||
|
let (frame, header, _) = page_frame("Sync", &page);
|
||||||
|
header.pack_start(&add_folder);
|
||||||
|
header.pack_end(&refresh);
|
||||||
|
|
||||||
let widgets = LocationsWidgets {
|
let widgets = LocationsWidgets {
|
||||||
content: content.clone(),
|
content: content.clone(),
|
||||||
@@ -124,8 +176,15 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
|||||||
retry: retry.clone(),
|
retry: retry.clone(),
|
||||||
refresh,
|
refresh,
|
||||||
add_folder,
|
add_folder,
|
||||||
|
transfers_group,
|
||||||
|
card_icon,
|
||||||
|
card_row,
|
||||||
|
pause,
|
||||||
|
queue_group,
|
||||||
|
retry_all,
|
||||||
|
conflicts_group,
|
||||||
};
|
};
|
||||||
(page.upcast(), widgets)
|
(frame.upcast(), widgets)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
||||||
@@ -136,6 +195,522 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
|||||||
});
|
});
|
||||||
let ui_add = ui.clone();
|
let ui_add = ui.clone();
|
||||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||||
|
wire_pause(ui);
|
||||||
|
let ui_retry = ui.clone();
|
||||||
|
ui.locations
|
||||||
|
.queue
|
||||||
|
.retry_all
|
||||||
|
.connect_clicked(move |_| retry_queued(&ui_retry, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The status card: a large state icon, the state in words, and Pause/Resume
|
||||||
|
/// with timed pauses in its menu.
|
||||||
|
fn build_sync_card() -> (
|
||||||
|
adw::PreferencesGroup,
|
||||||
|
gtk4::Image,
|
||||||
|
adw::ActionRow,
|
||||||
|
adw::SplitButton,
|
||||||
|
) {
|
||||||
|
let icon = gtk4::Image::builder()
|
||||||
|
.icon_name("emblem-synchronizing-symbolic")
|
||||||
|
.pixel_size(32)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(6)
|
||||||
|
.build();
|
||||||
|
icon.add_css_class("sidebar-status");
|
||||||
|
let menu = gio::Menu::new();
|
||||||
|
menu.append(Some("Pause for 1 Hour"), Some("sync.pause-for(int64 3600)"));
|
||||||
|
menu.append(
|
||||||
|
Some("Pause for 8 Hours"),
|
||||||
|
Some("sync.pause-for(int64 28800)"),
|
||||||
|
);
|
||||||
|
menu.append(
|
||||||
|
Some("Pause for 24 Hours"),
|
||||||
|
Some("sync.pause-for(int64 86400)"),
|
||||||
|
);
|
||||||
|
let pause = adw::SplitButton::builder()
|
||||||
|
.label("Pause")
|
||||||
|
.menu_model(&menu)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.tooltip_text("Stop uploading until you resume. Files still open as usual.")
|
||||||
|
.dropdown_tooltip("Pause for a while")
|
||||||
|
.build();
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title("Checking…")
|
||||||
|
.title_lines(1)
|
||||||
|
.subtitle_lines(2)
|
||||||
|
.build();
|
||||||
|
row.add_css_class("property");
|
||||||
|
row.add_prefix(&icon);
|
||||||
|
row.add_suffix(&pause);
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
group.add(&row);
|
||||||
|
(group, icon, row, pause)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the Sync page's status card from what the sidebar strip shows.
|
||||||
|
pub(crate) fn paint_sync_card(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
icon: &str,
|
||||||
|
class: Option<&str>,
|
||||||
|
title: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
paused: bool,
|
||||||
|
connected: bool,
|
||||||
|
) {
|
||||||
|
let card = &ui.locations.card;
|
||||||
|
card.icon.set_icon_name(Some(icon));
|
||||||
|
for c in ["success", "warning", "error"] {
|
||||||
|
card.icon.remove_css_class(c);
|
||||||
|
}
|
||||||
|
if let Some(class) = class {
|
||||||
|
card.icon.add_css_class(class);
|
||||||
|
}
|
||||||
|
card.row.set_title(title);
|
||||||
|
card.row.set_subtitle(detail.unwrap_or_default());
|
||||||
|
card.paused.set(paused);
|
||||||
|
card.pause
|
||||||
|
.set_label(if paused { "Resume" } else { "Pause" });
|
||||||
|
card.pause.set_tooltip_text(Some(if paused {
|
||||||
|
"Upload everything that waited while sync was paused"
|
||||||
|
} else {
|
||||||
|
"Stop uploading until you resume. Files still open as usual."
|
||||||
|
}));
|
||||||
|
if paused {
|
||||||
|
card.pause.add_css_class("suggested-action");
|
||||||
|
} else {
|
||||||
|
card.pause.remove_css_class("suggested-action");
|
||||||
|
}
|
||||||
|
card.pause.set_sensitive(connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause/Resume on the card: the button flips, the menu pauses for a while.
|
||||||
|
fn wire_pause(ui: &Rc<Ui>) {
|
||||||
|
let ui_click = ui.clone();
|
||||||
|
ui.locations.card.pause.connect_clicked(move |_| {
|
||||||
|
let resume = ui_click.locations.card.paused.get();
|
||||||
|
set_sync_paused(&ui_click, !resume, None);
|
||||||
|
});
|
||||||
|
let actions = gio::SimpleActionGroup::new();
|
||||||
|
let pause_for = gio::SimpleAction::new("pause-for", Some(glib::VariantTy::INT64));
|
||||||
|
let ui_for = ui.clone();
|
||||||
|
pause_for.connect_activate(move |_, param| {
|
||||||
|
let Some(secs) = param.and_then(|p| p.get::<i64>()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let until = glib::DateTime::now_utc()
|
||||||
|
.map(|now| now.to_unix())
|
||||||
|
.unwrap_or_default()
|
||||||
|
+ secs;
|
||||||
|
set_sync_paused(&ui_for, true, Some(until));
|
||||||
|
});
|
||||||
|
actions.add_action(&pause_for);
|
||||||
|
ui.locations
|
||||||
|
.card
|
||||||
|
.pause
|
||||||
|
.insert_action_group("sync", Some(&actions));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
|
||||||
|
ui.locations.card.pause.set_sensitive(false);
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::SetSyncPaused { paused, until },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let what = if paused {
|
||||||
|
"Couldn't pause sync"
|
||||||
|
} else {
|
||||||
|
"Couldn't resume sync"
|
||||||
|
};
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(
|
||||||
|
&ui,
|
||||||
|
match (paused, until) {
|
||||||
|
(false, _) => "Sync resumed",
|
||||||
|
(true, None) => "Sync paused until you resume",
|
||||||
|
(true, Some(_)) => "Sync paused",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, what, &message, kind),
|
||||||
|
_ => toast_error(&ui, what, "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
ui.locations.card.pause.set_sensitive(true);
|
||||||
|
refresh_status(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The queue list, hidden while nothing waits.
|
||||||
|
fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) {
|
||||||
|
let retry_all = gtk4::Button::builder()
|
||||||
|
.label("Retry All")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.tooltip_text("Try every failed change again now")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
retry_all.add_css_class("flat");
|
||||||
|
let group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Waiting to Upload")
|
||||||
|
.description("Changes made on this computer that are not on Proton Drive yet.")
|
||||||
|
.header_suffix(&retry_all)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
(group, retry_all)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most queue rows shown at once. The queue can hold thousands after a big copy;
|
||||||
|
/// the first screenful says what is going on, and the count says the rest.
|
||||||
|
const QUEUE_ROWS_SHOWN: usize = 50;
|
||||||
|
|
||||||
|
/// Poll the pending-op queue while the Sync page is on screen.
|
||||||
|
pub(crate) fn refresh_queue(ui: &Rc<Ui>) {
|
||||||
|
if ui.locations.queue.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.locations.queue.inflight.set(true);
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListPendingOps);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.locations.queue.inflight.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::PendingOps { items })) => repaint_queue(&ui, &items),
|
||||||
|
// An older daemon without the request, or none at all: say nothing
|
||||||
|
// rather than show a list that cannot be kept current.
|
||||||
|
_ => repaint_queue(&ui, &[]),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
||||||
|
let queue = &ui.locations.queue;
|
||||||
|
// Stuck first — they are why the user is here — then oldest first.
|
||||||
|
let mut items: Vec<&PendingOpInfo> = items.iter().collect();
|
||||||
|
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||||
|
let key: Vec<QueueKey> = items
|
||||||
|
.iter()
|
||||||
|
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked))
|
||||||
|
.collect();
|
||||||
|
if *queue.painted.borrow() == key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*queue.painted.borrow_mut() = key;
|
||||||
|
|
||||||
|
for row in queue.rows.borrow_mut().drain(..) {
|
||||||
|
queue.group.remove(&row);
|
||||||
|
}
|
||||||
|
queue.group.set_visible(!items.is_empty());
|
||||||
|
queue
|
||||||
|
.retry_all
|
||||||
|
.set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked));
|
||||||
|
let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN);
|
||||||
|
queue.group.set_description(Some(&if hidden > 0 {
|
||||||
|
format!(
|
||||||
|
"Changes made on this computer that are not on Proton Drive yet. \
|
||||||
|
Showing the first {QUEUE_ROWS_SHOWN} of {}.",
|
||||||
|
items.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"Changes made on this computer that are not on Proton Drive yet.".to_string()
|
||||||
|
}));
|
||||||
|
let now = glib::DateTime::now_utc()
|
||||||
|
.map(|now| now.to_unix())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut rows = queue.rows.borrow_mut();
|
||||||
|
for op in items.into_iter().take(QUEUE_ROWS_SHOWN) {
|
||||||
|
let row = queue_row(ui, op, now);
|
||||||
|
queue.group.add(&row);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||||
|
let (icon, action) = match op.kind.as_str() {
|
||||||
|
"revision" => ("pdfs-upload-symbolic", "Upload changes"),
|
||||||
|
"create" => ("pdfs-upload-symbolic", "Upload new file"),
|
||||||
|
"mkdir" => ("folder-new-symbolic", "Create folder"),
|
||||||
|
"rename" => ("document-edit-symbolic", "Rename or move"),
|
||||||
|
"trash" => ("user-trash-symbolic", "Move to trash"),
|
||||||
|
_ => ("emblem-synchronizing-symbolic", "Change"),
|
||||||
|
};
|
||||||
|
let state = match (op.parked, op.next_attempt_at) {
|
||||||
|
(true, _) => "waiting for the app writing it to finish".to_string(),
|
||||||
|
(false, Some(at)) if at > now && op.attempts > 0 => {
|
||||||
|
format!("retrying {}", clock_time(at))
|
||||||
|
}
|
||||||
|
(false, _) if ui.locations.card.paused.get() => "waiting for sync to resume".to_string(),
|
||||||
|
(false, _) => "up next".to_string(),
|
||||||
|
};
|
||||||
|
let mut subtitle = format!("{action} · {state}");
|
||||||
|
if let Some(error) = &op.last_error {
|
||||||
|
subtitle.push_str(&format!(
|
||||||
|
"\nFailed {}: {error}",
|
||||||
|
count_noun(op.attempts.max(0) as usize, "time", "times")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(glib::markup_escape_text(&op.path).as_str())
|
||||||
|
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||||
|
.title_lines(1)
|
||||||
|
.subtitle_lines(3)
|
||||||
|
.tooltip_text(&op.path)
|
||||||
|
.build();
|
||||||
|
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||||
|
"dialog-warning-symbolic"
|
||||||
|
} else {
|
||||||
|
icon
|
||||||
|
});
|
||||||
|
if op.failing {
|
||||||
|
image.add_css_class("error");
|
||||||
|
}
|
||||||
|
row.add_prefix(&image);
|
||||||
|
let waiting = !op.parked && op.attempts > 0 && op.next_attempt_at.is_some_and(|at| at > now);
|
||||||
|
if waiting {
|
||||||
|
let retry = gtk4::Button::builder()
|
||||||
|
.icon_name("view-refresh-symbolic")
|
||||||
|
.tooltip_text("Retry now")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
retry.add_css_class("flat");
|
||||||
|
let ui = ui.clone();
|
||||||
|
let id = op.id;
|
||||||
|
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||||
|
row.add_suffix(&retry);
|
||||||
|
}
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
||||||
|
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
toast(&ui, "Retrying now…");
|
||||||
|
ui.locations.queue.painted.borrow_mut().clear();
|
||||||
|
refresh_queue(&ui);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't retry", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(&ui, "Couldn't retry", "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a conflict listing stays fresh on the refresh tick.
|
||||||
|
pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Poll the conflict list while the Sync page is on screen. `force` skips the
|
||||||
|
/// TTL, for navigation and right after a resolution.
|
||||||
|
pub(crate) fn refresh_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||||
|
let conflicts = &ui.locations.conflicts;
|
||||||
|
if conflicts.inflight.get()
|
||||||
|
|| (!force
|
||||||
|
&& conflicts
|
||||||
|
.fetched_at
|
||||||
|
.get()
|
||||||
|
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 conflicts = &ui.locations.conflicts;
|
||||||
|
conflicts.inflight.set(false);
|
||||||
|
conflicts.fetched_at.set(Some(Instant::now()));
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Conflicts { items })) => repaint_conflicts(&ui, &items),
|
||||||
|
// Same as the queue: an older daemon, or none, shows nothing.
|
||||||
|
_ => repaint_conflicts(&ui, &[]),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repaint_conflicts(ui: &Rc<Ui>, items: &[ConflictInfo]) {
|
||||||
|
let conflicts = &ui.locations.conflicts;
|
||||||
|
let key: Vec<ConflictKey> = items
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
(
|
||||||
|
c.path.clone(),
|
||||||
|
c.size,
|
||||||
|
c.modified,
|
||||||
|
c.original_size,
|
||||||
|
c.original_modified,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if *conflicts.painted.borrow() == key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*conflicts.painted.borrow_mut() = key;
|
||||||
|
|
||||||
|
for row in conflicts.rows.borrow_mut().drain(..) {
|
||||||
|
conflicts.group.remove(&row);
|
||||||
|
}
|
||||||
|
conflicts.group.set_visible(!items.is_empty());
|
||||||
|
let mut rows = conflicts.rows.borrow_mut();
|
||||||
|
for conflict in items {
|
||||||
|
let row = conflict_row(ui, conflict);
|
||||||
|
conflicts.group.add(&row);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "12.3 MB, changed at 14:30": one side of a conflict.
|
||||||
|
fn conflict_side(size: u64, modified: i64) -> String {
|
||||||
|
format!(
|
||||||
|
"{}, changed {}",
|
||||||
|
glib::format_size(size),
|
||||||
|
clock_time(modified)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
||||||
|
let verdict = if !conflict.original_exists {
|
||||||
|
"The original is gone; only this copy is left".to_string()
|
||||||
|
} else if conflict.identical {
|
||||||
|
"Same content as the original".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Differs from {}", file_name(&conflict.original_path))
|
||||||
|
};
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(glib::markup_escape_text(&conflict.path).as_str())
|
||||||
|
.subtitle(glib::markup_escape_text(&verdict).as_str())
|
||||||
|
.title_lines(1)
|
||||||
|
.tooltip_text(&conflict.path)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
let image = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||||
|
image.add_css_class("warning");
|
||||||
|
row.add_prefix(&image);
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
let ui = ui.clone();
|
||||||
|
let conflict = conflict.clone();
|
||||||
|
row.connect_activated(move |_| prompt_resolve_conflict(&ui, &conflict));
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last component of a mount-relative path.
|
||||||
|
pub(crate) fn file_name(path: &str) -> &str {
|
||||||
|
path.rsplit('/').next().unwrap_or(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask which version of a conflicted file to keep.
|
||||||
|
pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let copy_name = file_name(&conflict.path).to_string();
|
||||||
|
let original_name = file_name(&conflict.original_path).to_string();
|
||||||
|
let body = match (conflict.original_size, conflict.original_modified) {
|
||||||
|
(Some(size), Some(modified)) if conflict.original_exists => format!(
|
||||||
|
"“{original_name}” was changed here and elsewhere at the same time.\n\n\
|
||||||
|
Original: {}\nCopy: {}\n\n\
|
||||||
|
Whatever is not kept goes to Trash, where it can be restored.",
|
||||||
|
conflict_side(size, modified),
|
||||||
|
conflict_side(conflict.size, conflict.modified),
|
||||||
|
),
|
||||||
|
_ => format!(
|
||||||
|
"The original “{original_name}” no longer exists; only the copy is left \
|
||||||
|
({}).\n\nKeep the copy to give it the original name back.",
|
||||||
|
conflict_side(conflict.size, conflict.modified),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Resolve conflict")
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
let group = adw::PreferencesGroup::new();
|
||||||
|
let name_row = adw::EntryRow::builder()
|
||||||
|
.title("Name for the copy, if keeping both")
|
||||||
|
.build();
|
||||||
|
name_row.set_text(©_name);
|
||||||
|
group.add(&name_row);
|
||||||
|
dialog.set_extra_child(Some(&group));
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
if conflict.original_exists {
|
||||||
|
dialog.add_response("original", "Keep Original");
|
||||||
|
dialog.add_response("both", "Keep Both");
|
||||||
|
}
|
||||||
|
dialog.add_response("copy", "Keep Copy");
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
// Keeping both is a rename, so it needs a name that is neither side's.
|
||||||
|
let update_both = {
|
||||||
|
let dialog = dialog.clone();
|
||||||
|
let copy_name = copy_name.clone();
|
||||||
|
move |row: &adw::EntryRow| {
|
||||||
|
let name = row.text();
|
||||||
|
let name = name.trim();
|
||||||
|
dialog.set_response_enabled(
|
||||||
|
"both",
|
||||||
|
!name.is_empty()
|
||||||
|
&& name != copy_name
|
||||||
|
&& name != original_name
|
||||||
|
&& !name.contains('/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if conflict.original_exists {
|
||||||
|
update_both(&name_row);
|
||||||
|
name_row.connect_changed(update_both);
|
||||||
|
}
|
||||||
|
let ui = ui.clone();
|
||||||
|
let path = conflict.path.clone();
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
let keep = match resp {
|
||||||
|
"original" => ConflictKeep::Original,
|
||||||
|
"copy" => ConflictKeep::Copy,
|
||||||
|
"both" => ConflictKeep::Both {
|
||||||
|
name: name_row.text().trim().to_string(),
|
||||||
|
},
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
resolve_conflict(&ui, path.clone(), keep);
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ResolveConflict { path, keep },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { message })) => {
|
||||||
|
toast(&ui, &capitalize(&message));
|
||||||
|
refresh_conflicts(&ui, true);
|
||||||
|
refresh_activity_conflicts(&ui, true);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind)
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't resolve the conflict",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First letter upper-cased, for a daemon message used as a sentence.
|
||||||
|
pub(crate) fn capitalize(message: &str) -> String {
|
||||||
|
let mut chars = message.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the locations list.
|
/// Show a status page in place of the locations list.
|
||||||
@@ -327,8 +902,8 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The primary mount's only control: where it lives. Changing it rewrites config
|
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||||
/// and offers a service restart, which is why it stays a single prompt shared
|
/// and offers a service restart, which is why it goes through the shared
|
||||||
/// with the (now removed) Settings row.
|
/// [`prompt_mountpoint`] dialog.
|
||||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||||
let change = gtk4::Button::builder()
|
let change = gtk4::Button::builder()
|
||||||
.label("Change")
|
.label("Change")
|
||||||
@@ -341,10 +916,30 @@ fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
|||||||
row.add_suffix(&change);
|
row.add_suffix(&change);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
/// Pause or resume and Sync now (mirror only), the on-demand switch, and Stop
|
||||||
/// controls, and the same handlers, the Computers page used to carry.
|
/// syncing for one synced folder.
|
||||||
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||||
if spec.mode != MountMode::OnDemand {
|
if spec.mode != MountMode::OnDemand {
|
||||||
|
let (icon, tooltip) = match spec.paused {
|
||||||
|
true => (
|
||||||
|
"media-playback-start-symbolic",
|
||||||
|
"Resume syncing this folder",
|
||||||
|
),
|
||||||
|
false => ("media-playback-pause-symbolic", "Pause syncing this folder"),
|
||||||
|
};
|
||||||
|
let pause = gtk4::Button::builder()
|
||||||
|
.icon_name(icon)
|
||||||
|
.tooltip_text(tooltip)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
pause.add_css_class("flat");
|
||||||
|
let ui_pause = ui.clone();
|
||||||
|
let paused = spec.paused;
|
||||||
|
pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused));
|
||||||
|
row.add_suffix(&pause);
|
||||||
|
}
|
||||||
|
// A paused folder skips its passes, so Sync now would do nothing.
|
||||||
|
if spec.mode != MountMode::OnDemand && !spec.paused {
|
||||||
let sync_now = gtk4::Button::builder()
|
let sync_now = gtk4::Button::builder()
|
||||||
.icon_name("view-refresh-symbolic")
|
.icon_name("view-refresh-symbolic")
|
||||||
.tooltip_text("Sync this folder now")
|
.tooltip_text("Sync this folder now")
|
||||||
@@ -369,16 +964,27 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
|||||||
)
|
)
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.active(target == MountMode::OnDemand)
|
.active(target == MountMode::OnDemand)
|
||||||
|
// The daemon refuses a mode switch on a paused folder.
|
||||||
|
.sensitive(!spec.paused)
|
||||||
.build();
|
.build();
|
||||||
let ui_mode = ui.clone();
|
let ui_mode = ui.clone();
|
||||||
|
let mode_path = spec.local_path.clone();
|
||||||
ondemand.connect_state_set(move |_, on| {
|
ondemand.connect_state_set(move |_, on| {
|
||||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
if on {
|
||||||
glib::Propagation::Proceed
|
// Going on-demand deletes the local copies, so it is asked for rather
|
||||||
|
// than done on a flick of the switch. Stop leaves the switch's state
|
||||||
|
// alone; the next repaint paints whatever the daemon then reports.
|
||||||
|
confirm_ondemand(&ui_mode, id, &mode_path);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
set_sync_folder_mode(&ui_mode, id, "mirror");
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
});
|
});
|
||||||
row.add_suffix(&ondemand);
|
row.add_suffix(&ondemand);
|
||||||
|
|
||||||
let remove = gtk4::Button::builder()
|
let remove = gtk4::Button::builder()
|
||||||
.icon_name("user-trash-symbolic")
|
.icon_name("media-playback-stop-symbolic")
|
||||||
.tooltip_text("Stop syncing this folder")
|
.tooltip_text("Stop syncing this folder")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
@@ -392,6 +998,60 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
|||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before switching a synced folder to on-demand: the switch frees disk
|
||||||
|
/// space by removing the local copies, which is not something to do by accident.
|
||||||
|
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Make Folder On-Demand?")
|
||||||
|
.body(format!(
|
||||||
|
"Files in {path} will be removed from this computer and kept in Proton \
|
||||||
|
Drive only. Each file downloads again when you open it."
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("ondemand", "Make On-Demand");
|
||||||
|
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
let win = ui_window(ui);
|
||||||
|
let ui = ui.clone();
|
||||||
|
dialog.connect_response(None, move |_, response| {
|
||||||
|
if response == "ondemand" {
|
||||||
|
set_sync_folder_mode(&ui, id, "ondemand");
|
||||||
|
} else {
|
||||||
|
refresh_locations(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause or resume one synced folder, then repaint so its row shows the result.
|
||||||
|
fn set_folder_paused(ui: &Rc<Ui>, id: i64, paused: bool) {
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::SetSyncFolderPaused { id, paused },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let failed = match paused {
|
||||||
|
true => "Couldn't pause the folder",
|
||||||
|
false => "Couldn't resume the folder",
|
||||||
|
};
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(
|
||||||
|
&ui,
|
||||||
|
match paused {
|
||||||
|
true => "Folder paused",
|
||||||
|
false => "Folder resumed",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||||
|
_ => toast_error(&ui, failed, "The mount service didn't respond."),
|
||||||
|
}
|
||||||
|
refresh_locations(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon for an immediate pass over one folder.
|
/// Ask the daemon for an immediate pass over one folder.
|
||||||
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||||
@@ -439,6 +1099,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|||||||
if matches!(spec.kind, MountKind::Device { .. }) {
|
if matches!(spec.kind, MountKind::Device { .. }) {
|
||||||
parts.push(match &spec.progress {
|
parts.push(match &spec.progress {
|
||||||
Some(p) => sync_progress_label(p),
|
Some(p) => sync_progress_label(p),
|
||||||
|
None if spec.paused => "paused".to_string(),
|
||||||
None => sync_state_label(&spec.state).to_string(),
|
None => sync_state_label(&spec.state).to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -471,6 +1132,7 @@ mod tests {
|
|||||||
pending_mode: None,
|
pending_mode: None,
|
||||||
mounted,
|
mounted,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +1148,17 @@ mod tests {
|
|||||||
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_paused_folder_says_paused_instead_of_its_state() {
|
||||||
|
let mut spec = spec(
|
||||||
|
MountKind::Device { sync_folder_id: 7 },
|
||||||
|
MountMode::Mirror,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
spec.paused = true;
|
||||||
|
assert_eq!(location_subtitle(&spec), "Synced · paused");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_on_demand_folder_without_a_session_says_so() {
|
fn an_on_demand_folder_without_a_session_says_so() {
|
||||||
let spec = spec(
|
let spec = spec(
|
||||||
|
|||||||
@@ -6,22 +6,20 @@ pub(crate) struct LoginState {
|
|||||||
pub(crate) email: adw::EntryRow,
|
pub(crate) email: adw::EntryRow,
|
||||||
pub(crate) password: adw::PasswordEntryRow,
|
pub(crate) password: adw::PasswordEntryRow,
|
||||||
pub(crate) login_button: gtk4::Button,
|
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,
|
pub(crate) login_status: gtk4::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The login page: an email row, password row, a primary "Sign in" button and a
|
/// Where the login page's account links go.
|
||||||
/// status label, centred in a clamp. The 2FA code is prompted lazily in a
|
const SIGNUP_URL: &str = "https://account.proton.me/drive/signup";
|
||||||
/// dialog (see [`prompt_2fa`]) only when the account actually requires it.
|
const RESET_PASSWORD_URL: &str = "https://account.proton.me/reset-password";
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub(crate) fn build_login_page() -> (
|
/// The login page: an email row, password row, a primary "Sign in" button, a
|
||||||
gtk4::Widget,
|
/// 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`])
|
||||||
adw::EntryRow,
|
/// only when the account actually requires it.
|
||||||
adw::PasswordEntryRow,
|
pub(crate) fn build_login_page() -> (gtk4::Widget, LoginState) {
|
||||||
gtk4::Button,
|
|
||||||
gtk4::Label,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
let group = adw::PreferencesGroup::builder()
|
let group = adw::PreferencesGroup::builder()
|
||||||
.title("Sign in to Proton")
|
.title("Sign in to Proton")
|
||||||
.description("Use your Proton account to connect Drive.")
|
.description("Use your Proton account to connect Drive.")
|
||||||
@@ -32,8 +30,12 @@ pub(crate) fn build_login_page() -> (
|
|||||||
group.add(&email);
|
group.add(&email);
|
||||||
group.add(&password);
|
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("Sign in")));
|
||||||
let login_button = gtk4::Button::builder()
|
let login_button = gtk4::Button::builder()
|
||||||
.label("Sign in")
|
.child(&button_content)
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
login_button.add_css_class("suggested-action");
|
login_button.add_css_class("suggested-action");
|
||||||
@@ -64,6 +66,14 @@ pub(crate) fn build_login_page() -> (
|
|||||||
inner.append(&group);
|
inner.append(&group);
|
||||||
inner.append(&login_button);
|
inner.append(&login_button);
|
||||||
inner.append(&login_status);
|
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, "Create account"));
|
||||||
|
links.append(>k4::LinkButton::with_label(
|
||||||
|
RESET_PASSWORD_URL,
|
||||||
|
"Forgot password?",
|
||||||
|
));
|
||||||
|
inner.append(&links);
|
||||||
|
|
||||||
let clamp = adw::Clamp::builder()
|
let clamp = adw::Clamp::builder()
|
||||||
.maximum_size(420)
|
.maximum_size(420)
|
||||||
@@ -73,7 +83,13 @@ pub(crate) fn build_login_page() -> (
|
|||||||
|
|
||||||
(
|
(
|
||||||
scroll.upcast(),
|
scroll.upcast(),
|
||||||
(email, password, login_button, login_status),
|
LoginState {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
login_button,
|
||||||
|
login_spinner,
|
||||||
|
login_status,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +119,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.login.login_button.set_sensitive(false);
|
set_signing_in(&ui, true);
|
||||||
ui.login.login_status.set_text("Signing in…");
|
ui.login.login_status.set_text("Signing in…");
|
||||||
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password);
|
||||||
|
|
||||||
@@ -140,7 +156,7 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
.recv()
|
.recv()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| Err("login cancelled".into()));
|
.unwrap_or_else(|_| Err("login cancelled".into()));
|
||||||
ui.login.login_button.set_sensitive(true);
|
set_signing_in(&ui, false);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui.login.login_status.set_text("");
|
ui.login.login_status.set_text("");
|
||||||
@@ -151,15 +167,53 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
|||||||
service::enable_start();
|
service::enable_start();
|
||||||
refresh(&ui);
|
refresh(&ui);
|
||||||
}
|
}
|
||||||
Err(e) => ui
|
Err(e) => ui.login.login_status.set_text(&e),
|
||||||
.login
|
|
||||||
.login_status
|
|
||||||
.set_text(&format!("Sign-in failed: {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 => {
|
||||||
|
"Too many sign-in attempts. Wait a few minutes and try again.".to_string()
|
||||||
|
}
|
||||||
|
pdfs_core::Error::Proton(ProtonError::Api(api)) if !api.message.is_empty() => {
|
||||||
|
api.message.clone()
|
||||||
|
}
|
||||||
|
pdfs_core::Error::Proton(ProtonError::Transport(_)) => {
|
||||||
|
"Couldn't reach Proton. Check your internet connection and try again.".to_string()
|
||||||
|
}
|
||||||
|
pdfs_core::Error::Keyring(_) => {
|
||||||
|
"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."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
pdfs_core::Error::Other(message) if message.contains("two-factor") => {
|
||||||
|
"Sign-in cancelled: no two-factor code was entered.".to_string()
|
||||||
|
}
|
||||||
|
pdfs_core::Error::Other(message) if message.contains("verification") => {
|
||||||
|
"Sign-in cancelled: the verification wasn't completed. Sign in again to get a new \
|
||||||
|
one."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
other => format!("Sign-in failed: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio
|
/// 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;
|
/// 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
|
/// the second fires *only if* the account needs a 2FA code, carrying a
|
||||||
@@ -223,7 +277,7 @@ pub(crate) fn spawn_login(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| login_error_message(&e))
|
||||||
});
|
});
|
||||||
let _ = tx.send_blocking(result);
|
let _ = tx.send_blocking(result);
|
||||||
});
|
});
|
||||||
@@ -236,7 +290,7 @@ pub(crate) fn spawn_login(
|
|||||||
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>) {
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Two-factor authentication")
|
.heading("Two-factor authentication")
|
||||||
.body("Enter the code from your authenticator app.")
|
.body("Enter the code from your authenticator app, or one of your recovery codes.")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
let group = adw::PreferencesGroup::new();
|
||||||
@@ -268,17 +322,44 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
|||||||
dialog.present(parent.as_ref());
|
dialog.present(parent.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||||
/// isn't left running without credentials), forget the stored session, and drop
|
/// daemon isn't left running without credentials), forget the stored session,
|
||||||
/// back to the login page.
|
/// and drop back to the login page.
|
||||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
pub(crate) fn sign_out(ui: &Rc<Ui>) {
|
||||||
|
let Some(window) = ui_window(ui) else { return };
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
button.connect_clicked(move |_| {
|
confirm_destructive(
|
||||||
service::disable_stop();
|
&window,
|
||||||
if let Err(e) = auth::logout() {
|
"Sign Out?",
|
||||||
tracing::error!("logout failed: {e}");
|
"Proton Drive will disconnect and stop syncing until you sign in again. \
|
||||||
}
|
Files already on this computer stay where they are.",
|
||||||
*ui.session.borrow_mut() = None;
|
"Sign Out",
|
||||||
refresh(&ui);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,5 +9,6 @@ pub(crate) mod photos;
|
|||||||
pub(crate) mod shared;
|
pub(crate) mod shared;
|
||||||
pub(crate) mod shared_by_me;
|
pub(crate) mod shared_by_me;
|
||||||
pub(crate) mod status;
|
pub(crate) mod status;
|
||||||
|
pub(crate) mod takeout;
|
||||||
pub(crate) mod trash;
|
pub(crate) mod trash;
|
||||||
pub(crate) mod verify;
|
pub(crate) mod verify;
|
||||||
File diff suppressed because it is too large.
Load diff
+1479
-286
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,11 @@ pub(crate) struct SharedState {
|
|||||||
pub(crate) with_me_group: adw::PreferencesGroup,
|
pub(crate) with_me_group: adw::PreferencesGroup,
|
||||||
pub(crate) invitations_group: adw::PreferencesGroup,
|
pub(crate) invitations_group: adw::PreferencesGroup,
|
||||||
pub(crate) bookmarks_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
|
/// 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
|
/// 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.
|
/// 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) retry: gtk4::Button,
|
||||||
pub(crate) add_bookmark: gtk4::Button,
|
pub(crate) add_bookmark: gtk4::Button,
|
||||||
pub(crate) refresh: 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
|
/// The Shared page: three stacked sections — items shared *with* me (each with a
|
||||||
@@ -39,38 +46,30 @@ pub(crate) struct SharedWidgets {
|
|||||||
/// links I've bookmarked (open / remove). All three live outside the mount tree,
|
/// 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.
|
/// 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) {
|
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()
|
let add_bookmark = gtk4::Button::builder()
|
||||||
.label("Add Bookmark")
|
.icon_name("bookmark-new-symbolic")
|
||||||
.valign(gtk4::Align::Center)
|
.tooltip_text("Add Bookmark — save a public link to open it from here")
|
||||||
.build();
|
.build();
|
||||||
add_bookmark.add_css_class("flat");
|
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
// Only sections with something in them are shown, so an account with one
|
||||||
header.append(&titles);
|
// share reads as one list rather than three boxes, two of them saying
|
||||||
header.append(&refresh);
|
// "nothing here". Invitations come first: they are the one thing on the
|
||||||
header.append(&add_bookmark);
|
// page waiting for a decision.
|
||||||
|
|
||||||
let shared_with_me = adw::PreferencesGroup::builder()
|
|
||||||
.title("Shared with me")
|
|
||||||
.build();
|
|
||||||
let invitations = adw::PreferencesGroup::builder()
|
let invitations = adw::PreferencesGroup::builder()
|
||||||
.title("Invitations")
|
.title("Invitations")
|
||||||
|
.description("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("Bookmarks")
|
||||||
|
.visible(false)
|
||||||
.build();
|
.build();
|
||||||
let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build();
|
|
||||||
|
|
||||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||||
groups.append(&shared_with_me);
|
|
||||||
groups.append(&invitations);
|
groups.append(&invitations);
|
||||||
|
groups.append(&shared_with_me);
|
||||||
groups.append(&bookmarks);
|
groups.append(&bookmarks);
|
||||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
@@ -99,15 +98,24 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
|||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(18);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(18);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(18);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(18);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
let back = gtk4::Button::builder()
|
||||||
|
.icon_name("go-previous-symbolic")
|
||||||
|
.tooltip_text("Back")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let (frame, header, title) = page_frame("Shared with Me", &inner);
|
||||||
|
header.pack_start(&back);
|
||||||
|
header.pack_end(&refresh);
|
||||||
|
header.pack_end(&add_bookmark);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
SharedWidgets {
|
SharedWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
@@ -117,12 +125,19 @@ pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) {
|
|||||||
retry,
|
retry,
|
||||||
add_bookmark,
|
add_bookmark,
|
||||||
refresh,
|
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) {
|
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();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
@@ -151,6 +166,7 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
|||||||
if ui.shared.inflight.get() {
|
if ui.shared.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
let current = ui.shared.nav.borrow().last().cloned();
|
let current = ui.shared.nav.borrow().last().cloned();
|
||||||
if let Some((uid, _)) = current {
|
if let Some((uid, _)) = current {
|
||||||
load_shared_folder(ui, uid);
|
load_shared_folder(ui, uid);
|
||||||
@@ -244,8 +260,26 @@ 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
|
/// The header subtitle inside a shared folder: the folders from the share root
|
||||||
/// go back up, then the children. Invitations and bookmarks are hidden here —
|
/// 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 {
|
||||||
|
format!("Invitations ({count})")
|
||||||
|
} else {
|
||||||
|
"Invitations".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// they are top-level things, not contents of this folder.
|
||||||
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||||
@@ -254,28 +288,15 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
ui.shared.content.set_visible_child_name("list");
|
ui.shared.content.set_visible_child_name("list");
|
||||||
ui.shared.invitations_group.set_visible(false);
|
ui.shared.invitations_group.set_visible(false);
|
||||||
ui.shared.bookmarks_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 nav = ui.shared.nav.borrow().clone();
|
||||||
let title = nav
|
ui.shared.title.set_subtitle(&shared_path(&nav));
|
||||||
.iter()
|
ui.shared.with_me_group.set_title("");
|
||||||
.map(|(_, name)| name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" / ");
|
|
||||||
ui.shared.with_me_group.set_title(&title);
|
|
||||||
|
|
||||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
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() {
|
if entries.is_empty() {
|
||||||
let row = dim_row("This folder is empty.");
|
let row = dim_row("This folder is empty.");
|
||||||
@@ -283,7 +304,7 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||||
} else {
|
} else {
|
||||||
for entry in entries {
|
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);
|
ui.shared.with_me_group.add(&row);
|
||||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
||||||
}
|
}
|
||||||
@@ -293,7 +314,10 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
|
|
||||||
/// A row for one node shared with me: folders descend into, files download and
|
/// A row for one node shared with me: folders descend into, files download and
|
||||||
/// open with the user's default application.
|
/// 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()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&entry.name)
|
.title(&entry.name)
|
||||||
.activatable(true)
|
.activatable(true)
|
||||||
@@ -305,14 +329,25 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
// What I am allowed to do with someone else's file is the thing this page
|
// 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
|
// cannot leave implicit: a viewer share is browsable and unwritable, and
|
||||||
// that is only visible if it is said.
|
// that is only visible if it is said.
|
||||||
if let Some(badge) = role_badge(&entry.role) {
|
if let Some(pill) = role_pill(&entry.role) {
|
||||||
row.add_suffix(&badge);
|
row.add_suffix(&pill);
|
||||||
|
}
|
||||||
|
// The "shared by" in the subtitle is what the invitation claims. When its
|
||||||
|
// signature does not check out against that person's keys, the claim must
|
||||||
|
// not read as fact — someone may be impersonating a contact to get a file
|
||||||
|
// opened.
|
||||||
|
if entry.shared_by_unverified {
|
||||||
|
let warning = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||||
|
warning.add_css_class("warning");
|
||||||
|
warning.set_valign(gtk4::Align::Center);
|
||||||
|
warning.set_tooltip_text(Some(UNVERIFIED_INVITER_TOOLTIP));
|
||||||
|
row.add_suffix(&warning);
|
||||||
|
}
|
||||||
|
row.add_prefix(&file_thumbnail(ui, entry, 40, 24, true));
|
||||||
|
row.add_suffix(&shared_entry_menu(ui, entry, leavable));
|
||||||
|
if entry.is_dir {
|
||||||
|
row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
||||||
}
|
}
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir {
|
|
||||||
"folder-symbolic"
|
|
||||||
} else {
|
|
||||||
"text-x-generic-symbolic"
|
|
||||||
}));
|
|
||||||
let ui_act = ui.clone();
|
let ui_act = ui.clone();
|
||||||
let uid = entry.uid.clone();
|
let uid = entry.uid.clone();
|
||||||
let name = entry.name.clone();
|
let name = entry.name.clone();
|
||||||
@@ -332,18 +367,69 @@ fn shared_entry_row(ui: &Rc<Ui>, entry: &DirEntry) -> adw::ActionRow {
|
|||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A shared row's subtitle: its size (files only), and where it can be reached
|
/// A shared row's ⋮ menu: open it, find it in My Files when the mount has
|
||||||
/// in the local mount once the synthetic `Shared with me/` directory has interned
|
/// interned it, and leave it (share roots only).
|
||||||
/// it. An unlisted share has no local path yet, and says nothing rather than
|
fn shared_entry_menu(ui: &Rc<Ui>, entry: &DirEntry, leavable: bool) -> gtk4::MenuButton {
|
||||||
/// pointing at a directory that is not there.
|
let mut items: Vec<(&str, MenuAction)> = Vec::new();
|
||||||
|
let (ui_open, uid, name, is_dir) = (
|
||||||
|
ui.clone(),
|
||||||
|
entry.uid.clone(),
|
||||||
|
entry.name.clone(),
|
||||||
|
entry.is_dir,
|
||||||
|
);
|
||||||
|
items.push((
|
||||||
|
"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((
|
||||||
|
"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((
|
||||||
|
"Leave…",
|
||||||
|
Box::new(move || prompt_leave_shared(&ui_leave, &uid, &name)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let button = more_menu_button(items);
|
||||||
|
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.";
|
||||||
|
|
||||||
|
/// A shared row's subtitle: who shared it and when (share roots only — that is
|
||||||
|
/// where the invitation lives) and its size (files only). Where the share sits
|
||||||
|
/// in the mount is one menu item away ("Show in My Files"), not spelled out on
|
||||||
|
/// every row, where the long path wrapped the subtitle onto a second line.
|
||||||
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
pub(crate) fn shared_row_subtitle(entry: &DirEntry) -> String {
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
if !entry.shared_by.is_empty() {
|
||||||
|
parts.push(entry.shared_by.clone());
|
||||||
|
if entry.shared_at > 0 {
|
||||||
|
parts.push(format_modified(entry.shared_at));
|
||||||
|
}
|
||||||
|
}
|
||||||
if !entry.is_dir {
|
if !entry.is_dir {
|
||||||
parts.push(human_bytes(entry.size));
|
parts.push(human_bytes(entry.size));
|
||||||
}
|
}
|
||||||
if !entry.path.is_empty() {
|
|
||||||
parts.push(format!("in {}", entry.path));
|
|
||||||
}
|
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,13 +446,25 @@ pub(crate) fn role_label(role: &str) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A dim role pill for a share I did not create.
|
/// What a role lets me do, as a short phrase for the row's pill. A bare
|
||||||
pub(crate) fn role_badge(role: &str) -> Option<gtk4::Label> {
|
/// "Editor" beside a Leave button read like one more button.
|
||||||
let badge = gtk4::Label::new(Some(role_label(role)?));
|
pub(crate) fn role_access(role: &str) -> Option<&'static str> {
|
||||||
badge.add_css_class("dim-label");
|
match role {
|
||||||
badge.add_css_class("caption");
|
"viewer" => Some("Can view"),
|
||||||
badge.set_valign(gtk4::Align::Center);
|
"editor" => Some("Can edit"),
|
||||||
Some(badge)
|
"admin" => Some("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));
|
||||||
|
Some(pill)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file shared with me into the daemon's cache and hand it to the
|
/// Download a file shared with me into the daemon's cache and hand it to the
|
||||||
@@ -449,119 +547,127 @@ pub(crate) fn repaint_shared(
|
|||||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||||
group.remove(&row);
|
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",
|
||||||
|
"Nothing Shared with You",
|
||||||
|
"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.content.set_visible_child_name("list");
|
||||||
ui.shared.invitations_group.set_visible(true);
|
ui.shared
|
||||||
ui.shared.bookmarks_group.set_visible(true);
|
.invitations_group
|
||||||
ui.shared.with_me_group.set_title("Shared with me");
|
.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() {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"Shared with you"
|
||||||
|
});
|
||||||
|
ui.shared
|
||||||
|
.invitations_group
|
||||||
|
.set_title(&invitations_title(invitations.len()));
|
||||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||||
|
|
||||||
// Shared with me: name + Leave.
|
for entry in shared {
|
||||||
if shared.is_empty() {
|
let row = shared_entry_row(ui, entry, true);
|
||||||
let row = dim_row("Nothing is shared with you.");
|
|
||||||
ui.shared.with_me_group.add(&row);
|
ui.shared.with_me_group.add(&row);
|
||||||
rows.push((ui.shared.with_me_group.clone(), row.upcast()));
|
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.
|
// Invitations: inviter + item, Accept / Reject.
|
||||||
if invitations.is_empty() {
|
for inv in invitations {
|
||||||
let row = dim_row("No pending 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");
|
||||||
|
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(|| "this item".into());
|
||||||
|
reject.connect_clicked(move |btn| {
|
||||||
|
let ui = ui_rej.clone();
|
||||||
|
let id = id_rej.clone();
|
||||||
|
confirm_destructive(
|
||||||
|
btn,
|
||||||
|
"Reject Invitation?",
|
||||||
|
&format!("You won't have access to {name_rej} unless it is shared again."),
|
||||||
|
"Reject",
|
||||||
|
move || respond_invitation(&ui, &id, false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
row.add_suffix(&accept);
|
||||||
|
row.add_suffix(&reject);
|
||||||
ui.shared.invitations_group.add(&row);
|
ui.shared.invitations_group.add(&row);
|
||||||
rows.push((ui.shared.invitations_group.clone(), row.upcast()));
|
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.
|
// Bookmarks: name/URL, Open / Remove.
|
||||||
if bookmarks.is_empty() {
|
for bm in bookmarks {
|
||||||
let row = dim_row("No saved 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);
|
ui.shared.bookmarks_group.add(&row);
|
||||||
rows.push((ui.shared.bookmarks_group.clone(), row.upcast()));
|
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;
|
*ui.shared.rows.borrow_mut() = rows;
|
||||||
@@ -726,9 +832,22 @@ mod tests {
|
|||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
path: path.into(),
|
path: path.into(),
|
||||||
role: role.into(),
|
role: role.into(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_root_says_who_shared_it() {
|
||||||
|
let mut root = entry(true, "", "viewer");
|
||||||
|
root.shared_by = "alice@proton.me".into();
|
||||||
|
assert_eq!(shared_row_subtitle(&root), "alice@proton.me");
|
||||||
|
root.shared_at = 1_700_000_000;
|
||||||
|
let subtitle = shared_row_subtitle(&root);
|
||||||
|
assert!(subtitle.starts_with("alice@proton.me · "), "{subtitle}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_share_with_no_local_path_says_nothing_about_one() {
|
fn a_share_with_no_local_path_says_nothing_about_one() {
|
||||||
// Before the synthetic directory has been listed, the daemon cannot
|
// Before the synthetic directory has been listed, the daemon cannot
|
||||||
@@ -742,9 +861,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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"));
|
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"), Some("Can view"));
|
||||||
|
assert_eq!(role_access("editor"), Some("Can edit"));
|
||||||
|
assert_eq!(role_access("admin"), 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]
|
#[test]
|
||||||
@@ -758,4 +891,14 @@ mod tests {
|
|||||||
assert!(role_label("").is_none());
|
assert!(role_label("").is_none());
|
||||||
assert!(role_label("inherited").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 —
|
/// 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)
|
/// each row summarizing who has access, opening the item when activated, with
|
||||||
/// and a Manage shortcut into the per-node Share dialog.
|
/// 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) {
|
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 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 group = adw::PreferencesGroup::new();
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
let clamp = adw::Clamp::builder().child(&group).build();
|
||||||
@@ -65,15 +55,16 @@ pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) {
|
|||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(18);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(18);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(18);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(18);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
let (frame, header, _) = page_frame("Shared by Me", &inner);
|
||||||
|
header.pack_end(&refresh);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
SharedByMeWidgets {
|
SharedByMeWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
@@ -113,6 +104,7 @@ pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
|||||||
if ui.shared_by_me.inflight.get() {
|
if ui.shared_by_me.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
ui.shared_by_me.inflight.set(true);
|
ui.shared_by_me.inflight.set(true);
|
||||||
shared_by_me_status(
|
shared_by_me_status(
|
||||||
ui,
|
ui,
|
||||||
@@ -200,18 +192,24 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
ui.shared_by_me.content.set_visible_child_name("list");
|
ui.shared_by_me.content.set_visible_child_name("list");
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
||||||
for item in items {
|
for item in items {
|
||||||
|
let entry = shared_item_as_entry(item);
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&item.name)
|
.title(&item.name)
|
||||||
.subtitle(shared_item_summary(item))
|
.subtitle(shared_item_summary(item))
|
||||||
.build();
|
.build();
|
||||||
row.add_prefix(>k4::Image::from_icon_name(if item.is_dir {
|
row.add_prefix(&file_thumbnail(ui, &entry, 40, 24, true));
|
||||||
"folder-symbolic"
|
|
||||||
} else {
|
|
||||||
"text-x-generic-symbolic"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Copy / open the public link, when there is one with a recovered URL.
|
// A node the daemon can place in my tree opens like it would in My Files.
|
||||||
if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) {
|
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()
|
let copy = gtk4::Button::builder()
|
||||||
.icon_name("edit-copy-symbolic")
|
.icon_name("edit-copy-symbolic")
|
||||||
.tooltip_text("Copy link")
|
.tooltip_text("Copy link")
|
||||||
@@ -219,35 +217,13 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
.build();
|
.build();
|
||||||
copy.add_css_class("flat");
|
copy.add_css_class("flat");
|
||||||
let ui_copy = ui.clone();
|
let ui_copy = ui.clone();
|
||||||
let url_copy = url.clone();
|
|
||||||
copy.connect_clicked(move |btn| {
|
copy.connect_clicked(move |btn| {
|
||||||
btn.clipboard().set_text(&url_copy);
|
btn.clipboard().set_text(&url);
|
||||||
toast(&ui_copy, "Link copied");
|
toast(&ui_copy, "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(©);
|
||||||
row.add_suffix(&open);
|
|
||||||
}
|
}
|
||||||
|
row.add_suffix(&shared_by_me_menu(ui, &entry, url));
|
||||||
// Manage opens the per-node Share dialog. It used to be offered only for
|
|
||||||
// an item whose path the daemon could resolve; the dialog now addresses a
|
|
||||||
// pathless node by uid, so every shared item can be managed from here.
|
|
||||||
let manage = gtk4::Button::builder()
|
|
||||||
.label("Manage")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
manage.add_css_class("flat");
|
|
||||||
let ui_manage = ui.clone();
|
|
||||||
let entry = shared_item_as_entry(item);
|
|
||||||
manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry));
|
|
||||||
row.add_suffix(&manage);
|
|
||||||
|
|
||||||
ui.shared_by_me.group.add(&row);
|
ui.shared_by_me.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
@@ -255,6 +231,90 @@ pub(crate) fn repaint_shared_by_me(ui: &Rc<Ui>, items: &[SharedItem]) {
|
|||||||
*ui.shared_by_me.rows.borrow_mut() = rows;
|
*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<(&str, MenuAction)> = Vec::new();
|
||||||
|
if !entry.path.is_empty() {
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
items.push(("Open", Box::new(move || open_shared_by_me(&ui_c, &entry_c))));
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
items.push((
|
||||||
|
"Show in My Files",
|
||||||
|
Box::new(move || show_in_my_files(&ui_c, &entry_c)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(url) = url {
|
||||||
|
items.push(("Open Link", Box::new(move || open_uri(&url))));
|
||||||
|
}
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
items.push((
|
||||||
|
"Manage Access…",
|
||||||
|
Box::new(move || open_share_dialog(&ui_c, &entry_c)),
|
||||||
|
));
|
||||||
|
let (ui_c, entry_c) = (ui.clone(), entry.clone());
|
||||||
|
items.push((
|
||||||
|
"Stop Sharing…",
|
||||||
|
Box::new(move || prompt_stop_sharing(&ui_c, &entry_c)),
|
||||||
|
));
|
||||||
|
more_menu_button(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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("Stop sharing?")
|
||||||
|
.body(format!(
|
||||||
|
"Everyone you invited loses access to “{}”, pending invitations are \
|
||||||
|
withdrawn and its public link stops working.",
|
||||||
|
entry.name
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("stop", "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() },
|
||||||
|
format!("Stopped sharing {name}"),
|
||||||
|
"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.
|
/// A one-line summary of who can reach a shared item, for its row subtitle.
|
||||||
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
pub(crate) fn shared_item_summary(item: &SharedItem) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
@@ -289,12 +349,15 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
|
|||||||
name: item.name.clone(),
|
name: item.name.clone(),
|
||||||
is_dir: item.is_dir,
|
is_dir: item.is_dir,
|
||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: item.modified,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
cached: false,
|
cached: false,
|
||||||
uid: item.uid.clone(),
|
uid: item.uid.clone(),
|
||||||
path: item.path.clone(),
|
path: item.path.clone(),
|
||||||
// Shared *by* me: I own it, so there is no role of mine to report.
|
// Shared *by* me: I own it, so there is no role of mine to report.
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
shared_by: String::new(),
|
||||||
|
shared_at: 0,
|
||||||
|
shared_by_unverified: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,772 @@
|
|||||||
|
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||||
|
//! Photos.
|
||||||
|
//!
|
||||||
|
//! 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,
|
||||||
|
//! so its progress needs somewhere to live that the user can navigate away from
|
||||||
|
//! and come back to.
|
||||||
|
//!
|
||||||
|
//! Archives arrive either by drag-and-drop onto the page or through the file
|
||||||
|
//! chooser; both funnel into [`add_archives`], which is where the `.zip` filter
|
||||||
|
//! and de-duplication live. Nothing is sent until the user presses Scan or
|
||||||
|
//! Import — staging is local, so a mis-drop costs nothing.
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! 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 status tick is
|
||||||
|
/// already polling instead of adding a second poll of its own.
|
||||||
|
const IMPORT_JOB: &str = "Importing Google Photos";
|
||||||
|
|
||||||
|
/// What the archive list says before anything is staged. `repaint_archives`
|
||||||
|
/// swaps in the count and total once there is something to report, and swaps
|
||||||
|
/// this back when the list is emptied again.
|
||||||
|
/// Where the staged set is remembered between runs, under the app's state
|
||||||
|
/// directory. Staging an export means picking dozens of files out of a file
|
||||||
|
/// chooser; losing that to a window close (or a crash mid-import) and having to
|
||||||
|
/// redo it is the kind of small cruelty that makes a migration not get finished.
|
||||||
|
fn staged_path(ui: &Rc<Ui>) -> PathBuf {
|
||||||
|
ui.dirs.state_dir().join("takeout-staged.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the remembered set, dropping any archive that has since been moved or
|
||||||
|
/// deleted — a stale path would only fail at launch, and silently is worse.
|
||||||
|
fn load_staged(ui: &Rc<Ui>) -> Vec<PathBuf> {
|
||||||
|
let Ok(text) = std::fs::read_to_string(staged_path(ui)) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let paths: Vec<String> = serde_json::from_str(&text).unwrap_or_default();
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember the staged set. Best-effort: failing to write it costs the user a
|
||||||
|
/// re-pick next time, which is not worth interrupting them over now.
|
||||||
|
fn save_staged(ui: &Rc<Ui>) {
|
||||||
|
let paths: Vec<String> = ui
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect();
|
||||||
|
let path = staged_path(ui);
|
||||||
|
let written = serde_json::to_string(&paths)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|text| std::fs::write(&path, text).map_err(|e| e.to_string()));
|
||||||
|
if let Err(e) = written {
|
||||||
|
tracing::warn!("cannot remember staged Takeout archives: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
pub(crate) struct TakeoutState {
|
||||||
|
/// Archives staged for this import, in the order they were added. Sent as
|
||||||
|
/// one set: Google splits an export across numbered zips and routinely puts
|
||||||
|
/// a photo in one part and its metadata sidecar in another, so a per-file
|
||||||
|
/// import would lose album titles and capture times.
|
||||||
|
pub(crate) archives: RefCell<Vec<PathBuf>>,
|
||||||
|
/// The staged-archive list and the rows currently rendered in it.
|
||||||
|
pub(crate) list_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) rows: RefCell<Vec<adw::PreferencesRow>>,
|
||||||
|
/// The drop zone, whose `dropzone-active` class follows the drag.
|
||||||
|
pub(crate) dropzone: gtk4::Box,
|
||||||
|
pub(crate) scan_button: gtk4::Button,
|
||||||
|
pub(crate) import_button: gtk4::Button,
|
||||||
|
pub(crate) cancel_button: gtk4::Button,
|
||||||
|
pub(crate) clear_button: gtk4::Button,
|
||||||
|
/// Progress group, hidden unless an import is running.
|
||||||
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) progress_label: gtk4::Label,
|
||||||
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
|
/// Report group for the last finished run, hidden until there is one.
|
||||||
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) summary_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
/// One [`Request::ImportStatus`] in flight at a time, so a wedged daemon
|
||||||
|
/// can't stack worker threads on the 2s tick.
|
||||||
|
pub(crate) inflight: Cell<bool>,
|
||||||
|
/// Whether the daemon reported an import running on the last poll.
|
||||||
|
pub(crate) running: Cell<bool>,
|
||||||
|
/// Whether the run in flight was launched as a dry run, so the report can
|
||||||
|
/// say "would upload" rather than claiming photos were sent. The daemon's
|
||||||
|
/// summary carries no such flag — it is the same shape either way.
|
||||||
|
pub(crate) dry_run: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widgets the Import page hands back for wiring.
|
||||||
|
pub(crate) struct TakeoutWidgets {
|
||||||
|
pub(crate) list_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) dropzone: gtk4::Box,
|
||||||
|
/// The whole page, which is the drop target: a drag released anywhere on it
|
||||||
|
/// counts, not only inside the dashed rectangle.
|
||||||
|
pub(crate) page: gtk4::Widget,
|
||||||
|
pub(crate) choose_button: gtk4::Button,
|
||||||
|
pub(crate) scan_button: gtk4::Button,
|
||||||
|
pub(crate) import_button: gtk4::Button,
|
||||||
|
pub(crate) cancel_button: gtk4::Button,
|
||||||
|
pub(crate) clear_button: gtk4::Button,
|
||||||
|
pub(crate) back_button: gtk4::Button,
|
||||||
|
pub(crate) progress_group: adw::PreferencesGroup,
|
||||||
|
pub(crate) progress_label: gtk4::Label,
|
||||||
|
pub(crate) progress_bar: gtk4::ProgressBar,
|
||||||
|
pub(crate) summary_group: adw::PreferencesGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
|
// 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 Photos")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
back_button.add_css_class("flat");
|
||||||
|
|
||||||
|
let subtitle = gtk4::Label::builder()
|
||||||
|
.label("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 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
|
||||||
|
// whole page so a drag released just outside the rectangle still lands.
|
||||||
|
let drop_icon = gtk4::Image::from_icon_name("folder-download-symbolic");
|
||||||
|
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")
|
||||||
|
.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.")
|
||||||
|
.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…")
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
choose_button.add_css_class("pill");
|
||||||
|
|
||||||
|
let dropzone = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
|
||||||
|
dropzone.set_halign(gtk4::Align::Fill);
|
||||||
|
dropzone.add_css_class("dropzone");
|
||||||
|
dropzone.append(&drop_icon);
|
||||||
|
dropzone.append(&drop_title);
|
||||||
|
dropzone.append(&drop_hint);
|
||||||
|
dropzone.append(&choose_button);
|
||||||
|
|
||||||
|
// Staged archives. Filled by `repaint_archives`, which owns the description
|
||||||
|
// too (it becomes the count + total once there is something to count); the
|
||||||
|
// 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)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let clear_button = gtk4::Button::builder()
|
||||||
|
.label("Clear")
|
||||||
|
.tooltip_text("Remove every staged archive")
|
||||||
|
.build();
|
||||||
|
clear_button.add_css_class("flat");
|
||||||
|
|
||||||
|
// Actions. Scan is a dry run: it reads the archives' directories and reports
|
||||||
|
// 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")
|
||||||
|
.build();
|
||||||
|
scan_button.add_css_class("pill");
|
||||||
|
let import_button = gtk4::Button::builder().label("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")
|
||||||
|
.build();
|
||||||
|
cancel_button.add_css_class("pill");
|
||||||
|
cancel_button.add_css_class("destructive-action");
|
||||||
|
cancel_button.set_visible(false);
|
||||||
|
|
||||||
|
// Clear sits apart from the launch buttons, on the other end of the row: it
|
||||||
|
// undoes staging, and putting it next to Import invites the wrong click.
|
||||||
|
let actions = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||||
|
let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
spacer.set_hexpand(true);
|
||||||
|
actions.append(&clear_button);
|
||||||
|
actions.append(&spacer);
|
||||||
|
actions.append(&cancel_button);
|
||||||
|
actions.append(&scan_button);
|
||||||
|
actions.append(&import_button);
|
||||||
|
|
||||||
|
// Progress: the import's job from the transfer snapshot, so this page shows
|
||||||
|
// the same numbers the Sync page's Transfers list does.
|
||||||
|
let progress_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Import in progress")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let progress_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||||
|
progress_box.set_margin_top(8);
|
||||||
|
progress_box.set_margin_bottom(8);
|
||||||
|
let progress_label = gtk4::Label::builder().halign(gtk4::Align::Start).build();
|
||||||
|
progress_label.add_css_class("dim-label");
|
||||||
|
let progress_bar = gtk4::ProgressBar::new();
|
||||||
|
progress_box.append(&progress_label);
|
||||||
|
progress_box.append(&progress_bar);
|
||||||
|
progress_group.add(
|
||||||
|
&adw::PreferencesRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.child(&progress_box)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Report for the last finished run, kept on screen after it ends: an import
|
||||||
|
// 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")
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||||
|
groups.append(&dropzone);
|
||||||
|
groups.append(&list_group);
|
||||||
|
groups.append(&actions);
|
||||||
|
groups.append(&progress_group);
|
||||||
|
groups.append(&summary_group);
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(640)
|
||||||
|
.child(&groups)
|
||||||
|
.build();
|
||||||
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let page = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
page.set_margin_top(18);
|
||||||
|
page.set_margin_bottom(18);
|
||||||
|
page.set_margin_start(18);
|
||||||
|
page.set_margin_end(18);
|
||||||
|
page.append(&subtitle_clamp);
|
||||||
|
page.append(&scroll);
|
||||||
|
let (frame, header, _) = page_frame("Import from Google Photos", &page);
|
||||||
|
header.pack_start(&back_button);
|
||||||
|
|
||||||
|
let widgets = TakeoutWidgets {
|
||||||
|
list_group: list_group.clone(),
|
||||||
|
dropzone: dropzone.clone(),
|
||||||
|
page: page.clone().upcast(),
|
||||||
|
choose_button,
|
||||||
|
scan_button: scan_button.clone(),
|
||||||
|
import_button: import_button.clone(),
|
||||||
|
cancel_button: cancel_button.clone(),
|
||||||
|
clear_button,
|
||||||
|
back_button,
|
||||||
|
progress_group: progress_group.clone(),
|
||||||
|
progress_label: progress_label.clone(),
|
||||||
|
progress_bar: progress_bar.clone(),
|
||||||
|
summary_group: summary_group.clone(),
|
||||||
|
};
|
||||||
|
(frame.upcast(), widgets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
||||||
|
// Drag-and-drop. The target sits on the whole page rather than the dashed
|
||||||
|
// rectangle, so a drag released anywhere counts; the rectangle only lights
|
||||||
|
// up to say where the drop is understood.
|
||||||
|
let target = gtk4::DropTarget::new(
|
||||||
|
gtk4::gdk::FileList::static_type(),
|
||||||
|
gtk4::gdk::DragAction::COPY,
|
||||||
|
);
|
||||||
|
let ui_enter = ui.clone();
|
||||||
|
target.connect_enter(move |_, _, _| {
|
||||||
|
ui_enter.takeout.dropzone.add_css_class("dropzone-active");
|
||||||
|
gtk4::gdk::DragAction::COPY
|
||||||
|
});
|
||||||
|
let ui_leave = ui.clone();
|
||||||
|
target.connect_leave(move |_| {
|
||||||
|
ui_leave
|
||||||
|
.takeout
|
||||||
|
.dropzone
|
||||||
|
.remove_css_class("dropzone-active");
|
||||||
|
});
|
||||||
|
let ui_drop = ui.clone();
|
||||||
|
target.connect_drop(move |_, value, _, _| {
|
||||||
|
ui_drop.takeout.dropzone.remove_css_class("dropzone-active");
|
||||||
|
let Ok(files) = value.get::<gtk4::gdk::FileList>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let paths: Vec<PathBuf> = files.files().iter().filter_map(|f| f.path()).collect();
|
||||||
|
add_archives(&ui_drop, paths);
|
||||||
|
true
|
||||||
|
});
|
||||||
|
widgets.page.add_controller(target);
|
||||||
|
|
||||||
|
let ui_choose = ui.clone();
|
||||||
|
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")
|
||||||
|
.build();
|
||||||
|
let filter = gtk4::FileFilter::new();
|
||||||
|
filter.set_name(Some("Takeout archives"));
|
||||||
|
filter.add_pattern("*.zip");
|
||||||
|
let filters = gio::ListStore::new::<gtk4::FileFilter>();
|
||||||
|
filters.append(&filter);
|
||||||
|
dialog.set_filters(Some(&filters));
|
||||||
|
|
||||||
|
let ui = ui_choose.clone();
|
||||||
|
dialog.open_multiple(
|
||||||
|
ui_window(&ui_choose).as_ref(),
|
||||||
|
gio::Cancellable::NONE,
|
||||||
|
move |res| {
|
||||||
|
let Ok(files) = res else { return };
|
||||||
|
let paths: Vec<PathBuf> = (0..files.n_items())
|
||||||
|
.filter_map(|index| files.item(index))
|
||||||
|
.filter_map(|object| object.downcast::<gio::File>().ok())
|
||||||
|
.filter_map(|file| file.path())
|
||||||
|
.collect();
|
||||||
|
add_archives(&ui, paths);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_clear = ui.clone();
|
||||||
|
widgets.clear_button.connect_clicked(move |_| {
|
||||||
|
ui_clear.takeout.archives.borrow_mut().clear();
|
||||||
|
repaint_archives(&ui_clear);
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_scan = ui.clone();
|
||||||
|
widgets
|
||||||
|
.scan_button
|
||||||
|
.connect_clicked(move |_| start_import(&ui_scan, true));
|
||||||
|
|
||||||
|
// The import itself is confirmed: it uploads a whole photo library, which is
|
||||||
|
// hours of bandwidth and a large change to the account's timeline.
|
||||||
|
let ui_import = ui.clone();
|
||||||
|
widgets.import_button.connect_clicked(move |_| {
|
||||||
|
let count = ui_import.takeout.archives.borrow().len();
|
||||||
|
let ui = ui_import.clone();
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Import this export?")
|
||||||
|
.body(format!(
|
||||||
|
"{} 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_noun(count, "archive", "archives")
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("import", "Import");
|
||||||
|
dialog.set_response_appearance("import", adw::ResponseAppearance::Suggested);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
dialog.connect_response(None, move |_, resp| {
|
||||||
|
if resp == "import" {
|
||||||
|
start_import(&ui, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(ui_window(&ui_import).as_ref());
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_cancel = ui.clone();
|
||||||
|
widgets.cancel_button.connect_clicked(move |_| {
|
||||||
|
let ui = ui_cancel.clone();
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::CancelImport);
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, "Stopping the import…"),
|
||||||
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
|
toast_failure(&ui, "Couldn't stop the import", &message, kind);
|
||||||
|
}
|
||||||
|
_ => toast_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't stop the import",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let ui_back = ui.clone();
|
||||||
|
widgets
|
||||||
|
.back_button
|
||||||
|
.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.
|
||||||
|
*ui.takeout.archives.borrow_mut() = load_staged(ui);
|
||||||
|
repaint_archives(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage `paths`, keeping only `.zip` files and skipping ones already staged.
|
||||||
|
///
|
||||||
|
/// Both the chooser and the drop target land here, so the filter is stated once.
|
||||||
|
/// A dropped folder (an already-extracted export) is rejected rather than walked:
|
||||||
|
/// the scan reads the archives' central directories, and an extracted tree has
|
||||||
|
/// none — silently importing half of it would be worse than saying no.
|
||||||
|
pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
||||||
|
let mut added = 0usize;
|
||||||
|
let mut rejected = 0usize;
|
||||||
|
{
|
||||||
|
let mut staged = ui.takeout.archives.borrow_mut();
|
||||||
|
for path in paths {
|
||||||
|
let is_zip = path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
|
||||||
|
if !is_zip || !path.is_file() {
|
||||||
|
rejected += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if staged.contains(&path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
staged.push(path);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
// Numbered parts drop in whatever order the file manager hands them
|
||||||
|
// over; sorting keeps the list readable and the request stable.
|
||||||
|
staged.sort();
|
||||||
|
}
|
||||||
|
repaint_archives(ui);
|
||||||
|
if rejected > 0 {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||||
|
);
|
||||||
|
} else if added > 0 {
|
||||||
|
toast(
|
||||||
|
ui,
|
||||||
|
&format!("Added {}", count_noun(added, "archive", "archives")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the staged-archive list and re-derive which actions are available.
|
||||||
|
pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
||||||
|
for row in ui.takeout.rows.borrow_mut().drain(..) {
|
||||||
|
ui.takeout.list_group.remove(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let staged = ui.takeout.archives.borrow().clone();
|
||||||
|
let mut rows: Vec<adw::PreferencesRow> = Vec::new();
|
||||||
|
if staged.is_empty() {
|
||||||
|
ui.takeout
|
||||||
|
.list_group
|
||||||
|
.set_description(Some(EMPTY_LIST_DESCRIPTION));
|
||||||
|
let row = dim_row("No archives added yet.");
|
||||||
|
ui.takeout.list_group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
} else {
|
||||||
|
let mut total = 0u64;
|
||||||
|
for path in &staged {
|
||||||
|
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
total += size;
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(
|
||||||
|
path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| path.display().to_string()),
|
||||||
|
)
|
||||||
|
.subtitle(human_bytes(size))
|
||||||
|
.build();
|
||||||
|
let remove = gtk4::Button::builder()
|
||||||
|
.icon_name("edit-delete-symbolic")
|
||||||
|
.tooltip_text("Remove from this import")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove.add_css_class("flat");
|
||||||
|
let ui_remove = ui.clone();
|
||||||
|
let target = path.clone();
|
||||||
|
remove.connect_clicked(move |_| {
|
||||||
|
ui_remove
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow_mut()
|
||||||
|
.retain(|p| *p != target);
|
||||||
|
repaint_archives(&ui_remove);
|
||||||
|
});
|
||||||
|
row.add_suffix(&remove);
|
||||||
|
ui.takeout.list_group.add(&row);
|
||||||
|
rows.push(row.upcast());
|
||||||
|
}
|
||||||
|
ui.takeout.list_group.set_description(Some(&format!(
|
||||||
|
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||||
|
count_noun(staged.len(), "archive", "archives"),
|
||||||
|
human_bytes(total)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
*ui.takeout.rows.borrow_mut() = rows;
|
||||||
|
|
||||||
|
save_staged(ui);
|
||||||
|
sync_takeout_actions(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable exactly the actions that make sense right now: nothing to launch
|
||||||
|
/// without archives, nothing to launch while a run is in flight, and Stop only
|
||||||
|
/// while there is something to stop.
|
||||||
|
fn sync_takeout_actions(ui: &Rc<Ui>) {
|
||||||
|
let has_files = !ui.takeout.archives.borrow().is_empty();
|
||||||
|
let running = ui.takeout.running.get();
|
||||||
|
ui.takeout.scan_button.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout
|
||||||
|
.import_button
|
||||||
|
.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout.clear_button.set_sensitive(has_files && !running);
|
||||||
|
ui.takeout.cancel_button.set_visible(running);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the staged set to the daemon. It acks immediately and works in the
|
||||||
|
/// background — an export is far past any socket timeout — so success here means
|
||||||
|
/// "started", and the page switches over to reporting progress.
|
||||||
|
fn start_import(ui: &Rc<Ui>, dry_run: bool) {
|
||||||
|
let archives: Vec<String> = ui
|
||||||
|
.takeout
|
||||||
|
.archives
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.collect();
|
||||||
|
if archives.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.takeout.dry_run.set(dry_run);
|
||||||
|
ui.busy_begin();
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ImportTakeout { archives, dry_run },
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.busy_end();
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
|
ui.takeout.running.set(true);
|
||||||
|
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…"
|
||||||
|
} else {
|
||||||
|
"Starting import…"
|
||||||
|
});
|
||||||
|
sync_takeout_actions(&ui);
|
||||||
|
toast(
|
||||||
|
&ui,
|
||||||
|
if dry_run {
|
||||||
|
"Scanning the export…"
|
||||||
|
} else {
|
||||||
|
"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_error(
|
||||||
|
&ui,
|
||||||
|
"Couldn't start the import",
|
||||||
|
"The mount service didn't respond.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the daemon for the import's state while the page is on screen, and paint
|
||||||
|
/// the report once a run has finished. Cheap and inflight-guarded, like the other
|
||||||
|
/// tick polls.
|
||||||
|
pub(crate) fn refresh_takeout(ui: &Rc<Ui>) {
|
||||||
|
if ui.takeout.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.takeout.inflight.set(true);
|
||||||
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ImportStatus);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.takeout.inflight.set(false);
|
||||||
|
let Ok(Ok(Response::ImportStatus { running, summary })) = result else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let was_running = ui.takeout.running.replace(running);
|
||||||
|
if running {
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_group.set_visible(false);
|
||||||
|
// An import runs for hours, so its ending is announced rather than
|
||||||
|
// left to be discovered: a toast for whoever is looking at the
|
||||||
|
// window, and a desktop notification for whoever is not.
|
||||||
|
if was_running {
|
||||||
|
let dry = ui.takeout.dry_run.get();
|
||||||
|
let headline = if dry {
|
||||||
|
"Scan finished"
|
||||||
|
} else {
|
||||||
|
"Import finished"
|
||||||
|
};
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
notify("takeout-import", headline, &body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if was_running != running {
|
||||||
|
sync_takeout_actions(&ui);
|
||||||
|
}
|
||||||
|
match summary {
|
||||||
|
Some(summary) => repaint_summary(&ui, &summary),
|
||||||
|
None => ui.takeout.summary_group.set_visible(false),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint the last run's report.
|
||||||
|
fn repaint_summary(ui: &Rc<Ui>, summary: &ImportSummary) {
|
||||||
|
for row in ui.takeout.summary_rows.borrow_mut().drain(..) {
|
||||||
|
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."
|
||||||
|
} else {
|
||||||
|
"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()),
|
||||||
|
(
|
||||||
|
if dry { "Would upload" } else { "Uploaded" }.into(),
|
||||||
|
summary.uploaded.to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Already in Proton Photos".into(),
|
||||||
|
summary.duplicates.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
lines.push((
|
||||||
|
if dry {
|
||||||
|
"Albums to create"
|
||||||
|
} else {
|
||||||
|
"Albums created"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
summary.albums_created.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.album_links > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Photos filed into albums".into(),
|
||||||
|
summary.album_links.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.skipped_trashed > 0 {
|
||||||
|
lines.push((
|
||||||
|
"Skipped (in Google's trash)".into(),
|
||||||
|
summary.skipped_trashed.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.bytes > 0 {
|
||||||
|
lines.push((
|
||||||
|
if dry {
|
||||||
|
"Data to upload"
|
||||||
|
} else {
|
||||||
|
"Data uploaded"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
human_bytes(summary.bytes),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
lines.push(("Failed".into(), summary.failed.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (title, value) in lines {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(title)
|
||||||
|
.subtitle(value)
|
||||||
|
.build();
|
||||||
|
row.add_css_class("property");
|
||||||
|
ui.takeout.summary_group.add(&row);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
*ui.takeout.summary_rows.borrow_mut() = rows;
|
||||||
|
ui.takeout.summary_group.set_visible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]) {
|
||||||
|
let Some(job) = jobs.iter().find(|job| job.title == IMPORT_JOB) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.takeout.progress_group.set_visible(true);
|
||||||
|
if job.total > 0 {
|
||||||
|
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));
|
||||||
|
} else {
|
||||||
|
ui.takeout.progress_bar.pulse();
|
||||||
|
ui.takeout
|
||||||
|
.progress_label
|
||||||
|
.set_text(if job.detail.is_empty() {
|
||||||
|
"Working…"
|
||||||
|
} else {
|
||||||
|
&job.detail
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,11 @@ pub(crate) struct TrashState {
|
|||||||
/// Empties the trash; insensitive while it is empty (or unread).
|
/// Empties the trash; insensitive while it is empty (or unread).
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
/// "12 items" under the page title.
|
/// "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.
|
/// 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) retry: gtk4::Button,
|
||||||
pub(crate) empty: gtk4::Button,
|
pub(crate) empty: gtk4::Button,
|
||||||
pub(crate) refresh: 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
|
/// 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
|
/// 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
|
/// trashed — so unlike the Files page this one addresses entries by uid and always
|
||||||
@@ -35,31 +46,13 @@ pub(crate) struct TrashWidgets {
|
|||||||
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
||||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
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()
|
let empty = gtk4::Button::builder()
|
||||||
.label("Empty Trash")
|
.label("Empty Trash…")
|
||||||
.valign(gtk4::Align::Center)
|
.tooltip_text("Permanently delete everything in the Trash")
|
||||||
.sensitive(false)
|
.sensitive(false)
|
||||||
.build();
|
.build();
|
||||||
empty.add_css_class("destructive-action");
|
|
||||||
let refresh = refresh_button();
|
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()
|
let retry = gtk4::Button::builder()
|
||||||
.label("Retry")
|
.label("Retry")
|
||||||
.halign(gtk4::Align::Center)
|
.halign(gtk4::Align::Center)
|
||||||
@@ -74,11 +67,10 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
status.add_css_class("compact");
|
status.add_css_class("compact");
|
||||||
|
|
||||||
// No selection model: every action lives on the row it acts on, so a
|
// Bulk Delete Forever always confirms with the count, so a stale selection
|
||||||
// mis-aimed Delete Forever isn't one click away from a stale selection.
|
// can't delete anything silently.
|
||||||
let list = gtk4::ListView::builder()
|
let selection = gtk4::MultiSelection::new(Some(model.clone()));
|
||||||
.model(>k4::NoSelection::new(Some(model.clone())))
|
let list = gtk4::ListView::builder().model(&selection).build();
|
||||||
.build();
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.child(&list)
|
.child(&list)
|
||||||
@@ -91,15 +83,32 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
inner.set_margin_top(12);
|
inner.set_margin_top(18);
|
||||||
inner.set_margin_bottom(12);
|
inner.set_margin_bottom(18);
|
||||||
inner.set_margin_start(12);
|
inner.set_margin_start(18);
|
||||||
inner.set_margin_end(12);
|
inner.set_margin_end(18);
|
||||||
inner.append(&header);
|
|
||||||
inner.append(&content);
|
inner.append(&content);
|
||||||
|
|
||||||
|
let selection_label = gtk4::Label::new(None);
|
||||||
|
let restore_selected = gtk4::Button::with_label("Restore");
|
||||||
|
let delete_selected = gtk4::Button::with_label("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("Trash", &inner);
|
||||||
|
header.pack_start(&empty);
|
||||||
|
header.pack_end(&refresh);
|
||||||
|
|
||||||
(
|
(
|
||||||
inner.upcast(),
|
frame.upcast(),
|
||||||
TrashWidgets {
|
TrashWidgets {
|
||||||
model,
|
model,
|
||||||
list,
|
list,
|
||||||
@@ -109,6 +118,11 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
empty,
|
empty,
|
||||||
refresh,
|
refresh,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
selection,
|
||||||
|
selection_bar,
|
||||||
|
selection_label,
|
||||||
|
restore_selected,
|
||||||
|
delete_selected,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -116,13 +130,15 @@ pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) {
|
|||||||
/// Install the row factory and the Empty Trash button. The row's two buttons read
|
/// 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
|
/// 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.
|
/// 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 factory = gtk4::SignalListItemFactory::new();
|
||||||
let ui_setup = ui.clone();
|
let ui_setup = ui.clone();
|
||||||
factory.connect_setup(move |_, item| {
|
factory.connect_setup(move |_, item| {
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
|
|
||||||
let icon = gtk4::Image::builder().pixel_size(32).build();
|
let thumbnail = file_thumbnail_widget(40, 32);
|
||||||
let name = gtk4::Label::builder()
|
let name = gtk4::Label::builder()
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||||
@@ -160,7 +176,7 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
let item_purge = item.clone();
|
let item_purge = item.clone();
|
||||||
purge.connect_clicked(move |_| {
|
purge.connect_clicked(move |_| {
|
||||||
if let Some(entry) = bound_entry(&item_purge) {
|
if let Some(entry) = bound_entry(&item_purge) {
|
||||||
prompt_delete_forever(&ui_purge, &entry);
|
prompt_delete_forever(&ui_purge, std::slice::from_ref(&entry));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,41 +185,84 @@ pub(crate) fn wire_trash(ui: &Rc<Ui>, list: >k4::ListView, empty: >k4::Butto
|
|||||||
row.set_margin_bottom(6);
|
row.set_margin_bottom(6);
|
||||||
row.set_margin_start(6);
|
row.set_margin_start(6);
|
||||||
row.set_margin_end(6);
|
row.set_margin_end(6);
|
||||||
row.append(&icon);
|
row.append(&thumbnail);
|
||||||
row.append(&text);
|
row.append(&text);
|
||||||
row.append(&restore);
|
row.append(&restore);
|
||||||
row.append(&purge);
|
row.append(&purge);
|
||||||
item.set_child(Some(&row));
|
item.set_child(Some(&row));
|
||||||
});
|
});
|
||||||
factory.connect_bind(|_, item| {
|
factory.connect_bind({
|
||||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
let ui = ui.clone();
|
||||||
let Some(entry) = bound_entry(item) else {
|
move |_, item| {
|
||||||
return;
|
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||||
};
|
let Some(entry) = bound_entry(item) else {
|
||||||
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
return;
|
||||||
let Some(icon) = row.first_child().and_downcast::<gtk4::Image>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry))));
|
|
||||||
let Some(text) = icon.next_sibling().and_downcast::<gtk4::Box>() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
|
||||||
name.set_label(&entry.name);
|
|
||||||
}
|
|
||||||
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
|
||||||
let kind = if entry.is_dir {
|
|
||||||
"Folder".to_string()
|
|
||||||
} else {
|
|
||||||
human_bytes(entry.size)
|
|
||||||
};
|
};
|
||||||
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
let row = item.child().and_downcast::<gtk4::Box>().unwrap();
|
||||||
|
let Some(thumbnail) = row.first_child().and_downcast::<gtk4::Overlay>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
bind_file_thumbnail(&ui, &thumbnail, &entry, true);
|
||||||
|
let Some(text) = thumbnail.next_sibling().and_downcast::<gtk4::Box>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(name) = text.first_child().and_downcast::<gtk4::Label>() {
|
||||||
|
name.set_label(&entry.name);
|
||||||
|
}
|
||||||
|
if let Some(meta) = text.last_child().and_downcast::<gtk4::Label>() {
|
||||||
|
let kind = if entry.is_dir {
|
||||||
|
"Folder".to_string()
|
||||||
|
} else {
|
||||||
|
human_bytes(entry.size)
|
||||||
|
};
|
||||||
|
meta.set_label(&format!("{kind} · {}", format_modified(entry.modified)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
list.set_factory(Some(&factory));
|
list.set_factory(Some(&factory));
|
||||||
|
|
||||||
let ui_empty = ui.clone();
|
let ui_empty = ui.clone();
|
||||||
empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty));
|
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(&format!("{} selected", count_noun(count, "item", "items")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound
|
||||||
@@ -216,10 +275,12 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
|||||||
|
|
||||||
/// Fetch the trash listing and repaint the page.
|
/// Fetch the trash listing and repaint the page.
|
||||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||||
|
cancel_file_thumbnails(ui);
|
||||||
// Drop the old rows first: a stale row here would offer Restore on something
|
// Drop the old rows first: a stale row here would offer Restore on something
|
||||||
// that may already be gone.
|
// that may already be gone.
|
||||||
ui.trash.model.remove_all();
|
ui.trash.model.remove_all();
|
||||||
ui.trash.empty.set_sensitive(false);
|
ui.trash.empty.set_sensitive(false);
|
||||||
|
sync_trash_selection(ui);
|
||||||
trash_status(
|
trash_status(
|
||||||
ui,
|
ui,
|
||||||
"user-trash-symbolic",
|
"user-trash-symbolic",
|
||||||
@@ -290,13 +351,14 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
|||||||
ui.trash.status.set_description(Some(description));
|
ui.trash.status.set_description(Some(description));
|
||||||
ui.trash.retry.set_visible(retry);
|
ui.trash.retry.set_visible(retry);
|
||||||
ui.trash.content.set_visible_child_name("status");
|
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
|
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||||
/// user looks for what they just deleted.
|
/// user looks for what they just deleted.
|
||||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||||
ui.trash.model.remove_all();
|
ui.trash.model.remove_all();
|
||||||
|
sync_trash_selection(ui);
|
||||||
ui.trash.empty.set_sensitive(!entries.is_empty());
|
ui.trash.empty.set_sensitive(!entries.is_empty());
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
trash_status(
|
trash_status(
|
||||||
@@ -309,10 +371,9 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.trash.content.set_visible_child_name("list");
|
ui.trash.content.set_visible_child_name("list");
|
||||||
ui.trash.subtitle.set_label(&match entries.len() {
|
ui.trash
|
||||||
1 => "1 item".to_string(),
|
.subtitle
|
||||||
n => format!("{n} items"),
|
.set_subtitle(&count_noun(entries.len(), "item", "items"));
|
||||||
});
|
|
||||||
|
|
||||||
let mut sorted = entries.to_vec();
|
let mut sorted = entries.to_vec();
|
||||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||||
@@ -323,27 +384,37 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
|||||||
|
|
||||||
/// Restore one trashed entry to the folder it was trashed from.
|
/// Restore one trashed entry to the folder it was trashed from.
|
||||||
pub(crate) fn restore_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
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(
|
run_mutation(
|
||||||
ui,
|
ui,
|
||||||
Request::Restore {
|
Request::Restore {
|
||||||
uids: vec![entry.uid.clone()],
|
uids: entries.iter().map(|e| e.uid.clone()).collect(),
|
||||||
},
|
},
|
||||||
format!("Restored “{name}”"),
|
format!("Restored {}", entries_label(entries)),
|
||||||
"Couldn't restore",
|
"Couldn't restore",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks.
|
/// “name” for one entry, “3 items” for several.
|
||||||
pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
fn entries_label(entries: &[DirEntry]) -> String {
|
||||||
|
match entries {
|
||||||
|
[one] => format!("“{}”", one.name),
|
||||||
|
_ => count_noun(entries.len(), "item", "items"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 win = ui_window(ui);
|
||||||
let uid = entry.uid.clone();
|
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
|
||||||
let name = entry.name.clone();
|
let name = entries_label(entries);
|
||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Delete permanently")
|
.heading("Delete Permanently")
|
||||||
.body(format!(
|
.body(format!("Permanently delete {name}? This cannot be undone."))
|
||||||
"Permanently delete “{name}”? This cannot be undone."
|
|
||||||
))
|
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
dialog.add_response("delete", "Delete Permanently");
|
dialog.add_response("delete", "Delete Permanently");
|
||||||
@@ -356,10 +427,8 @@ pub(crate) fn prompt_delete_forever(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
if resp == "delete" {
|
if resp == "delete" {
|
||||||
run_mutation(
|
run_mutation(
|
||||||
&ui,
|
&ui,
|
||||||
Request::DeleteForever {
|
Request::DeleteForever { uids: uids.clone() },
|
||||||
uids: vec![uid.clone()],
|
format!("Deleted {name} permanently"),
|
||||||
},
|
|
||||||
format!("Deleted “{name}” permanently"),
|
|
||||||
"Couldn't delete",
|
"Couldn't delete",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -374,7 +443,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
|||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Empty Trash")
|
.heading("Empty Trash")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
"Permanently delete all {} in the trash? This cannot be undone.",
|
||||||
|
count_noun(count as usize, "item", "items")
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ pub(crate) struct DetailsState {
|
|||||||
/// Set while the details pane is being populated, so setting the offline
|
/// Set while the details pane is being populated, so setting the offline
|
||||||
/// switch programmatically doesn't fire a pin/unpin round-trip.
|
/// switch programmatically doesn't fire a pin/unpin round-trip.
|
||||||
pub(crate) details_suppress: Cell<bool>,
|
pub(crate) details_suppress: Cell<bool>,
|
||||||
/// The grid's and list's selection models. Both wrap the one browser model, so
|
/// The one selection model behind both the grid and the list, so switching
|
||||||
/// a selection in either drives the details pane.
|
/// views keeps what is selected; it drives the details pane.
|
||||||
pub(crate) grid_selection: gtk4::SingleSelection,
|
pub(crate) selection: gtk4::MultiSelection,
|
||||||
pub(crate) list_selection: gtk4::SingleSelection,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The widgets in the browser's details pane that a selection repaints.
|
/// The widgets in the browser's details pane that a selection repaints.
|
||||||
@@ -26,17 +25,34 @@ pub(crate) struct DetailsWidgets {
|
|||||||
pub(crate) pin_row: adw::SwitchRow,
|
pub(crate) pin_row: adw::SwitchRow,
|
||||||
pub(crate) open_button: gtk4::Button,
|
pub(crate) open_button: gtk4::Button,
|
||||||
pub(crate) rename_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.
|
/// 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) versions_button: gtk4::Button,
|
||||||
pub(crate) trash_button: gtk4::Button,
|
pub(crate) trash_button: gtk4::Button,
|
||||||
pub(crate) close_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
|
/// 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
|
/// name, then Info, Offline, Sharing and Versions sections and the primary
|
||||||
/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and
|
/// actions. Built empty; [`show_details`] fills it from the selected
|
||||||
/// [`wire_details`] connects the buttons.
|
/// [`DirEntry`] and [`wire_details`] connects the buttons.
|
||||||
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
||||||
|
let toggle = gtk4::ToggleButton::builder()
|
||||||
|
.icon_name("sidebar-show-right-symbolic")
|
||||||
|
.tooltip_text("Details (Alt+Enter)")
|
||||||
|
.build();
|
||||||
|
|
||||||
let close_button = gtk4::Button::builder()
|
let close_button = gtk4::Button::builder()
|
||||||
.icon_name("window-close-symbolic")
|
.icon_name("window-close-symbolic")
|
||||||
.tooltip_text("Close details")
|
.tooltip_text("Close details")
|
||||||
@@ -65,7 +81,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
head.append(&name);
|
head.append(&name);
|
||||||
head.append(&kind);
|
head.append(&kind);
|
||||||
|
|
||||||
let props = adw::PreferencesGroup::new();
|
let props = adw::PreferencesGroup::builder().title("Info").build();
|
||||||
let size_row = adw::ActionRow::builder()
|
let size_row = adw::ActionRow::builder()
|
||||||
.title("Size")
|
.title("Size")
|
||||||
.subtitle("—")
|
.subtitle("—")
|
||||||
@@ -86,20 +102,50 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
props.add(&modified_row);
|
props.add(&modified_row);
|
||||||
props.add(&path_row);
|
props.add(&path_row);
|
||||||
|
|
||||||
let offline = adw::PreferencesGroup::new();
|
let offline = adw::PreferencesGroup::builder().title("Offline").build();
|
||||||
let pin_row = adw::SwitchRow::builder()
|
let pin_row = adw::SwitchRow::builder()
|
||||||
.title("Available offline")
|
.title("Available offline")
|
||||||
.subtitle("Keep a local copy on this device.")
|
.subtitle("Keep a copy on this computer, even without a connection.")
|
||||||
.build();
|
.build();
|
||||||
offline.add(&pin_row);
|
offline.add(&pin_row);
|
||||||
|
|
||||||
|
let sharing = adw::PreferencesGroup::builder().title("Sharing").build();
|
||||||
|
let sharing_row = adw::ActionRow::builder()
|
||||||
|
.title("Access")
|
||||||
|
.subtitle("—")
|
||||||
|
.build();
|
||||||
|
sharing_row.add_css_class("property");
|
||||||
|
sharing.add(&sharing_row);
|
||||||
|
let share_button = gtk4::Button::builder().label("Share…").build();
|
||||||
|
let copy_link_button = gtk4::Button::builder().label("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("Versions").build();
|
||||||
|
let versions_row = adw::ActionRow::builder()
|
||||||
|
.title("History")
|
||||||
|
.subtitle("—")
|
||||||
|
.build();
|
||||||
|
versions_row.add_css_class("property");
|
||||||
|
versions_group.add(&versions_row);
|
||||||
|
let versions_button = gtk4::Button::builder()
|
||||||
|
.label("Versions…")
|
||||||
|
.margin_top(6)
|
||||||
|
.build();
|
||||||
|
versions_group.add(&versions_button);
|
||||||
|
|
||||||
let open_button = gtk4::Button::builder().label("Open").build();
|
let open_button = gtk4::Button::builder().label("Open").build();
|
||||||
open_button.add_css_class("suggested-action");
|
open_button.add_css_class("suggested-action");
|
||||||
open_button.add_css_class("pill");
|
open_button.add_css_class("pill");
|
||||||
let rename_button = gtk4::Button::builder().label("Rename").build();
|
let rename_button = gtk4::Button::builder().label("Rename").build();
|
||||||
rename_button.add_css_class("pill");
|
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("Move to Trash").build();
|
||||||
trash_button.add_css_class("destructive-action");
|
trash_button.add_css_class("destructive-action");
|
||||||
trash_button.add_css_class("pill");
|
trash_button.add_css_class("pill");
|
||||||
@@ -108,7 +154,6 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
actions.set_margin_top(6);
|
actions.set_margin_top(6);
|
||||||
actions.append(&open_button);
|
actions.append(&open_button);
|
||||||
actions.append(&rename_button);
|
actions.append(&rename_button);
|
||||||
actions.append(&versions_button);
|
|
||||||
actions.append(&trash_button);
|
actions.append(&trash_button);
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
@@ -120,14 +165,45 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
inner.append(&head);
|
inner.append(&head);
|
||||||
inner.append(&props);
|
inner.append(&props);
|
||||||
inner.append(&offline);
|
inner.append(&offline);
|
||||||
|
inner.append(&sharing);
|
||||||
|
inner.append(&versions_group);
|
||||||
inner.append(&actions);
|
inner.append(&actions);
|
||||||
|
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.child(&inner)
|
.child(&inner)
|
||||||
.build();
|
.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("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("No item selected")
|
||||||
|
.description("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();
|
let pane = adw::ToolbarView::new();
|
||||||
pane.set_content(Some(&scroll));
|
pane.set_content(Some(&pages));
|
||||||
pane.add_css_class("background");
|
pane.add_css_class("background");
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -140,11 +216,18 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
modified_row,
|
modified_row,
|
||||||
path_row,
|
path_row,
|
||||||
pin_row,
|
pin_row,
|
||||||
|
sharing_row,
|
||||||
|
share_button,
|
||||||
|
copy_link_button,
|
||||||
|
versions_group,
|
||||||
|
versions_row,
|
||||||
open_button,
|
open_button,
|
||||||
rename_button,
|
rename_button,
|
||||||
versions_button,
|
versions_button,
|
||||||
trash_button,
|
trash_button,
|
||||||
close_button,
|
close_button,
|
||||||
|
toggle,
|
||||||
|
pages,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -152,31 +235,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
|
/// 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.
|
/// wire its buttons back onto the entry it's showing.
|
||||||
pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
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();
|
let ui_sel = ui.clone();
|
||||||
selection.connect_selection_changed(move |sel, _, _| {
|
ui.details
|
||||||
match entry_at(sel.model().as_ref(), sel.selected()) {
|
.selection
|
||||||
Some(entry) => show_details(&ui_sel, &entry),
|
.connect_selection_changed(move |_, _, _| {
|
||||||
None => hide_details(&ui_sel),
|
// 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();
|
let ui_close = ui.clone();
|
||||||
ui.details.details.close_button.connect_clicked(move |_| {
|
ui.details.details.close_button.connect_clicked(move |_| {
|
||||||
ui_close
|
ui_close.details.details.toggle.set_active(false);
|
||||||
.details
|
});
|
||||||
.grid_selection
|
|
||||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
let ui_toggle = ui.clone();
|
||||||
ui_close
|
ui.details.details.toggle.connect_toggled(move |toggle| {
|
||||||
.details
|
ui_toggle.browser.split.set_show_sidebar(toggle.is_active());
|
||||||
.list_selection
|
});
|
||||||
.set_selected(gtk4::INVALID_LIST_POSITION);
|
// On a narrow window the pane overlays the files, and a click outside it
|
||||||
hide_details(&ui_close);
|
// 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();
|
let ui_open = ui.clone();
|
||||||
@@ -206,11 +298,28 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
open_versions_dialog(&ui_versions, &entry);
|
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();
|
let ui_trash = ui.clone();
|
||||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||||
if let Some(entry) = entry {
|
if let Some(entry) = entry {
|
||||||
prompt_delete(&ui_trash, &entry);
|
trash_entry(&ui_trash, &entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_pin = ui.clone();
|
let ui_pin = ui.clone();
|
||||||
@@ -232,7 +341,7 @@ 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) {
|
pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
ui.details.details_suppress.set(true);
|
ui.details.details_suppress.set(true);
|
||||||
let d = &ui.details.details;
|
let d = &ui.details.details;
|
||||||
@@ -258,39 +367,171 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else {
|
} else {
|
||||||
parent
|
parent
|
||||||
});
|
});
|
||||||
|
// 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_active(entry.pinned);
|
||||||
|
d.pin_row.set_visible(!entry.is_dir);
|
||||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||||
d.open_button
|
d.open_button
|
||||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||||
// Only files have revisions, and only a connected daemon can fetch them.
|
// Only files have revisions, and only a connected daemon can fetch them.
|
||||||
d.versions_button.set_visible(!entry.is_dir);
|
let mounted = *ui.mounted.borrow();
|
||||||
d.versions_button.set_sensitive(*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_suppress.set(false);
|
||||||
|
|
||||||
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
*ui.details.details_entry.borrow_mut() = Some(entry.clone());
|
||||||
// Reveal the pane on idle, not inline. This runs from `selection_changed`,
|
if mounted {
|
||||||
// which fires on the *first* press of a double-click; mutating the widget
|
load_details_extras(ui, entry);
|
||||||
// tree here cancels GtkGridView's multi-press tracking, so the second press
|
}
|
||||||
// restarts the count and `activate` never fires — the folder needs two
|
// Only the content changes. Whether the pane shows is the toggle's call:
|
||||||
// double-clicks. Deferring lets the click gesture finish first. Guard on the
|
// this runs from `selection_changed`, on the *first* press of a
|
||||||
// entry still being present so a navigation that clears it wins the race.
|
// double-click, and sliding the pane in there would move the files so the
|
||||||
let ui = ui.clone();
|
// second press lands elsewhere and the folder never opens.
|
||||||
glib::idle_add_local_once(move || {
|
d.pages.set_visible_child_name("details");
|
||||||
if ui.details.details_entry.borrow().is_some() {
|
}
|
||||||
ui.browser.split.set_show_sidebar(true);
|
|
||||||
|
/// 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())
|
||||||
|
}
|
||||||
|
_ => "Couldn't load sharing".to_string(),
|
||||||
|
};
|
||||||
|
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),
|
||||||
|
_ => "Couldn't load versions".to_string(),
|
||||||
|
};
|
||||||
|
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
|
fn details_showing(ui: &Rc<Ui>, uid: &str) -> bool {
|
||||||
/// can't be acted on after the listing moves on.
|
ui.details
|
||||||
pub(crate) fn hide_details(ui: &Rc<Ui>) {
|
.details_entry
|
||||||
ui.browser.split.set_show_sidebar(false);
|
.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 people = match people {
|
||||||
|
0 => None,
|
||||||
|
1 => Some("1 person".to_string()),
|
||||||
|
n => Some(format!("{n} people")),
|
||||||
|
};
|
||||||
|
match (people, link) {
|
||||||
|
(None, false) => "Only you".to_string(),
|
||||||
|
(None, true) => "Anyone with the link".to_string(),
|
||||||
|
(Some(p), false) => format!("Shared with {p}"),
|
||||||
|
(Some(p), true) => format!("Shared with {p} · Public link"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "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) => "No earlier versions".to_string(),
|
||||||
|
(1, Some(t)) => format!("1 version · {}", format_modified(t)),
|
||||||
|
(n, Some(t)) => format!("{n} versions · latest {}", 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;
|
*ui.details.details_entry.borrow_mut() = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entry highlighted in whichever browser view is on screen, if any. Backs the
|
/// The single entry the details pane is showing, if any. Backs the actions that
|
||||||
/// F2 / Delete shortcuts.
|
/// only make sense on one entry (rename, versions); the batch-capable ones ask
|
||||||
|
/// [`selected_entries`] instead.
|
||||||
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
pub(crate) fn selected_entry(ui: &Rc<Ui>) -> Option<DirEntry> {
|
||||||
ui.details.details_entry.borrow().clone()
|
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,158 @@
|
|||||||
|
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")
|
||||||
|
.tooltip_text("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,3 +1,5 @@
|
|||||||
pub(crate) mod details;
|
pub(crate) mod details;
|
||||||
|
pub(crate) mod menu;
|
||||||
pub(crate) mod share_dialog;
|
pub(crate) mod share_dialog;
|
||||||
|
pub(crate) mod thumbnails;
|
||||||
pub(crate) mod versions_dialog;
|
pub(crate) mod versions_dialog;
|
||||||
@@ -21,6 +21,99 @@ 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] = [
|
||||||
|
("Never", None),
|
||||||
|
("1 day", Some(1)),
|
||||||
|
("7 days", Some(7)),
|
||||||
|
("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.
|
/// How an open Share dialog addresses its node.
|
||||||
///
|
///
|
||||||
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
/// A node the browser is showing has a mountpoint-relative path. A node reached
|
||||||
@@ -94,19 +187,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 {
|
match self {
|
||||||
ShareTarget::Path(path) => Request::CreatePublicLink {
|
ShareTarget::Path(path) => Request::CreatePublicLink {
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
role,
|
role,
|
||||||
password,
|
password,
|
||||||
expires: None,
|
expires,
|
||||||
},
|
},
|
||||||
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
ShareTarget::Uid(uid) => Request::CreatePublicLinkByUid {
|
||||||
uid: uid.clone(),
|
uid: uid.clone(),
|
||||||
role,
|
role,
|
||||||
password,
|
password,
|
||||||
expires: None,
|
expires,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +272,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
.margin_top(6)
|
.margin_top(6)
|
||||||
.build();
|
.build();
|
||||||
invite_btn.add_css_class("suggested-action");
|
invite_btn.add_css_class("suggested-action");
|
||||||
|
invite_btn.set_sensitive(false);
|
||||||
let invite_wrap = adw::PreferencesRow::builder()
|
let invite_wrap = adw::PreferencesRow::builder()
|
||||||
.activatable(false)
|
.activatable(false)
|
||||||
.child(&invite_btn)
|
.child(&invite_btn)
|
||||||
@@ -227,6 +321,25 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
link_rows: RefCell::new(Vec::new()),
|
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");
|
||||||
|
row.set_tooltip_text(Some(&format!("“{bad}” isn't an email address")));
|
||||||
|
}
|
||||||
|
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.
|
// Enter in either free-text field sends the invitations, no mouse needed.
|
||||||
let btn = invite_btn.clone();
|
let btn = invite_btn.clone();
|
||||||
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
email_row.connect_entry_activated(move |_| btn.emit_clicked());
|
||||||
@@ -235,16 +348,15 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
|
|
||||||
// Invite button.
|
// Invite button.
|
||||||
let state_inv = state.clone();
|
let state_inv = state.clone();
|
||||||
invite_btn.connect_clicked(move |_| {
|
invite_btn.connect_clicked(move |btn| {
|
||||||
let raw = email_row.text();
|
// Enter in a field reaches here even while the button is off.
|
||||||
let emails: Vec<String> = raw
|
if !btn.is_sensitive() {
|
||||||
.split([',', ' ', ';'])
|
return;
|
||||||
.map(str::trim)
|
}
|
||||||
.filter(|s| !s.is_empty())
|
let Ok(emails) = parse_emails(&email_row.text()) else {
|
||||||
.map(str::to_string)
|
return;
|
||||||
.collect();
|
};
|
||||||
if emails.is_empty() {
|
if emails.is_empty() {
|
||||||
toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let role = role_index_to_wire(role_drop.selected()).to_string();
|
let role = role_index_to_wire(role_drop.selected()).to_string();
|
||||||
@@ -257,6 +369,7 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
state_inv.target.invite(emails, role, message),
|
state_inv.target.invite(emails, role, message),
|
||||||
"Invitations sent",
|
"Invitations sent",
|
||||||
"Couldn't send invitations",
|
"Couldn't send invitations",
|
||||||
|
Some(Busy::start(btn)),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
email_clear.set_text("");
|
email_clear.set_text("");
|
||||||
msg_clear.set_text("");
|
msg_clear.set_text("");
|
||||||
@@ -340,6 +453,7 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
"Role updated",
|
"Role updated",
|
||||||
"Couldn't update the role",
|
"Couldn't update the role",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&drop);
|
row.add_suffix(&drop);
|
||||||
@@ -361,13 +475,26 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
let state_rm = state.clone();
|
let state_rm = state.clone();
|
||||||
let id = entry.id.clone();
|
let id = entry.id.clone();
|
||||||
let kind = entry.kind;
|
let kind = entry.kind;
|
||||||
remove.connect_clicked(move |_| {
|
let who = entry.email.clone();
|
||||||
share_dialog_op(
|
remove.connect_clicked(move |btn| {
|
||||||
&state_rm,
|
let state = state_rm.clone();
|
||||||
state_rm.target.remove_entry(id.clone(), kind),
|
let id = id.clone();
|
||||||
"Access removed",
|
let button = btn.clone();
|
||||||
"Couldn't remove access",
|
confirm_destructive(
|
||||||
None,
|
btn,
|
||||||
|
"Remove Access?",
|
||||||
|
&format!("{who} will no longer be able to open this item."),
|
||||||
|
"Remove",
|
||||||
|
move || {
|
||||||
|
share_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.remove_entry(id.clone(), kind),
|
||||||
|
"Access removed",
|
||||||
|
"Couldn't remove access",
|
||||||
|
Some(Busy::start(&button)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
@@ -389,11 +516,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
match link {
|
match link {
|
||||||
Some(link) => {
|
Some(link) => {
|
||||||
let url = link.url.clone().unwrap_or_default();
|
let url = link.url.clone().unwrap_or_default();
|
||||||
let subtitle = if link.has_password {
|
let mut subtitle = format!("Anyone with the link ({})", capitalize(&link.role));
|
||||||
format!("Anyone with the link ({}) · password-protected", link.role)
|
if link.has_password {
|
||||||
} else {
|
subtitle.push_str(" · password-protected");
|
||||||
format!("Anyone with the link ({})", link.role)
|
}
|
||||||
};
|
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()
|
let row = adw::ActionRow::builder()
|
||||||
.title(if url.is_empty() { "Public link" } else { &url })
|
.title(if url.is_empty() { "Public link" } else { &url })
|
||||||
.subtitle(&subtitle)
|
.subtitle(&subtitle)
|
||||||
@@ -424,13 +554,26 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
remove.add_css_class("flat");
|
remove.add_css_class("flat");
|
||||||
let state_rm = state.clone();
|
let state_rm = state.clone();
|
||||||
let id = link.id.clone();
|
let id = link.id.clone();
|
||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |btn| {
|
||||||
share_dialog_op(
|
let state = state_rm.clone();
|
||||||
&state_rm,
|
let id = id.clone();
|
||||||
state_rm.target.remove_link(id.clone()),
|
let button = btn.clone();
|
||||||
"Public link removed",
|
confirm_destructive(
|
||||||
"Couldn't remove the link",
|
btn,
|
||||||
None,
|
"Remove Public Link?",
|
||||||
|
"The link stops working for everyone who has it. A new link will have \
|
||||||
|
a different address.",
|
||||||
|
"Remove Link",
|
||||||
|
move || {
|
||||||
|
share_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.remove_link(id.clone()),
|
||||||
|
"Public link removed",
|
||||||
|
"Couldn't remove the link",
|
||||||
|
Some(Busy::start(&button)),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
@@ -450,6 +593,14 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
let pw_row = adw::PasswordEntryRow::builder()
|
let pw_row = adw::PasswordEntryRow::builder()
|
||||||
.title("Password (optional)")
|
.title("Password (optional)")
|
||||||
.build();
|
.build();
|
||||||
|
let expiry_names: Vec<&str> = LINK_EXPIRY.iter().map(|(name, _)| *name).collect();
|
||||||
|
let expiry_drop = gtk4::DropDown::builder()
|
||||||
|
.model(>k4::StringList::new(&expiry_names))
|
||||||
|
.selected(0)
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
let expiry_row = adw::ActionRow::builder().title("Expires").build();
|
||||||
|
expiry_row.add_suffix(&expiry_drop);
|
||||||
let create = gtk4::Button::builder()
|
let create = gtk4::Button::builder()
|
||||||
.label("Create Public Link")
|
.label("Create Public Link")
|
||||||
.halign(gtk4::Align::End)
|
.halign(gtk4::Align::End)
|
||||||
@@ -467,7 +618,10 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
|
|
||||||
let state_c = state.clone();
|
let state_c = state.clone();
|
||||||
let pw_for = pw_row.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 {
|
let role = if role_drop.selected() == 1 {
|
||||||
"editor"
|
"editor"
|
||||||
} else {
|
} else {
|
||||||
@@ -476,14 +630,17 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
.to_string();
|
.to_string();
|
||||||
let pw = pw_for.text().to_string();
|
let pw = pw_for.text().to_string();
|
||||||
let password = if pw.is_empty() { None } else { Some(pw) };
|
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(&role_row);
|
||||||
state.link_group.add(&pw_row);
|
state.link_group.add(&pw_row);
|
||||||
|
state.link_group.add(&expiry_row);
|
||||||
state.link_group.add(&create_wrap);
|
state.link_group.add(&create_wrap);
|
||||||
rows.push(role_row.upcast());
|
rows.push(role_row.upcast());
|
||||||
rows.push(pw_row.upcast());
|
rows.push(pw_row.upcast());
|
||||||
|
rows.push(expiry_row.upcast());
|
||||||
rows.push(create_wrap.upcast());
|
rows.push(create_wrap.upcast());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,14 +653,18 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
state: &Rc<ShareDialog>,
|
state: &Rc<ShareDialog>,
|
||||||
role: String,
|
role: String,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
|
expires: Option<i64>,
|
||||||
|
busy: Busy,
|
||||||
) {
|
) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
state.ui.dirs.control_socket(),
|
state.ui.dirs.control_socket(),
|
||||||
state.target.create_link(role, password),
|
state.target.create_link(role, password, expires),
|
||||||
);
|
);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
let reply = rx.recv().await;
|
||||||
|
drop(busy);
|
||||||
|
match reply {
|
||||||
Ok(Ok(Response::PublicLink { .. })) => {
|
Ok(Ok(Response::PublicLink { .. })) => {
|
||||||
toast(&state.ui, "Public link created");
|
toast(&state.ui, "Public link created");
|
||||||
share_dialog_reload(&state);
|
share_dialog_reload(&state);
|
||||||
@@ -521,18 +682,22 @@ pub(crate) fn share_dialog_create_link(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run a Share-dialog mutation, then reload the dialog on success. `on_success`
|
/// 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(
|
pub(crate) fn share_dialog_op(
|
||||||
state: &Rc<ShareDialog>,
|
state: &Rc<ShareDialog>,
|
||||||
req: Request,
|
req: Request,
|
||||||
done: &'static str,
|
done: &'static str,
|
||||||
failed: &'static str,
|
failed: &'static str,
|
||||||
|
busy: Option<Busy>,
|
||||||
on_success: Option<Box<dyn Fn()>>,
|
on_success: Option<Box<dyn Fn()>>,
|
||||||
) {
|
) {
|
||||||
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
let rx = spawn_request(state.ui.dirs.control_socket(), req);
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
let reply = rx.recv().await;
|
||||||
|
drop(busy);
|
||||||
|
match reply {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
if let Some(cb) = on_success {
|
if let Some(cb) = on_success {
|
||||||
cb();
|
cb();
|
||||||
@@ -550,3 +715,61 @@ pub(crate) fn share_dialog_op(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
return "expired".to_string();
|
||||||
|
}
|
||||||
|
let date = glib::DateTime::from_unix_local(expires)
|
||||||
|
.ok()
|
||||||
|
.and_then(|at| at.format("%x").ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("expires {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 "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Shared thumbnails for ordinary Drive image files.
|
||||||
|
//!
|
||||||
|
//! The Photos gallery has timeline-specific batching and aspect-ratio state.
|
||||||
|
//! File listings need a smaller, reusable surface: one loader backs My files,
|
||||||
|
//! search, Shared and Trash, with modification-time cache keys
|
||||||
|
//! so a replaced image never flashes its previous contents.
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
use pdfs_core::control::{FileThumbRequest, is_thumbnail_image_name};
|
||||||
|
|
||||||
|
const THUMB_BATCH: usize = 32;
|
||||||
|
const THUMB_CACHE_MAX: usize = 256;
|
||||||
|
const THUMB_MISSING_MAX: usize = 2048;
|
||||||
|
const THUMB_DEBOUNCE: Duration = Duration::from_millis(40);
|
||||||
|
const THUMB_RETRY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
pub(crate) struct FileThumbnailState {
|
||||||
|
textures: RefCell<HashMap<String, gtk4::gdk::Texture>>,
|
||||||
|
texture_order: RefCell<VecDeque<String>>,
|
||||||
|
missing: RefCell<HashSet<String>>,
|
||||||
|
missing_order: RefCell<VecDeque<String>>,
|
||||||
|
wanted: RefCell<HashMap<String, Vec<gtk4::Picture>>>,
|
||||||
|
queue: RefCell<VecDeque<FileThumbRequest>>,
|
||||||
|
inflight: Cell<bool>,
|
||||||
|
source: RefCell<Option<glib::SourceId>>,
|
||||||
|
generation: Cell<u64>,
|
||||||
|
generation_loading: Cell<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum ReplyDisposition {
|
||||||
|
Ignore,
|
||||||
|
Load,
|
||||||
|
Retry,
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply_disposition(wanted: bool, has_path: bool, pending: bool) -> ReplyDisposition {
|
||||||
|
if !wanted {
|
||||||
|
ReplyDisposition::Ignore
|
||||||
|
} else if has_path {
|
||||||
|
ReplyDisposition::Load
|
||||||
|
} else if pending {
|
||||||
|
ReplyDisposition::Retry
|
||||||
|
} else {
|
||||||
|
ReplyDisposition::Missing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileThumbnailState {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
textures: RefCell::new(HashMap::new()),
|
||||||
|
texture_order: RefCell::new(VecDeque::new()),
|
||||||
|
missing: RefCell::new(HashSet::new()),
|
||||||
|
missing_order: RefCell::new(VecDeque::new()),
|
||||||
|
wanted: RefCell::new(HashMap::new()),
|
||||||
|
queue: RefCell::new(VecDeque::new()),
|
||||||
|
inflight: Cell::new(false),
|
||||||
|
source: RefCell::new(None),
|
||||||
|
generation: Cell::new(0),
|
||||||
|
generation_loading: Cell::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_texture(&self, key: &str, texture: gtk4::gdk::Texture) {
|
||||||
|
let mut textures = self.textures.borrow_mut();
|
||||||
|
let mut order = self.texture_order.borrow_mut();
|
||||||
|
if textures.insert(key.to_string(), texture).is_none() {
|
||||||
|
order.push_back(key.to_string());
|
||||||
|
}
|
||||||
|
while order.len() > THUMB_CACHE_MAX {
|
||||||
|
if let Some(old) = order.pop_front() {
|
||||||
|
textures.remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store_missing(&self, key: String) {
|
||||||
|
let mut missing = self.missing.borrow_mut();
|
||||||
|
let mut order = self.missing_order.borrow_mut();
|
||||||
|
if missing.insert(key.clone()) {
|
||||||
|
order.push_back(key);
|
||||||
|
}
|
||||||
|
while order.len() > THUMB_MISSING_MAX {
|
||||||
|
if let Some(old) = order.pop_front() {
|
||||||
|
missing.remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon every opportunistic ordinary-file thumbnail associated with the
|
||||||
|
/// listing being left. Advancing a wire generation lets the daemon cancel work
|
||||||
|
/// even when an older request reaches it after this one.
|
||||||
|
pub(crate) fn cancel_file_thumbnails(ui: &Rc<Ui>) {
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
ui.file_thumbs.queue.borrow_mut().clear();
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().clear();
|
||||||
|
let generation = ui.file_thumbs.generation.replace(0);
|
||||||
|
// The request is useful even when its reply is not. `spawn_request` owns the
|
||||||
|
// socket round-trip on a worker thread, so dropping the receiver is safe.
|
||||||
|
if generation != 0 {
|
||||||
|
drop(spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::CancelFileThumbs {
|
||||||
|
generation: generation.saturating_add(1),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A square thumbnail surface with a generic file icon underneath the picture.
|
||||||
|
/// Call [`bind_file_thumbnail`] whenever a recycled row is bound to an entry.
|
||||||
|
pub(crate) fn file_thumbnail_widget(size: i32, fallback_size: i32) -> gtk4::Overlay {
|
||||||
|
let fallback = gtk4::Image::builder().pixel_size(fallback_size).build();
|
||||||
|
let picture = gtk4::Picture::builder()
|
||||||
|
.width_request(size)
|
||||||
|
.height_request(size)
|
||||||
|
.content_fit(gtk4::ContentFit::Cover)
|
||||||
|
.can_shrink(true)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
picture.add_css_class("file-thumbnail");
|
||||||
|
|
||||||
|
let overlay = gtk4::Overlay::new();
|
||||||
|
overlay.set_width_request(size);
|
||||||
|
overlay.set_height_request(size);
|
||||||
|
overlay.set_halign(gtk4::Align::Center);
|
||||||
|
overlay.set_valign(gtk4::Align::Center);
|
||||||
|
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||||
|
overlay.set_child(Some(&fallback));
|
||||||
|
overlay.add_overlay(&picture);
|
||||||
|
overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize an existing thumbnail surface. Grid rows are recycled, so changing
|
||||||
|
/// these requests in place and rebinding the model is enough to implement the
|
||||||
|
/// status-bar zoom without downloading or decoding an image again.
|
||||||
|
pub(crate) fn resize_file_thumbnail(widget: >k4::Overlay, size: i32, fallback_size: i32) {
|
||||||
|
widget.set_width_request(size);
|
||||||
|
widget.set_height_request(size);
|
||||||
|
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
fallback.set_pixel_size(fallback_size);
|
||||||
|
if let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() {
|
||||||
|
picture.set_width_request(size);
|
||||||
|
picture.set_height_request(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind a thumbnail surface to one entry, painting a cached texture immediately
|
||||||
|
/// or queueing an on-demand daemon request. Non-images retain the generic icon.
|
||||||
|
pub(crate) fn bind_file_thumbnail(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
widget: >k4::Overlay,
|
||||||
|
entry: &DirEntry,
|
||||||
|
symbolic_fallback: bool,
|
||||||
|
) {
|
||||||
|
let Some(fallback) = widget.child().and_downcast::<gtk4::Image>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// The browser may add a status badge after the picture. Locate the picture
|
||||||
|
// immediately after the base fallback instead of assuming it is the last
|
||||||
|
// overlay child.
|
||||||
|
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let fallback_name = if symbolic_fallback {
|
||||||
|
format!("{}-symbolic", icon_base_for(entry))
|
||||||
|
} else {
|
||||||
|
icon_base_for(entry).to_string()
|
||||||
|
};
|
||||||
|
fallback.set_icon_name(Some(&fallback_name));
|
||||||
|
fallback.set_visible(true);
|
||||||
|
picture.set_visible(false);
|
||||||
|
picture.set_paintable(gtk4::gdk::Paintable::NONE);
|
||||||
|
|
||||||
|
// The widget name is a cheap binding token. List factories recycle their
|
||||||
|
// children; a reply for the previous entry must not paint into the new one.
|
||||||
|
let key = thumbnail_key(&entry.uid, entry.modified);
|
||||||
|
picture.set_widget_name(&key);
|
||||||
|
remove_waiter(ui, &picture);
|
||||||
|
|
||||||
|
if entry.is_dir || !is_image_name(&entry.name) || entry.uid.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(texture) = ui.file_thumbs.textures.borrow().get(&key) {
|
||||||
|
paint(&picture, &key, texture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.file_thumbs.missing.borrow().contains(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.file_thumbs
|
||||||
|
.wanted
|
||||||
|
.borrow_mut()
|
||||||
|
.entry(key)
|
||||||
|
.or_default()
|
||||||
|
.push(picture);
|
||||||
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
|
queue.retain(|item| item.uid != entry.uid);
|
||||||
|
queue.push_back(FileThumbRequest {
|
||||||
|
uid: entry.uid.clone(),
|
||||||
|
modified: entry.modified,
|
||||||
|
name: entry.name.clone(),
|
||||||
|
});
|
||||||
|
drop(queue);
|
||||||
|
schedule_file_thumbs(ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create and immediately bind a thumbnail for a non-recycled row.
|
||||||
|
pub(crate) fn file_thumbnail(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
entry: &DirEntry,
|
||||||
|
size: i32,
|
||||||
|
fallback_size: i32,
|
||||||
|
symbolic_fallback: bool,
|
||||||
|
) -> gtk4::Overlay {
|
||||||
|
let widget = file_thumbnail_widget(size, fallback_size);
|
||||||
|
bind_file_thumbnail(ui, &widget, entry, symbolic_fallback);
|
||||||
|
widget
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the file name denotes an image format for which Proton or the local
|
||||||
|
/// decoder can reasonably provide a thumbnail.
|
||||||
|
pub(crate) fn is_image_name(name: &str) -> bool {
|
||||||
|
is_thumbnail_image_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn thumbnail_key(uid: &str, modified: i64) -> String {
|
||||||
|
format!("{uid}:{modified}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_waiter(ui: &Rc<Ui>, picture: >k4::Picture) {
|
||||||
|
let mut wanted = ui.file_thumbs.wanted.borrow_mut();
|
||||||
|
wanted.retain(|_, pictures| {
|
||||||
|
pictures.retain(|candidate| candidate != picture);
|
||||||
|
!pictures.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(picture: >k4::Picture, key: &str, texture: >k4::gdk::Texture) {
|
||||||
|
if picture.widget_name() != key {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
picture.set_paintable(Some(texture));
|
||||||
|
picture.set_visible(true);
|
||||||
|
if let Some(overlay) = picture.parent().and_downcast::<gtk4::Overlay>()
|
||||||
|
&& let Some(fallback) = overlay.child().and_downcast::<gtk4::Image>()
|
||||||
|
{
|
||||||
|
fallback.set_visible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_file_thumbs(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.queue.borrow().is_empty() || ui.file_thumbs.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ui.file_thumbs.generation.get() == 0 {
|
||||||
|
reserve_file_thumb_generation(ui);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let ui_flush = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || {
|
||||||
|
ui_flush.file_thumbs.source.borrow_mut().take();
|
||||||
|
flush_file_thumbs(&ui_flush);
|
||||||
|
});
|
||||||
|
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reserve_file_thumb_generation(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.generation_loading.replace(true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::ReserveFileThumbGeneration,
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.file_thumbs.generation_loading.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => {
|
||||||
|
ui.file_thumbs.generation.set(generation);
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
}
|
||||||
|
_ => retry_file_thumb_generation(&ui),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_file_thumb_generation(ui: &Rc<Ui>) {
|
||||||
|
if let Some(source) = ui.file_thumbs.source.borrow_mut().take() {
|
||||||
|
source.remove();
|
||||||
|
}
|
||||||
|
let ui_retry = ui.clone();
|
||||||
|
let source = glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||||
|
ui_retry.file_thumbs.source.borrow_mut().take();
|
||||||
|
schedule_file_thumbs(&ui_retry);
|
||||||
|
});
|
||||||
|
*ui.file_thumbs.source.borrow_mut() = Some(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_file_thumbs(ui: &Rc<Ui>) {
|
||||||
|
if ui.file_thumbs.inflight.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let items: Vec<FileThumbRequest> = {
|
||||||
|
let mut queue = ui.file_thumbs.queue.borrow_mut();
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow();
|
||||||
|
let mut batch = Vec::new();
|
||||||
|
while batch.len() < THUMB_BATCH {
|
||||||
|
let Some(item) = queue.pop_front() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if wanted.contains_key(&thumbnail_key(&item.uid, item.modified)) {
|
||||||
|
batch.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
};
|
||||||
|
if items.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.file_thumbs.inflight.set(true);
|
||||||
|
let rx = spawn_request(
|
||||||
|
ui.dirs.control_socket(),
|
||||||
|
Request::FileThumbs {
|
||||||
|
items: items.clone(),
|
||||||
|
generation: ui.file_thumbs.generation.get(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
ui.file_thumbs.inflight.set(false);
|
||||||
|
match result {
|
||||||
|
Ok(Ok(Response::Thumbs { items: replies })) => {
|
||||||
|
let mut requested: HashMap<String, FileThumbRequest> = items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| (item.uid.clone(), item))
|
||||||
|
.collect();
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
for reply in replies {
|
||||||
|
let Some(request) = requested.remove(&reply.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = thumbnail_key(&request.uid, request.modified);
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow().contains_key(&key);
|
||||||
|
match reply_disposition(wanted, reply.path.is_some(), reply.pending) {
|
||||||
|
ReplyDisposition::Ignore => continue,
|
||||||
|
ReplyDisposition::Load => {
|
||||||
|
let Some(path) = reply.path else { continue };
|
||||||
|
match gtk4::gdk::Texture::from_filename(&path) {
|
||||||
|
Ok(texture) => {
|
||||||
|
ui.file_thumbs.store_texture(&key, texture.clone());
|
||||||
|
if let Some(pictures) =
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key)
|
||||||
|
{
|
||||||
|
for picture in pictures {
|
||||||
|
paint(&picture, &key, &texture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!("cannot decode file thumbnail {path}: {e}");
|
||||||
|
ui.file_thumbs.store_missing(key.clone());
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReplyDisposition::Retry => pending.push(request),
|
||||||
|
ReplyDisposition::Missing => {
|
||||||
|
ui.file_thumbs.store_missing(key.clone());
|
||||||
|
ui.file_thumbs.wanted.borrow_mut().remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.extend(requested.into_values());
|
||||||
|
if !pending.is_empty() {
|
||||||
|
retry_file_thumbs(&ui, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::FileThumbsStale)) => {
|
||||||
|
ui.file_thumbs.generation.set(0);
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
Ok(Ok(Response::Error { message, .. })) => {
|
||||||
|
tracing::debug!("file thumbnails failed: {message}");
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) | Ok(Err(_)) | Err(_) => {
|
||||||
|
tracing::debug!("file thumbnails: no reply");
|
||||||
|
retry_file_thumbs(&ui, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_file_thumbs(ui: &Rc<Ui>, items: Vec<FileThumbRequest>) {
|
||||||
|
let ui = ui.clone();
|
||||||
|
glib::timeout_add_local_once(THUMB_RETRY, move || {
|
||||||
|
let wanted = ui.file_thumbs.wanted.borrow();
|
||||||
|
let retry: Vec<FileThumbRequest> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| wanted.contains_key(&thumbnail_key(&item.uid, item.modified)))
|
||||||
|
.collect();
|
||||||
|
drop(wanted);
|
||||||
|
ui.file_thumbs.queue.borrow_mut().extend(retry);
|
||||||
|
schedule_file_thumbs(&ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{ReplyDisposition, is_image_name, reply_disposition};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_extensions_are_case_insensitive_and_specific() {
|
||||||
|
for name in [
|
||||||
|
"photo.jpg",
|
||||||
|
"SCAN.PNG",
|
||||||
|
"animation.Gif",
|
||||||
|
"archive.tiff",
|
||||||
|
"camera.nef",
|
||||||
|
"negative.CR3",
|
||||||
|
"camera.RAW",
|
||||||
|
] {
|
||||||
|
assert!(is_image_name(name), "{name}");
|
||||||
|
}
|
||||||
|
for name in [
|
||||||
|
"notes.txt",
|
||||||
|
"movie.mp4",
|
||||||
|
"image.jpg.zip",
|
||||||
|
"jpg",
|
||||||
|
"camera.heic",
|
||||||
|
"frame.avif",
|
||||||
|
"graphic.svg",
|
||||||
|
"camera.rw",
|
||||||
|
] {
|
||||||
|
assert!(!is_image_name(name), "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_listing_replies_cannot_become_permanent_misses() {
|
||||||
|
assert_eq!(
|
||||||
|
reply_disposition(false, false, false),
|
||||||
|
ReplyDisposition::Ignore
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reply_disposition(true, false, false),
|
||||||
|
ReplyDisposition::Missing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,16 +182,20 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
|||||||
.filter(|size| *size >= 0)
|
.filter(|size| *size >= 0)
|
||||||
.unwrap_or(item.size_on_storage)
|
.unwrap_or(item.size_on_storage)
|
||||||
.max(0) as u64;
|
.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 {
|
||||||
|
(
|
||||||
|
"Current version".to_string(),
|
||||||
|
format!("{} · {}", activity_time(item.created), human_bytes(size)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(activity_time(item.created), human_bytes(size))
|
||||||
|
};
|
||||||
if let Some(email) = &item.signed_by {
|
if let Some(email) = &item.signed_by {
|
||||||
subtitle.push_str(" · ");
|
subtitle.push_str(" · ");
|
||||||
subtitle.push_str(email);
|
subtitle.push_str(email);
|
||||||
}
|
}
|
||||||
let title = if item.is_active {
|
|
||||||
"Current version".to_string()
|
|
||||||
} else {
|
|
||||||
format!("Version {}", item.id)
|
|
||||||
};
|
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle)
|
||||||
|
|||||||
+16
-172
@@ -1,26 +1,23 @@
|
|||||||
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
|
||||||
//!
|
//!
|
||||||
//! Everything here is blocking and GTK-free — the process exists for one
|
//! Everything here is blocking and GTK-free — the process exists for one
|
||||||
//! round-trip and then exits, so there is no main loop to keep off. It reuses
|
//! round-trip and then exits, so there is no main loop to keep off. Talking to
|
||||||
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
|
//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
|
||||||
//! activation policy, so the two front ends cannot drift on what "open" means.
|
//! fzf front end and built on the GTK prompt's own [`Hit`](crate::Hit) model
|
||||||
|
//! and activation policy; this module is only presentation.
|
||||||
//!
|
//!
|
||||||
//! Launchers filter a static list; they cannot call back for a new one per
|
//! Launchers filter a static list; they cannot call back for a new one per
|
||||||
//! keystroke. So the interaction is a loop: the first menu shows pinned files
|
//! keystroke. So the interaction is a loop: the first menu shows pinned files
|
||||||
//! and accepts free text, and any text that matches no entry becomes the next
|
//! and accepts free text, and any text that matches no entry becomes the next
|
||||||
//! search. Escape (an empty selection) ends it.
|
//! search. Escape (an empty selection) ends it. `--fzf` ([`crate::fzf`]) is the
|
||||||
|
//! front end for users who want results to appear while they type instead.
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
|
||||||
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
|
||||||
};
|
|
||||||
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
|
||||||
use pdfs_core::opener::{self, OpenWith};
|
use pdfs_core::opener::OpenWith;
|
||||||
|
|
||||||
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
use crate::Hit;
|
||||||
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
|
use crate::query;
|
||||||
|
|
||||||
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
/// Guard against a launcher that keeps handing back text we cannot satisfy —
|
||||||
/// without it a scripted (non-interactive) menu could spin forever.
|
/// without it a scripted (non-interactive) menu could spin forever.
|
||||||
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
.to_string()
|
.to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mountpoint = match request(&socket, Request::Status)? {
|
let mountpoint = query::mountpoint(&socket, &dirs);
|
||||||
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
|
|
||||||
_ => dirs.default_mountpoint(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut query = options
|
let mut query = options
|
||||||
.query
|
.query
|
||||||
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
.filter(|q| !q.is_empty());
|
.filter(|q| !q.is_empty());
|
||||||
for _ in 0..MAX_ROUNDS {
|
for _ in 0..MAX_ROUNDS {
|
||||||
let hits = match &query {
|
let hits = match &query {
|
||||||
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
|
Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
|
||||||
None => pins(&socket)?,
|
None => query::pins(&socket)?,
|
||||||
};
|
};
|
||||||
let items: Vec<MenuItem> = if hits.is_empty() {
|
let items: Vec<MenuItem> = if hits.is_empty() {
|
||||||
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
vec![MenuItem::new(placeholder(query.as_deref()), None)]
|
||||||
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
|
|||||||
query = None;
|
query = None;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
return open(&socket, &mountpoint, &policy, hit, query.is_none());
|
return query::open(&socket, &mountpoint, &policy, hit, query.is_none());
|
||||||
}
|
}
|
||||||
// No entry matched what was typed: treat it as the next query.
|
// No entry matched what was typed: treat it as the next query.
|
||||||
MenuChoice::Custom(text) => query = Some(text),
|
MenuChoice::Custom(text) => query = Some(text),
|
||||||
@@ -118,57 +112,6 @@ fn placeholder(query: Option<&str>) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
|
||||||
/// with, and the only listing the daemon offers without a query.
|
|
||||||
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
|
||||||
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
Ok(pins
|
|
||||||
.into_iter()
|
|
||||||
.map(|pin| {
|
|
||||||
Hit::Drive(SearchHit {
|
|
||||||
name: file_name(&pin.path),
|
|
||||||
path: pin.path,
|
|
||||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
|
||||||
size: 0,
|
|
||||||
modified: 0,
|
|
||||||
pinned: true,
|
|
||||||
uid: pin.uid,
|
|
||||||
mounted_path: None,
|
|
||||||
score: 0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
|
||||||
let reply = request(
|
|
||||||
socket,
|
|
||||||
Request::SearchV2 {
|
|
||||||
query: query.to_string(),
|
|
||||||
limit,
|
|
||||||
filters: SearchFilters {
|
|
||||||
sources: vec![SearchSource::Drive, SearchSource::Local],
|
|
||||||
kind: SearchKind::All,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let Response::SearchResultsV2 {
|
|
||||||
drive_hits,
|
|
||||||
local_hits,
|
|
||||||
..
|
|
||||||
} = reply
|
|
||||||
else {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
};
|
|
||||||
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
|
||||||
hits.extend(local_hits.into_iter().map(Hit::Local));
|
|
||||||
rank_hits(&mut hits);
|
|
||||||
hits.truncate(limit);
|
|
||||||
Ok(hits)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One launcher line per hit. Labels must be unique: they are how the selection
|
/// One launcher line per hit. Labels must be unique: they are how the selection
|
||||||
/// is matched back, and two files of the same name in the same folder listing
|
/// is matched back, and two files of the same name in the same folder listing
|
||||||
/// would otherwise be indistinguishable.
|
/// would otherwise be indistinguishable.
|
||||||
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
|
|||||||
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
}
|
}
|
||||||
items.push(MenuItem::new(label, Some(icon_name(hit))));
|
items.push(MenuItem::new(label, Some(query::icon_name(hit))));
|
||||||
}
|
}
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Icon theme name for a hit.
|
|
||||||
///
|
|
||||||
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
|
||||||
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
|
||||||
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
|
||||||
/// names below exist in every icon theme.
|
|
||||||
fn icon_name(hit: &Hit) -> String {
|
|
||||||
if hit.is_dir() {
|
|
||||||
return "folder".to_string();
|
|
||||||
}
|
|
||||||
let name = hit.name();
|
|
||||||
if is_image(name) {
|
|
||||||
"image-x-generic"
|
|
||||||
} else if is_media(name) {
|
|
||||||
"video-x-generic"
|
|
||||||
} else if is_document(name) {
|
|
||||||
"text-x-generic"
|
|
||||||
} else {
|
|
||||||
"application-x-generic"
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
|
||||||
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
|
||||||
/// else is materialised by the daemon first.
|
|
||||||
fn open(
|
|
||||||
socket: &Path,
|
|
||||||
mountpoint: &Path,
|
|
||||||
policy: &OpenWith,
|
|
||||||
hit: &Hit,
|
|
||||||
from_pins: bool,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
match hit {
|
|
||||||
Hit::Local(local) => {
|
|
||||||
opener::open(policy, Path::new(&local.path), local.is_dir);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Hit::Drive(drive) => {
|
|
||||||
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
|
||||||
// policy rather than node kind, so both go to the mount unchecked.
|
|
||||||
let unconditional = from_pins
|
|
||||||
|| matches!(
|
|
||||||
drive_activation(&drive.name, drive.is_dir),
|
|
||||||
DriveActivation::Folder | DriveActivation::MountedMedia
|
|
||||||
);
|
|
||||||
if unconditional {
|
|
||||||
opener::open(
|
|
||||||
policy,
|
|
||||||
&mounted_or_relative(mountpoint, drive),
|
|
||||||
drive.is_dir,
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// Anything else the mount exposes opens there too, so an editor
|
|
||||||
// saves back into Drive instead of into a cache blob.
|
|
||||||
if let Some(path) = mounted_target(mountpoint, drive) {
|
|
||||||
opener::open(policy, &path, false);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
match request(
|
|
||||||
socket,
|
|
||||||
Request::OpenFile {
|
|
||||||
path: drive.path.clone(),
|
|
||||||
uid: Some(drive.uid.clone()),
|
|
||||||
},
|
|
||||||
)? {
|
|
||||||
Response::FilePath { path } => {
|
|
||||||
// The reply is a content-hash blob; the rules key off the
|
|
||||||
// Drive name, which is what the user actually opened.
|
|
||||||
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
|
||||||
_ => Err("unexpected reply from the daemon".to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One control-socket round-trip, with the daemon-is-down case phrased for a
|
|
||||||
/// terminal rather than a status page.
|
|
||||||
fn request(socket: &Path, request: Request) -> Result<Response, String> {
|
|
||||||
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use pdfs_core::control::LocalHit;
|
use pdfs_core::control::{LocalHit, SearchHit};
|
||||||
|
|
||||||
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
|
||||||
Hit::Drive(SearchHit {
|
Hit::Drive(SearchHit {
|
||||||
@@ -285,6 +142,7 @@ mod tests {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "uid".into(),
|
uid: "uid".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -331,18 +189,4 @@ mod tests {
|
|||||||
assert!(placeholder(None).contains("type to search"));
|
assert!(placeholder(None).contains("type to search"));
|
||||||
assert!(placeholder(Some("md")).contains("md"));
|
assert!(placeholder(Some("md")).contains("md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn icons_are_theme_names_the_launcher_can_look_up() {
|
|
||||||
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
|
|
||||||
assert_eq!(
|
|
||||||
icon_name(&drive("a.png", "a.png", false)),
|
|
||||||
"image-x-generic"
|
|
||||||
);
|
|
||||||
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
|
|
||||||
assert_eq!(
|
|
||||||
icon_name(&drive("a.bin", "a.bin", false)),
|
|
||||||
"application-x-generic"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
//! `pdfs-prompt --fzf`: launcher search that queries the daemon per keystroke.
|
||||||
|
//!
|
||||||
|
//! A dmenu-protocol launcher (fuzzel, rofi, wofi, …) is handed one static list
|
||||||
|
//! and filters it itself; it cannot ask for a new one as the user types, which
|
||||||
|
//! is why [`crate::dmenu`] has to make the search a two-step. `fzf` can:
|
||||||
|
//! `--bind change:reload(…)` re-runs a command on every keystroke. So this
|
||||||
|
//! front end re-executes *this binary* in `--feed` mode, and the daemon's own
|
||||||
|
//! ranking ([`pdfs_core::control::Request::SearchV2`]) decides the order —
|
||||||
|
//! `--disabled` turns fzf's matcher off so it does not re-sort what came back.
|
||||||
|
//!
|
||||||
|
//! Three roles, all the same binary:
|
||||||
|
//!
|
||||||
|
//! * **outer** — `--fzf` with no tty (a WM keybinding): spawn a terminal that
|
||||||
|
//! runs the inner role, and exit.
|
||||||
|
//! * **inner** — `--fzf` with a tty: run fzf, open what was chosen.
|
||||||
|
//! * **feed** — `--feed <TEXT>`: print one line per hit and exit. fzf spawns
|
||||||
|
//! this per keystroke and kills the one it superseded.
|
||||||
|
//!
|
||||||
|
//! A line is `display\tpayload`, shown through `--with-nth=1` so only the left
|
||||||
|
//! half is visible. The payload is the JSON [`Hit`], which is what makes the
|
||||||
|
//! selection unambiguous: unlike the dmenu path, identity does not have to be
|
||||||
|
//! recovered from the label, so [`crate::dmenu::label_all`]'s uniquifying
|
||||||
|
//! suffix has no counterpart here. JSON escapes tabs and newlines, so no file
|
||||||
|
//! name can split a line.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, IsTerminal, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use pdfs_core::config::AppDirs;
|
||||||
|
use pdfs_core::menu::{self, PromptConfig};
|
||||||
|
use pdfs_core::opener::OpenWith;
|
||||||
|
|
||||||
|
use crate::query;
|
||||||
|
use crate::{Hit, SEARCH_DEBOUNCE};
|
||||||
|
|
||||||
|
/// 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 › ";
|
||||||
|
|
||||||
|
pub(crate) struct Options {
|
||||||
|
/// Search for this immediately instead of opening on the pinned files.
|
||||||
|
pub query: Option<String>,
|
||||||
|
/// Set by the terminal we spawned: run fzf here even without a tty rather
|
||||||
|
/// than spawning a second terminal, which would recurse forever if the
|
||||||
|
/// terminal does not give its child one.
|
||||||
|
pub inner: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the fzf flow. Returns an error only for conditions the user must act on
|
||||||
|
/// (no fzf, no terminal, no daemon); a plain cancel is `Ok`.
|
||||||
|
pub(crate) fn run(options: Options) -> Result<(), String> {
|
||||||
|
if options.inner || std::io::stdin().is_terminal() {
|
||||||
|
return interactive(options.query);
|
||||||
|
}
|
||||||
|
spawn_terminal(options.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-launch this binary's inner role inside a terminal.
|
||||||
|
///
|
||||||
|
/// Not waited on: a keybinding should return immediately, and the terminal
|
||||||
|
/// owns the rest of the interaction — including opening the chosen file, which
|
||||||
|
/// `opener` detaches anyway.
|
||||||
|
fn spawn_terminal(query: Option<String>) -> Result<(), String> {
|
||||||
|
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||||
|
let prompt: PromptConfig = dirs.load_config().resolved_prompt();
|
||||||
|
let terminal = menu::resolve_terminal(prompt.terminal.as_ref()).ok_or_else(|| {
|
||||||
|
"no terminal found — install foot/kitty/alacritty, or set prompt.terminal in config.json"
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let argv = menu::with_command(&terminal, &inner_command(self_exe()?, query.as_deref()));
|
||||||
|
let (program, args) = argv
|
||||||
|
.split_first()
|
||||||
|
.ok_or_else(|| "no terminal command configured".to_string())?;
|
||||||
|
Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("cannot run terminal {program}: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The argv the spawned terminal runs.
|
||||||
|
fn inner_command(exe: PathBuf, query: Option<&str>) -> Vec<String> {
|
||||||
|
let mut argv = vec![
|
||||||
|
exe.display().to_string(),
|
||||||
|
"--fzf".to_string(),
|
||||||
|
"--inner".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(text) = query.filter(|text| !text.trim().is_empty()) {
|
||||||
|
argv.push("--query".to_string());
|
||||||
|
argv.push(text.to_string());
|
||||||
|
}
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run fzf here and act on the choice.
|
||||||
|
fn interactive(query: Option<String>) -> Result<(), String> {
|
||||||
|
if !menu::on_path("fzf") {
|
||||||
|
return Err("fzf is not installed — install it, or use --dmenu".to_string());
|
||||||
|
}
|
||||||
|
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
|
||||||
|
let config = dirs.load_config();
|
||||||
|
let prompt: PromptConfig = config.resolved_prompt();
|
||||||
|
let policy: OpenWith = config.resolved_open_with();
|
||||||
|
let socket = dirs.control_socket();
|
||||||
|
|
||||||
|
let query = query
|
||||||
|
.map(|q| q.trim().to_string())
|
||||||
|
.filter(|q| !q.is_empty());
|
||||||
|
// fzf only fires `change` for a *keystroke*, so the first screen — pins, or
|
||||||
|
// the results for an initial --query — has to arrive on stdin.
|
||||||
|
let initial = hits(&socket, query.as_deref(), prompt.resolved_menu_limit());
|
||||||
|
|
||||||
|
let argv = fzf_argv(&self_exe()?, query.as_deref());
|
||||||
|
let (program, args) = argv.split_first().expect("fzf_argv is never empty");
|
||||||
|
let mut child = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("cannot run fzf: {e}"))?;
|
||||||
|
|
||||||
|
// Written on a worker for the same reason the dmenu path does it: fzf reads
|
||||||
|
// stdin only once its UI is up, and a full pipe buffer would deadlock us.
|
||||||
|
let payload: String = initial.iter().map(encode_line).collect();
|
||||||
|
let mut stdin = child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| "fzf stdin unavailable".to_string())?;
|
||||||
|
let writer = std::thread::spawn(move || {
|
||||||
|
let _ = stdin.write_all(payload.as_bytes());
|
||||||
|
let _ = stdin.flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut selected = String::new();
|
||||||
|
if let Some(stdout) = child.stdout.take() {
|
||||||
|
let mut reader = BufReader::new(stdout);
|
||||||
|
let mut line = String::new();
|
||||||
|
while reader.read_line(&mut line).unwrap_or(0) > 0 {
|
||||||
|
if selected.is_empty() {
|
||||||
|
selected = line.trim_end_matches('\n').to_string();
|
||||||
|
}
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = writer.join();
|
||||||
|
|
||||||
|
// Escape, or Enter on the "cannot reach the daemon" row, which carries no
|
||||||
|
// payload — neither is an error.
|
||||||
|
let Some(hit) = decode_line(&selected) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mountpoint = query::mountpoint(&socket, &dirs);
|
||||||
|
// Only the no-query screen is the pin list, and only there does a row's
|
||||||
|
// `is_dir` mean pin policy rather than node kind.
|
||||||
|
query::open(&socket, &mountpoint, &policy, &hit, query.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--feed`: one round-trip, printed for fzf to swallow.
|
||||||
|
///
|
||||||
|
/// Always succeeds. A reload command that exits non-zero leaves fzf showing an
|
||||||
|
/// 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)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
|
||||||
|
let socket = dirs.control_socket();
|
||||||
|
let query = query.map(str::trim).filter(|text| !text.is_empty());
|
||||||
|
|
||||||
|
let stdout = std::io::stdout();
|
||||||
|
let mut out = stdout.lock();
|
||||||
|
for line in hits(&socket, query, limit).iter().map(encode_line) {
|
||||||
|
let _ = out.write_all(line.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows for one screen: pins when there is nothing typed, search results
|
||||||
|
/// otherwise — the same split the GTK prompt makes on an empty entry.
|
||||||
|
fn hits(socket: &std::path::Path, query: Option<&str>, limit: usize) -> Vec<Hit> {
|
||||||
|
let result = match query {
|
||||||
|
Some(text) => query::search(socket, text, limit),
|
||||||
|
None => query::pins(socket),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(hits) => hits,
|
||||||
|
Err(message) => {
|
||||||
|
// Printed as a row rather than to stderr: fzf's alternate screen
|
||||||
|
// hides stderr, so this is the only place the user would see it.
|
||||||
|
println!("({message})");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
|
||||||
|
let feed = format!("{} --feed {{q}}", shell_quote(&exe.display().to_string()));
|
||||||
|
// The sleep is the debounce: fzf kills a reload child that a later keystroke
|
||||||
|
// superseded, so a burst of typing costs one daemon round-trip, exactly as
|
||||||
|
// the GTK prompt's timeout does.
|
||||||
|
let debounce = SEARCH_DEBOUNCE.as_secs_f32();
|
||||||
|
let mut argv: Vec<String> = [
|
||||||
|
"fzf",
|
||||||
|
// fzf's own fuzzy matcher would re-order and re-filter what the daemon
|
||||||
|
// already ranked, and would hide rows it does not think match.
|
||||||
|
"--disabled",
|
||||||
|
"--no-sort",
|
||||||
|
"--no-multi",
|
||||||
|
"--layout=reverse",
|
||||||
|
"--info=inline",
|
||||||
|
"--delimiter=\t",
|
||||||
|
"--with-nth=1",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|arg| (*arg).to_string())
|
||||||
|
.collect();
|
||||||
|
argv.push(format!("--prompt={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.
|
||||||
|
argv.push(format!("--bind=change:reload:sleep {debounce}; {feed}"));
|
||||||
|
if let Some(text) = query {
|
||||||
|
argv.push(format!("--query={text}"));
|
||||||
|
}
|
||||||
|
argv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `display\tpayload\n`. The display half is scrubbed of the delimiter and of
|
||||||
|
/// newlines; the payload is JSON, which escapes both.
|
||||||
|
fn encode_line(hit: &Hit) -> String {
|
||||||
|
let display =
|
||||||
|
format!("{} · {}", hit.name(), hit.location()).replace(['\n', '\r', FIELD], " ");
|
||||||
|
let payload = serde_json::to_string(hit).unwrap_or_default();
|
||||||
|
format!("{display}{FIELD}{payload}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hit a selected line stands for, or `None` for a line that carries no
|
||||||
|
/// payload — the error/empty row, or an empty selection (Escape).
|
||||||
|
fn decode_line(line: &str) -> Option<Hit> {
|
||||||
|
let (_, payload) = line.trim_end_matches('\n').rsplit_once(FIELD)?;
|
||||||
|
serde_json::from_str(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn self_exe() -> Result<PathBuf, String> {
|
||||||
|
std::env::current_exe().map_err(|e| format!("cannot locate pdfs-prompt: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POSIX single-quoting, for the one string that goes through fzf's `sh -c`.
|
||||||
|
fn shell_quote(text: &str) -> String {
|
||||||
|
format!("'{}'", text.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::{LocalHit, SearchHit};
|
||||||
|
|
||||||
|
fn drive(name: &str, path: &str) -> Hit {
|
||||||
|
Hit::Drive(SearchHit {
|
||||||
|
name: name.into(),
|
||||||
|
path: path.into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 7,
|
||||||
|
modified: 9,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: "uid~1".into(),
|
||||||
|
mounted_path: None,
|
||||||
|
score: 42,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_line_round_trips_through_the_payload() {
|
||||||
|
let hit = drive("notes.md", "Docs/notes.md");
|
||||||
|
let line = encode_line(&hit);
|
||||||
|
assert!(line.ends_with('\n'));
|
||||||
|
let back = decode_line(&line).expect("payload decodes");
|
||||||
|
assert_eq!(back.name(), "notes.md");
|
||||||
|
assert_eq!(back.key(), hit.key());
|
||||||
|
assert_eq!(back.score(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_local_hit_round_trips_as_itself() {
|
||||||
|
let hit = Hit::Local(LocalHit {
|
||||||
|
name: "notes.md".into(),
|
||||||
|
path: "/home/me/Docs/notes.md".into(),
|
||||||
|
is_dir: false,
|
||||||
|
size: 1,
|
||||||
|
modified: 2,
|
||||||
|
score: 3,
|
||||||
|
});
|
||||||
|
let back = decode_line(&encode_line(&hit)).expect("payload decodes");
|
||||||
|
assert!(matches!(back, Hit::Local(_)));
|
||||||
|
assert_eq!(back.key(), hit.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_name_with_a_tab_or_newline_stays_one_line() {
|
||||||
|
let hit = drive("two\tparts\nfile.md", "Docs/two\tparts\nfile.md");
|
||||||
|
let line = encode_line(&hit);
|
||||||
|
assert_eq!(line.matches('\n').count(), 1, "{line:?}");
|
||||||
|
assert_eq!(line.matches(FIELD).count(), 1, "{line:?}");
|
||||||
|
assert_eq!(
|
||||||
|
decode_line(&line).expect("payload decodes").name(),
|
||||||
|
"two\tparts\nfile.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_row_without_a_payload_is_not_a_hit() {
|
||||||
|
assert!(decode_line("").is_none());
|
||||||
|
assert!(decode_line("(cannot reach the Proton Drive daemon)").is_none());
|
||||||
|
assert!(decode_line("bogus\tnot json").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fzf_is_told_not_to_do_its_own_matching() {
|
||||||
|
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), None);
|
||||||
|
assert!(argv.contains(&"--disabled".to_string()), "{argv:?}");
|
||||||
|
assert!(argv.contains(&"--no-sort".to_string()), "{argv:?}");
|
||||||
|
assert!(
|
||||||
|
argv.iter().any(|arg| arg.starts_with("--with-nth=")),
|
||||||
|
"the payload field must stay hidden: {argv:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_keystroke_re_runs_the_feed_after_the_debounce() {
|
||||||
|
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), Some("md"));
|
||||||
|
let bind = argv
|
||||||
|
.iter()
|
||||||
|
.find(|arg| arg.starts_with("--bind=change:"))
|
||||||
|
.expect("a change binding");
|
||||||
|
assert!(bind.contains("reload"), "{bind}");
|
||||||
|
assert!(bind.contains("--feed {q}"), "{bind}");
|
||||||
|
assert!(bind.contains(&format!("sleep {}", SEARCH_DEBOUNCE.as_secs_f32())));
|
||||||
|
assert!(argv.contains(&"--query=md".to_string()), "{argv:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_path_with_a_quote_cannot_break_out_of_the_reload_command() {
|
||||||
|
let argv = fzf_argv(std::path::Path::new("/home/o'brien/my prompt"), None);
|
||||||
|
let bind = argv
|
||||||
|
.iter()
|
||||||
|
.find(|arg| arg.starts_with("--bind=change:"))
|
||||||
|
.expect("a change binding");
|
||||||
|
assert!(bind.contains(r"'/home/o'\''brien/my prompt'"), "{bind}");
|
||||||
|
// The unparenthesised reload form: a bracket in the path must not end
|
||||||
|
// the command as far as fzf's own parser is concerned.
|
||||||
|
assert!(bind.starts_with("--bind=change:reload:"), "{bind}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_terminal_runs_the_inner_role_so_it_cannot_recurse() {
|
||||||
|
let argv = inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some("md"));
|
||||||
|
assert_eq!(
|
||||||
|
argv,
|
||||||
|
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner", "--query", "md"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some(" ")),
|
||||||
|
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner"],
|
||||||
|
"a blank query is no query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+266
-88
@@ -1,21 +1,24 @@
|
|||||||
//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client.
|
//! `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
|
//! A StatusNotifierItem (SNI) tray icon whose icon and tooltip say what the
|
||||||
//! showing login/mount status and offering mount, unmount, open-folder and quit.
|
//! drive is doing (synced, syncing, paused, offline, needs attention), with a
|
||||||
//! It talks to the running mount daemon over the same control socket the CLI
|
//! menu to open the app or the folder, pause sync, connect, hide the icon and
|
||||||
//! uses, so the tray is a thin presentation layer over [`pdfs_core::control`].
|
//! 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
|
//! 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
|
//! SNI item via the `ksni` crate.
|
||||||
//! a later slice.
|
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use ksni::blocking::TrayMethods;
|
||||||
use ksni::menu::StandardItem;
|
use ksni::menu::StandardItem;
|
||||||
use ksni::{MenuItem, Tray, TrayService};
|
use ksni::{MenuItem, Status, ToolTip, Tray};
|
||||||
use pdfs_core::auth;
|
use pdfs_core::auth;
|
||||||
use pdfs_core::config::AppDirs;
|
use pdfs_core::config::AppDirs;
|
||||||
use pdfs_core::control::{
|
use pdfs_core::control::{
|
||||||
@@ -26,11 +29,45 @@ use pdfs_core::service;
|
|||||||
/// How often the tray re-polls the daemon to refresh its menu.
|
/// How often the tray re-polls the daemon to refresh its menu.
|
||||||
const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
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.
|
/// Snapshot of what the tray knows about the daemon, recomputed each poll.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct DriveState {
|
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,
|
line: String,
|
||||||
|
phase: Phase,
|
||||||
/// Whether a mount daemon is currently serving the control socket.
|
/// Whether a mount daemon is currently serving the control socket.
|
||||||
mounted: bool,
|
mounted: bool,
|
||||||
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
/// Mountpoint to act on (from the daemon when mounted, else the default).
|
||||||
@@ -38,6 +75,10 @@ struct DriveState {
|
|||||||
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
|
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
|
||||||
/// disabled menu line under the status line.
|
/// disabled menu line under the status line.
|
||||||
sync: String,
|
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
|
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||||
@@ -81,6 +122,7 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
|||||||
|
|
||||||
struct DriveTray {
|
struct DriveTray {
|
||||||
state: DriveState,
|
state: DriveState,
|
||||||
|
socket: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask the daemon for its status, falling back to the stored session so the
|
/// Ask the daemon for its status, falling back to the stored session so the
|
||||||
@@ -93,64 +135,130 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
|||||||
online,
|
online,
|
||||||
pending_uploads,
|
pending_uploads,
|
||||||
pending_changes,
|
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.
|
// 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),
|
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
},
|
};
|
||||||
},
|
let queued = 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 => "Sync paused".to_string(),
|
||||||
|
_ if failing_ops > 0 => match failing_ops {
|
||||||
|
1 => "1 change needs attention".to_string(),
|
||||||
|
n => format!("{n} changes need attention"),
|
||||||
|
},
|
||||||
|
(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}"),
|
||||||
|
},
|
||||||
|
phase,
|
||||||
|
mounted: true,
|
||||||
|
paused,
|
||||||
|
mountpoint: PathBuf::from(mountpoint),
|
||||||
|
sync,
|
||||||
|
failing: failing_ops,
|
||||||
|
}
|
||||||
|
}
|
||||||
// Socket answered but with something unexpected — treat as up but odd.
|
// Socket answered but with something unexpected — treat as up but odd.
|
||||||
Ok(_) => DriveState {
|
Ok(_) => DriveState {
|
||||||
line: "Mount: unexpected daemon response".into(),
|
line: "Mount: unexpected daemon response".into(),
|
||||||
|
phase: Phase::Attention,
|
||||||
mounted: true,
|
mounted: true,
|
||||||
mountpoint: default_mountpoint.to_path_buf(),
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
sync: String::new(),
|
sync: String::new(),
|
||||||
|
paused: false,
|
||||||
|
failing: 0,
|
||||||
},
|
},
|
||||||
// No daemon: describe login state instead so the menu is still useful.
|
// No daemon: describe login state instead so the menu is still useful.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let line = match auth::load() {
|
let (line, phase) = match auth::load() {
|
||||||
Ok(s) => format!("Logged in as {} — not mounted", s.username),
|
Ok(s) => (
|
||||||
Err(pdfs_core::Error::NotLoggedIn) => "Not logged in".into(),
|
format!("Logged in as {} — not mounted", s.username),
|
||||||
Err(e) => format!("Error: {e}"),
|
Phase::Disconnected,
|
||||||
|
),
|
||||||
|
Err(pdfs_core::Error::NotLoggedIn) => ("Not signed in".into(), Phase::SignedOut),
|
||||||
|
Err(e) => (format!("Error: {e}"), Phase::Attention),
|
||||||
};
|
};
|
||||||
DriveState {
|
DriveState {
|
||||||
line,
|
line,
|
||||||
|
phase,
|
||||||
mounted: false,
|
mounted: false,
|
||||||
mountpoint: default_mountpoint.to_path_buf(),
|
mountpoint: default_mountpoint.to_path_buf(),
|
||||||
sync: String::new(),
|
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 {
|
||||||
|
match failing {
|
||||||
|
1 => "1 issue — View".to_string(),
|
||||||
|
n => format!("{n} issues — View"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_folder(mountpoint: &Path) {
|
fn open_folder(mountpoint: &Path) {
|
||||||
pdfs_core::opener::open_default(mountpoint, true);
|
pdfs_core::opener::open_default(mountpoint, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launch (or, since it's `SingleMainWindow`, raise) the settings/management
|
/// Launch (or, since it's `SingleMainWindow`, raise) the desktop app with
|
||||||
/// window. The app binary lives next to the tray binary; fall back to the bare
|
/// `args`. The app binary lives next to the tray binary; fall back to the bare
|
||||||
/// name so a PATH install still works.
|
/// name so a PATH install still works.
|
||||||
fn open_manager() {
|
fn open_manager(args: &[&str]) {
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
|
||||||
.filter(|p| p.exists())
|
.filter(|p| p.exists())
|
||||||
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
|
.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());
|
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 {
|
impl Tray for DriveTray {
|
||||||
fn id(&self) -> String {
|
fn id(&self) -> String {
|
||||||
"io.narl.proton-drive-linux".into()
|
"io.narl.proton-drive-linux".into()
|
||||||
@@ -161,91 +269,121 @@ impl Tray for DriveTray {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn icon_name(&self) -> String {
|
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>> {
|
fn menu(&self) -> Vec<MenuItem<Self>> {
|
||||||
// Value captured into the action closure (which only gets `&mut Self`).
|
// Value captured into the action closure (which only gets `&mut Self`).
|
||||||
let open_mp = self.state.mountpoint.clone();
|
let open_mp = self.state.mountpoint.clone();
|
||||||
|
let label = |text: &str| -> MenuItem<Self> {
|
||||||
let mut items: Vec<MenuItem<Self>> = vec![
|
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: self.state.line.clone(),
|
label: text.to_string(),
|
||||||
enabled: false,
|
enabled: false,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into()
|
||||||
];
|
};
|
||||||
|
let item = |text: &str, run: fn(&mut Self)| -> MenuItem<Self> {
|
||||||
// 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,
|
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Open Manager".into(),
|
label: text.to_string(),
|
||||||
activate: Box::new(|_: &mut Self| open_manager()),
|
activate: Box::new(run),
|
||||||
..Default::default()
|
..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("Sign In…", |_| open_manager(&[]))),
|
||||||
|
_ => items.push(item("Open Proton Drive", |_| open_manager(&[]))),
|
||||||
|
}
|
||||||
|
|
||||||
if self.state.mounted {
|
if self.state.mounted {
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Open folder".into(),
|
label: "Open Folder".into(),
|
||||||
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
|
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
// Pause holds uploads back but leaves the mount readable, so it
|
||||||
// login or reboot brings it back; this is a deliberate disconnect.
|
// sits apart from Stop, which takes the whole mount away.
|
||||||
|
let paused = self.state.paused;
|
||||||
items.push(
|
items.push(
|
||||||
StandardItem {
|
StandardItem {
|
||||||
label: "Disconnect".into(),
|
label: if paused { "Resume Sync" } else { "Pause Sync" }.into(),
|
||||||
activate: Box::new(move |this: &mut Self| {
|
activate: Box::new(move |this: &mut Self| {
|
||||||
service::stop();
|
let request = Request::SetSyncPaused {
|
||||||
this.state.mounted = false;
|
paused: !paused,
|
||||||
this.state.line = "Disconnecting…".into();
|
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()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
} else {
|
} else if self.state.phase == Phase::Disconnected {
|
||||||
// Enable+start the service so it mounts now and on future logins.
|
// Enable+start the service so it mounts now and on future logins.
|
||||||
items.push(
|
items.push(item("Connect", |this| {
|
||||||
StandardItem {
|
service::enable_start();
|
||||||
label: "Connect".into(),
|
this.state.line = "Connecting…".into();
|
||||||
activate: Box::new(|this: &mut Self| {
|
}));
|
||||||
service::enable_start();
|
|
||||||
this.state.line = "Connecting…".into();
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(MenuItem::Separator);
|
items.push(MenuItem::Separator);
|
||||||
items.push(
|
items.push(item("Hide Tray Icon", |_| hide_tray()));
|
||||||
StandardItem {
|
if self.state.mounted {
|
||||||
label: "Quit".into(),
|
// Stopping unmounts the drive, so the app asks first; the tray has no
|
||||||
activate: Box::new(|_: &mut Self| std::process::exit(0)),
|
// GTK of its own to ask with.
|
||||||
..Default::default()
|
items.push(item("Stop Proton Drive…", |_| {
|
||||||
}
|
open_manager(&["--confirm-stop"])
|
||||||
.into(),
|
}));
|
||||||
);
|
} else {
|
||||||
|
items.push(item("Quit", |_| std::process::exit(0)));
|
||||||
|
}
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +396,12 @@ fn main() -> Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let dirs = AppDirs::new().context("resolve app dirs")?;
|
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.
|
// Ensure only one instance of the tray runs.
|
||||||
let tray_sock = dirs.tray_socket();
|
let tray_sock = dirs.tray_socket();
|
||||||
@@ -266,7 +410,7 @@ fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let _ = std::fs::remove_file(&tray_sock);
|
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")?;
|
.context("failed to bind tray single-instance socket")?;
|
||||||
// Owner-only like the control socket (B6). This one only guards single
|
// 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
|
// instancing, so a failure is logged rather than fatal — but there is no
|
||||||
@@ -275,20 +419,34 @@ fn main() -> Result<()> {
|
|||||||
tracing::warn!(error = %e, "could not restrict tray socket permissions");
|
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 socket = dirs.control_socket();
|
||||||
let default_mountpoint = dirs.default_mountpoint();
|
let default_mountpoint = dirs.default_mountpoint();
|
||||||
|
|
||||||
let tray = DriveTray {
|
let tray = DriveTray {
|
||||||
state: poll_state(&socket, &default_mountpoint),
|
state: poll_state(&socket, &default_mountpoint),
|
||||||
|
socket: socket.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let service = TrayService::new(tray);
|
// The tray autostarts with the session and can beat the desktop's SNI
|
||||||
let handle = service.handle();
|
// watcher onto the bus. Without `assume_sni_available` that race is a hard
|
||||||
std::thread::spawn(move || {
|
// spawn error; with it the item waits and registers once the watcher shows.
|
||||||
if let Err(e) = service.run() {
|
let handle = tray
|
||||||
tracing::error!("tray service stopped: {e:?}");
|
.assume_sni_available(true)
|
||||||
}
|
.spawn()
|
||||||
});
|
.context("start tray service")?;
|
||||||
|
|
||||||
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
|
// Poll the daemon forever, pushing each fresh snapshot into the tray so the
|
||||||
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
|
||||||
@@ -298,3 +456,23 @@ fn main() -> Result<()> {
|
|||||||
handle.update(move |t: &mut DriveTray| t.state = st);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
|
|||||||
|
|
||||||
mod activation;
|
mod activation;
|
||||||
mod dmenu;
|
mod dmenu;
|
||||||
|
mod fzf;
|
||||||
|
mod query;
|
||||||
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
|
||||||
|
|
||||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||||
@@ -125,7 +127,7 @@ fn is_media(name: &str) -> bool {
|
|||||||
/// One row in the unified result list. The two sections hold different payloads
|
/// One row in the unified result list. The two sections hold different payloads
|
||||||
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
|
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
|
||||||
/// local file is already on disk — but they share one keyboard cursor.
|
/// local file is already on disk — but they share one keyboard cursor.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||||
enum Hit {
|
enum Hit {
|
||||||
Drive(SearchHit),
|
Drive(SearchHit),
|
||||||
Local(LocalHit),
|
Local(LocalHit),
|
||||||
@@ -341,12 +343,20 @@ Options:
|
|||||||
rofi, …) instead of the built-in GTK window. Also
|
rofi, …) instead of the built-in GTK window. Also
|
||||||
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
|
||||||
in config.json.
|
in config.json.
|
||||||
|
--fzf Search in fzf, in a terminal, re-querying the daemon on
|
||||||
|
every keystroke — results appear as you type, unlike
|
||||||
|
--dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }.
|
||||||
--gtk Force the built-in window, overriding that setting.
|
--gtk Force the built-in window, overriding that setting.
|
||||||
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
--menu <COMMAND> Launcher command line for --dmenu, e.g.
|
||||||
--menu 'fuzzel --dmenu --width 60'. Overrides
|
--menu 'fuzzel --dmenu --width 60'. Overrides
|
||||||
\"prompt\": { \"menu\": [...] }.
|
\"prompt\": { \"menu\": [...] }.
|
||||||
--query <TEXT> Search for TEXT immediately instead of opening on the
|
--query <TEXT> Search for TEXT immediately instead of opening on the
|
||||||
pinned-files list (--dmenu only).
|
pinned-files list (--dmenu and --fzf only).
|
||||||
|
--feed <TEXT> Print the hits for TEXT, one per line, and exit. This is
|
||||||
|
what --fzf re-runs on each keystroke; it is not meant to
|
||||||
|
be typed.
|
||||||
|
--inner Run fzf here rather than spawning a terminal. Set by the
|
||||||
|
terminal --fzf spawns; not meant to be typed.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
";
|
";
|
||||||
|
|
||||||
@@ -356,6 +366,12 @@ struct Args {
|
|||||||
mode: Option<PromptMode>,
|
mode: Option<PromptMode>,
|
||||||
menu: Option<Vec<String>>,
|
menu: Option<Vec<String>>,
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
|
/// `--feed`: print hits for this text and exit. `Some(None)` is `--feed`
|
||||||
|
/// with an empty argument, which fzf sends for an empty input — a real
|
||||||
|
/// request for the pinned list, not an absent flag.
|
||||||
|
feed: Option<Option<String>>,
|
||||||
|
/// `--inner`: we are the process a spawned terminal is running.
|
||||||
|
inner: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
||||||
@@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
|
|||||||
mode: None,
|
mode: None,
|
||||||
menu: None,
|
menu: None,
|
||||||
query: None,
|
query: None,
|
||||||
|
feed: None,
|
||||||
|
inner: false,
|
||||||
};
|
};
|
||||||
let mut argv = argv.peekable();
|
let mut argv = argv.peekable();
|
||||||
while let Some(arg) = argv.next() {
|
while let Some(arg) = argv.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
|
||||||
|
"--fzf" => args.mode = Some(PromptMode::Fzf),
|
||||||
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
"--gtk" => args.mode = Some(PromptMode::Gtk),
|
||||||
|
"--inner" => args.inner = true,
|
||||||
|
// fzf passes an empty argument for an empty input, so a missing
|
||||||
|
// value here is that case rather than a user error.
|
||||||
|
"--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())),
|
||||||
// A launcher command is one shell-ish string so it can live in a
|
// A launcher command is one shell-ish string so it can live in a
|
||||||
// keybinding; splitting on whitespace is enough for flags, and a
|
// keybinding; splitting on whitespace is enough for flags, and a
|
||||||
// launcher argument needing spaces belongs in config.json.
|
// launcher argument needing spaces belongs in config.json.
|
||||||
@@ -416,16 +439,31 @@ fn main() -> glib::ExitCode {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A feed is fzf's own reload child, not a front end: it must print hits and
|
||||||
|
// nothing else, so it is answered before the stored mode is even read.
|
||||||
|
if let Some(query) = args.feed {
|
||||||
|
fzf::feed(query.as_deref());
|
||||||
|
return glib::ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
// The stored mode makes an existing keybinding switch front ends without
|
// The stored mode makes an existing keybinding switch front ends without
|
||||||
// being re-bound; an explicit flag still wins.
|
// being re-bound; an explicit flag still wins.
|
||||||
let stored = AppDirs::new()
|
let stored = AppDirs::new()
|
||||||
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
|
let launcher = match args.mode.unwrap_or(stored) {
|
||||||
return match dmenu::run(dmenu::Options {
|
PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
|
||||||
menu: args.menu,
|
menu: args.menu,
|
||||||
query: args.query,
|
query: args.query,
|
||||||
}) {
|
})),
|
||||||
|
PromptMode::Fzf => Some(fzf::run(fzf::Options {
|
||||||
|
query: args.query,
|
||||||
|
inner: args.inner,
|
||||||
|
})),
|
||||||
|
PromptMode::Gtk => None,
|
||||||
|
};
|
||||||
|
if let Some(result) = launcher {
|
||||||
|
return match result {
|
||||||
Ok(()) => glib::ExitCode::SUCCESS,
|
Ok(()) => glib::ExitCode::SUCCESS,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
eprintln!("pdfs-prompt: {message}");
|
eprintln!("pdfs-prompt: {message}");
|
||||||
@@ -870,6 +908,7 @@ impl Ui {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
cached: pin.cached,
|
||||||
uid: pin.uid,
|
uid: pin.uid,
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -1326,7 +1365,7 @@ fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
|||||||
if is_dir {
|
if is_dir {
|
||||||
return gio::ThemedIcon::new("folder").upcast();
|
return gio::ThemedIcon::new("folder").upcast();
|
||||||
}
|
}
|
||||||
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]);
|
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None);
|
||||||
gio::functions::content_type_get_icon(&content_type)
|
gio::functions::content_type_get_icon(&content_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1575,6 +1614,7 @@ mod tests {
|
|||||||
size: 1,
|
size: 1,
|
||||||
modified: 1,
|
modified: 1,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "v~a".into(),
|
uid: "v~a".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 100,
|
score: 100,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! The daemon side of both launcher front ends: ask for hits, open a chosen one.
|
||||||
|
//!
|
||||||
|
//! `--dmenu` and `--fzf` differ only in how they present a list and collect a
|
||||||
|
//! choice. Everything between the control socket and the chosen file lives here
|
||||||
|
//! so the two cannot drift on what a result set is or what "open" means — the
|
||||||
|
//! same reason both already share [`Hit`] and [`rank_hits`] with the GTK prompt.
|
||||||
|
//!
|
||||||
|
//! Everything here is blocking and GTK-free: the callers are one-shot processes
|
||||||
|
//! with no main loop to keep free.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use pdfs_core::config::AppDirs;
|
||||||
|
use pdfs_core::control::{
|
||||||
|
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
||||||
|
};
|
||||||
|
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};
|
||||||
|
|
||||||
|
/// Where the mount lives, for turning a Drive path into something an
|
||||||
|
/// application can open. Falls back to the configured default so a momentarily
|
||||||
|
/// unreachable daemon still produces a plausible path rather than an error.
|
||||||
|
pub(crate) fn mountpoint(socket: &Path, dirs: &AppDirs) -> PathBuf {
|
||||||
|
match request(socket, Request::Status) {
|
||||||
|
Ok(Response::Status { mountpoint, .. }) => PathBuf::from(mountpoint),
|
||||||
|
_ => dirs.default_mountpoint(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
|
||||||
|
/// with, and the only listing the daemon offers without a query.
|
||||||
|
pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||||
|
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
Ok(pins
|
||||||
|
.into_iter()
|
||||||
|
.map(|pin| {
|
||||||
|
Hit::Drive(SearchHit {
|
||||||
|
name: file_name(&pin.path),
|
||||||
|
path: pin.path,
|
||||||
|
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
pinned: true,
|
||||||
|
cached: pin.cached,
|
||||||
|
uid: pin.uid,
|
||||||
|
mounted_path: None,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
|
||||||
|
let reply = request(
|
||||||
|
socket,
|
||||||
|
Request::SearchV2 {
|
||||||
|
query: query.to_string(),
|
||||||
|
limit,
|
||||||
|
filters: SearchFilters {
|
||||||
|
sources: vec![SearchSource::Drive, SearchSource::Local],
|
||||||
|
kind: SearchKind::All,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let Response::SearchResultsV2 {
|
||||||
|
drive_hits,
|
||||||
|
local_hits,
|
||||||
|
..
|
||||||
|
} = reply
|
||||||
|
else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
|
||||||
|
hits.extend(local_hits.into_iter().map(Hit::Local));
|
||||||
|
rank_hits(&mut hits);
|
||||||
|
hits.truncate(limit);
|
||||||
|
Ok(hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon theme name for a hit.
|
||||||
|
///
|
||||||
|
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
|
||||||
|
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
|
||||||
|
/// an empty buffer answers `application-x-zerosize` for everything. The generic
|
||||||
|
/// names below exist in every icon theme.
|
||||||
|
pub(crate) fn icon_name(hit: &Hit) -> String {
|
||||||
|
if hit.is_dir() {
|
||||||
|
return "folder".to_string();
|
||||||
|
}
|
||||||
|
let name = hit.name();
|
||||||
|
if is_image(name) {
|
||||||
|
"image-x-generic"
|
||||||
|
} else if is_media(name) {
|
||||||
|
"video-x-generic"
|
||||||
|
} else if is_document(name) {
|
||||||
|
"text-x-generic"
|
||||||
|
} else {
|
||||||
|
"application-x-generic"
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
|
||||||
|
/// disk, a Drive folder or streamable file goes through the mount, and anything
|
||||||
|
/// else is materialised by the daemon first.
|
||||||
|
pub(crate) fn open(
|
||||||
|
socket: &Path,
|
||||||
|
mountpoint: &Path,
|
||||||
|
policy: &OpenWith,
|
||||||
|
hit: &Hit,
|
||||||
|
from_pins: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match hit {
|
||||||
|
Hit::Local(local) => {
|
||||||
|
opener::open(policy, Path::new(&local.path), local.is_dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Hit::Drive(drive) => {
|
||||||
|
// A folder has no materialised form, and a pin row's `is_dir` is pin
|
||||||
|
// policy rather than node kind, so both go to the mount unchecked.
|
||||||
|
let unconditional = from_pins
|
||||||
|
|| matches!(
|
||||||
|
drive_activation(&drive.name, drive.is_dir),
|
||||||
|
DriveActivation::Folder | DriveActivation::MountedMedia
|
||||||
|
);
|
||||||
|
if unconditional {
|
||||||
|
opener::open(
|
||||||
|
policy,
|
||||||
|
&mounted_or_relative(mountpoint, drive),
|
||||||
|
drive.is_dir,
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Anything else the mount exposes opens there too, so an editor
|
||||||
|
// saves back into Drive instead of into a cache blob.
|
||||||
|
if let Some(path) = mounted_target(mountpoint, drive) {
|
||||||
|
opener::open(policy, &path, false);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match request(
|
||||||
|
socket,
|
||||||
|
Request::OpenFile {
|
||||||
|
path: drive.path.clone(),
|
||||||
|
uid: Some(drive.uid.clone()),
|
||||||
|
},
|
||||||
|
)? {
|
||||||
|
Response::FilePath { path } => {
|
||||||
|
// The reply is a content-hash blob; the rules key off the
|
||||||
|
// Drive name, which is what the user actually opened.
|
||||||
|
opener::open_named(policy, Path::new(&path), &drive.name, false);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Response::Error { message, .. } => Err(format!("could not open: {message}")),
|
||||||
|
_ => Err("unexpected reply from the daemon".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn drive(name: &str, is_dir: bool) -> Hit {
|
||||||
|
Hit::Drive(SearchHit {
|
||||||
|
name: name.into(),
|
||||||
|
path: name.into(),
|
||||||
|
is_dir,
|
||||||
|
size: 0,
|
||||||
|
modified: 0,
|
||||||
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
|
uid: "uid".into(),
|
||||||
|
mounted_path: None,
|
||||||
|
score: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icons_are_theme_names_the_launcher_can_look_up() {
|
||||||
|
assert_eq!(icon_name(&drive("Photos", true)), "folder");
|
||||||
|
assert_eq!(icon_name(&drive("a.png", false)), "image-x-generic");
|
||||||
|
assert_eq!(icon_name(&drive("a.md", false)), "text-x-generic");
|
||||||
|
assert_eq!(icon_name(&drive("a.bin", false)), "application-x-generic");
|
||||||
|
}
|
||||||
|
}
|
||||||
+340
@@ -12,6 +12,336 @@ Conventions:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
(B92); no library with an undecryptable photo in it has been driven against the fix.
|
||||||
|
**Found:** 2026-09-19, by reading `photos_replace` before making a refresh skip photos it has
|
||||||
|
already resolved. Skipping one means writing a `TimelineRow` that carries only the uid and the
|
||||||
|
capture time, which is exactly the row a failed resolve already produced.
|
||||||
|
|
||||||
|
**Where:** `crates/pdfs-core/src/db/photos.rs` (`Learned`, `photos_replace`).
|
||||||
|
|
||||||
|
**Cause.** `photos_replace` keeps what a refresh could not learn — ratio, thumbnail verdict, media
|
||||||
|
type, favourite flag, content hash, photo relation all survive a `None`. The `name` did not:
|
||||||
|
`Learned` never held it, the SELECT never read it, and the INSERT bound `row.name` straight
|
||||||
|
through. So a photo the SDK could not decrypt (it logs and skips the node) had its stored name
|
||||||
|
overwritten with `NULL` on every refresh. That is not a cosmetic loss — both the tab split and the
|
||||||
|
grouping are derived from the name. `PhotoKind::classify` is extension-first, so a nameless RAW
|
||||||
|
file classifies as a still photo and leaves the Raw tab; `name_stem` returns `None`, so the RAW and
|
||||||
|
the JPEG of one shot stop being one shot; and `redate` skips nameless photos outright.
|
||||||
|
|
||||||
|
**What changed.** `Learned` carries the name like everything else, and the kind and the group stem
|
||||||
|
are computed from the carried name rather than the incoming row. A row that *was* resolved is now
|
||||||
|
authoritative instead: its fields are taken as they are, so a photo unlinked from its main on
|
||||||
|
another device finally loses its `main_uid` — a carry could never express "the server says none".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B92 — Refreshing the gallery reported "Resource temporarily unavailable"
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified) — the daemon carrying the change has not been driven against a real
|
||||||
|
library yet.
|
||||||
|
**Found:** 2026-09-19, while checking why a 12,445-photo timeline still showed the pre-grouping
|
||||||
|
layout after pressing Refresh. `pdfs refresh photos` failed after about two minutes with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Caused by:
|
||||||
|
0: io: Resource temporarily unavailable (os error 11)
|
||||||
|
1: Resource temporarily unavailable (os error 11)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Where:** `crates/pdfs-fuse/src/control.rs` (`RefreshScope::Photos`),
|
||||||
|
`crates/pdfs-core/src/control.rs` (`READ_TIMEOUT`, the new `PhotosRefreshStatus`),
|
||||||
|
`crates/pdfs-gui/src/app/main.rs` (`refresh_then`), `crates/pdfs-cli/src/main.rs` (`cmd_refresh`).
|
||||||
|
|
||||||
|
**Cause.** `Refresh { Photos }` awaited `refresh_timeline()` before replying, on the reasoning that
|
||||||
|
somebody who presses Refresh wants the truth rather than a stale page. But the refresh resolves
|
||||||
|
every photo's node in batches of `TIMELINE_ENRICH_CHUNK` (200), so a 12,445-photo library is 63
|
||||||
|
server round-trips — minutes of work. The client's read bound is `READ_TIMEOUT` = 120 s, so the
|
||||||
|
socket timed out with `EAGAIN` while the refresh was running perfectly. In the GUI the timeout was
|
||||||
|
invisible: `refresh_then` ignores the reply and loads the page anyway, which re-read the *old*
|
||||||
|
timeline and made Refresh look like a dead button.
|
||||||
|
|
||||||
|
**What changed.** The daemon starts the refresh (`spawn_timeline_refresh`, which already dedupes)
|
||||||
|
and replies `refreshing` at once. `Request::PhotosRefreshStatus` reports whether one is running;
|
||||||
|
the gallery polls it every 2 s and reloads the page when it clears, and `pdfs refresh photos`
|
||||||
|
polls in the same way so the command still means "the timeline is up to date when I return".
|
||||||
|
`refresh_timeline` now logs `photos`, `chunks` and `elapsed_ms`.
|
||||||
|
|
||||||
|
**What changed, second pass (2026-09-19).** The refresh is incremental. `photos.resolved_at`
|
||||||
|
(schema v31) records that a photo's node was read, and `refresh_timeline` resolves only the photos
|
||||||
|
that have no such mark — the ones it has never read, and the ones a remote event marked stale.
|
||||||
|
A library that has already been read costs one `enumerate_timeline` and no `enumerate_nodes` call
|
||||||
|
at all.
|
||||||
|
|
||||||
|
The favourite question resolved by moving it off the refresh and onto the event feed:
|
||||||
|
`classify_photo_events` turns a `NodeUpdated` into `photos_mark_unresolved(uid)` instead of a
|
||||||
|
blanket "re-read everything", so a favourite toggled on a phone costs one node read rather than
|
||||||
|
63. A local toggle is claimed as our own echo (`note_self_change`), because the feed replays it and
|
||||||
|
a `NodeUpdated` carries nothing that says what changed. `ContinuityLost` / `ScopeAccessLost` and
|
||||||
|
`pdfs refresh photos --full` drop every mark, which is the one way back to a whole-library read.
|
||||||
|
`TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT` — a chunk of 200 was split
|
||||||
|
there into a full request and a 50-uid tail.
|
||||||
|
|
||||||
|
**Still open.** `invalidate_albums()` drives the same pattern per album
|
||||||
|
(`crates/pdfs-fuse/src/albums.rs:96` and `:191`). It is bounded by album size rather than library
|
||||||
|
size, so it is nowhere near as expensive, but the technique that fixed this would apply there too.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B91 — The daemon hung overnight, and nothing could say what it was waiting on
|
||||||
|
|
||||||
|
**Status:** Open — the hang itself is undiagnosed. What is fixed (unverified) is the
|
||||||
|
*undiagnosability*: the daemon now reports what it is doing, warns when it stops making progress,
|
||||||
|
and is restarted by systemd when it stops answering.
|
||||||
|
**Found:** 2026-09-18, from a user report — the mount stopped responding and only came back after
|
||||||
|
`systemctl --user restart`.
|
||||||
|
**Where:** `crates/pdfs-fuse/src/{workers.rs,diagnostics.rs,supervisor.rs,systemd.rs,mount.rs}`,
|
||||||
|
`packaging/proton-drive.service`.
|
||||||
|
|
||||||
|
**Symptom.** The mount stops answering. The process is alive and is not spinning; there is no
|
||||||
|
panic, no error, and no log line after the moment it stopped. The accounting for the hung run:
|
||||||
|
|
||||||
|
```
|
||||||
|
Consumed 43min 39.045s CPU time over 20h 8min 15.733s wall clock time,
|
||||||
|
5.2G memory peak, 65.7M memory swap peak
|
||||||
|
```
|
||||||
|
|
||||||
|
It went silent at 17:10 and stayed that way until the manual stop at 13:18:54 the next day. A
|
||||||
|
restart picked everything back up, which rules out corrupt local state.
|
||||||
|
|
||||||
|
**What made it undiagnosable.** Nothing in the daemon could be asked what it was doing:
|
||||||
|
|
||||||
|
- the worker pool published no state at all — not which threads were busy, not with what, not for
|
||||||
|
how long, not how deep the queues were;
|
||||||
|
- control requests were not tracked, so a handler that never returned looked exactly like a
|
||||||
|
handler that was never asked for;
|
||||||
|
- there was no watchdog, so a daemon that stopped answering stayed "active (running)" for twenty
|
||||||
|
hours;
|
||||||
|
- resident size was never sampled, so the 5.2 G peak was only visible in systemd's post-mortem.
|
||||||
|
|
||||||
|
**Related, and a real bug on its own.** At 2026-09-18 09:58:35 a meta worker panicked during
|
||||||
|
shutdown:
|
||||||
|
|
||||||
|
```
|
||||||
|
thread 'pdfs-fuse-meta-0' (3044) panicked at ...:
|
||||||
|
A Tokio 1.x context was found, but it is being shutdown.
|
||||||
|
```
|
||||||
|
|
||||||
|
The pool was flagged closed at teardown but never joined, so the process could drop the tokio
|
||||||
|
runtime while a worker was still inside a job. `catch_unwind` caught it, which is why it was only
|
||||||
|
ever a log line — but it is teardown ordering, not an error.
|
||||||
|
|
||||||
|
**What changed.**
|
||||||
|
|
||||||
|
- `Workers` publishes per-thread state (busy, job label, age) plus queue depths and per-lane
|
||||||
|
completion counts, all outside the queue lock, and every FUSE handler labels the job it hands
|
||||||
|
over.
|
||||||
|
- `Request::Diagnostics` / `pdfs diagnostics` reports that, the in-flight control requests with
|
||||||
|
their ages, the handler count against its limit, resident size and the pending-op count. It is
|
||||||
|
built to answer while the daemon is wedged: atomics and `try_lock` only, with the one database
|
||||||
|
read on a throwaway thread behind a 500 ms deadline.
|
||||||
|
- A supervisor thread WARNs when a worker has held one job for 120 s, when a control request has
|
||||||
|
run that long, or when a lane has a queue but completed nothing since the last tick; it samples
|
||||||
|
RSS every 5 minutes.
|
||||||
|
- The supervisor answers the systemd watchdog, but only after a real round trip over the control
|
||||||
|
socket — so the ping means "a client can still talk to this daemon" rather than "a thread is
|
||||||
|
still scheduled". `WatchdogSec=120` and `Restart=always` turn the next hang into a restart
|
||||||
|
instead of a morning of downtime, and `MemoryHigh=2G`/`MemoryMax=6G` bound the growth.
|
||||||
|
- Teardown joins the worker pool, with a 10 s deadline, before the caller drops the runtime.
|
||||||
|
|
||||||
|
**Still open.** None of this says *why* it hung. The candidates the evidence does not yet separate:
|
||||||
|
the 5.2 G working set (a leak, or a legitimately huge listing retained), a lock held across an
|
||||||
|
await somewhere in the control plane, and the ~150 `rt.block_on` sites on the control path that
|
||||||
|
have no op-level deadline of their own — each individual HTTP call is bounded by the SDK's 30 s
|
||||||
|
API / 300 s storage timeouts, but a sequence of them is not. The next occurrence should be a
|
||||||
|
one-line answer from `pdfs diagnostics` or from the stall WARN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B90 — The local-file indexer walks into foreign FUSE mounts, and a dead one makes the daemon unkillable
|
||||||
|
|
||||||
|
**Status:** Fixed (unverified) — the walker no longer stats a nested mountpoint. The stuck-daemon
|
||||||
|
half cannot be fixed in userspace at all; see "Why there is no second line of defence".
|
||||||
|
**Found:** 2026-09-16, from a user report of `systemctl --user restart` failing in a loop.
|
||||||
|
**Where:** `crates/pdfs-core/src/localindex.rs` (`default_excludes`, `scan`),
|
||||||
|
`crates/pdfs-fuse/src/background.rs` (`scan_local_once`).
|
||||||
|
|
||||||
|
**Symptom.** Stopping the service times out, SIGKILL does not help, and every subsequent start
|
||||||
|
fails with `another Proton Drive daemon is already using .../cache.db`:
|
||||||
|
|
||||||
|
```
|
||||||
|
systemd[2196]: proton-drive.service: State 'final-sigterm' timed out. Killing.
|
||||||
|
systemd[2196]: proton-drive.service: Killing process 2221 (pdfs) with signal SIGKILL.
|
||||||
|
systemd[2196]: proton-drive.service: Processes still around after final SIGKILL. Entering failed mode.
|
||||||
|
systemd[2196]: proton-drive.service: Unit process 2221 (pdfs) remains running after unit stopped.
|
||||||
|
pdfs[73921]: ERROR mount failed; retrying in 5s error="open cache db: another Proton Drive daemon
|
||||||
|
is already using /home/narl/.local/state/proton-drive-linux/cache.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis.** The surviving process is a zombie that still has a live thread:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat /proc/2221/status | grep -E 'State|Threads'
|
||||||
|
State: Z (zombie)
|
||||||
|
Threads: 2
|
||||||
|
$ cat /proc/2221/task/73231/comm; cat /proc/2221/task/73231/stack
|
||||||
|
pdfs-localindex
|
||||||
|
request_wait_answer
|
||||||
|
```
|
||||||
|
|
||||||
|
`pdfs-localindex` is parked in a FUSE request nobody will answer, in uninterruptible `D` state, so
|
||||||
|
it ignores SIGKILL. The thread group leader exits, the thread does not, the process cannot be
|
||||||
|
reaped — and it keeps the `cache.db` lock, which is what turns one stuck scan into a permanent
|
||||||
|
restart loop. The same dead connection also blocked an unrelated `duf` in `fuse_statfs`
|
||||||
|
(`/sys/fs/fuse/connections/69/waiting` was 2).
|
||||||
|
|
||||||
|
**Cause.** `scan_local_once` walks `$HOME`, and `default_excludes` only excluded the Drive
|
||||||
|
mountpoint plus our own state and cache dirs. The reporter's home also held
|
||||||
|
`~/remote/narl.io` (sshfs) and `~/remote/gdrive` (rclone) — third-party FUSE mounts the walk
|
||||||
|
descends into.
|
||||||
|
|
||||||
|
`scan` does set `same_file_system(true)`, which looks like it should already cover this. It does
|
||||||
|
not: in `ignore` 0.4.33 the check is `is_same_file_system` → `device_num(path)` →
|
||||||
|
`path.metadata()` (`walk.rs:2164`), i.e. **a stat of the mountpoint**, performed in `run_one`
|
||||||
|
*after* the directory has been queued. To discover that a mount is foreign, the walker first has
|
||||||
|
to touch it. Against a network FUSE mount whose server is gone, that stat never returns.
|
||||||
|
|
||||||
|
**Fix.** `localindex::nested_mount_points` reads `/proc/self/mounts` and returns every mountpoint
|
||||||
|
strictly below the scan root; `scan_local_once` appends those to the excludes. `filter_entry` runs
|
||||||
|
before the device stat (`walk.rs:1926`, before `send`), so a path-only rejection keeps the walker
|
||||||
|
from ever touching the mount. Mountpoint fields are octal-unescaped, because a `\040` left in
|
||||||
|
place would silently fail to match and put the mount back in the walk. Re-read per scan, not
|
||||||
|
cached at startup: a mount that appeared since the daemon started is the likeliest to be a
|
||||||
|
half-alive network filesystem.
|
||||||
|
|
||||||
|
This changes nothing about what gets indexed. A nested mount is a different device, so
|
||||||
|
`same_file_system(true)` was always going to skip it — the exclude only removes the stat that had
|
||||||
|
to happen first.
|
||||||
|
|
||||||
|
**Why there is no second line of defence.** Once a thread is in uninterruptible FUSE wait, no
|
||||||
|
userspace change rescues it: not a join timeout, not `std::process::exit`, not SIGKILL. The kernel
|
||||||
|
will not reap the task until the syscall returns, which needs someone to answer the request or
|
||||||
|
abort the connection (`echo 1 > /sys/fs/fuse/connections/<id>/abort`, root-only). Avoiding entry is
|
||||||
|
the entire fix. That makes any *new* code path that stats a user-supplied or mount-crossing path
|
||||||
|
from a daemon thread a repeat of this bug — mirror-folder scanning is the obvious next candidate.
|
||||||
|
|
||||||
|
**Recovery for an already-wedged daemon.** As root, abort the connection with waiters
|
||||||
|
(`grep . /sys/fs/fuse/connections/*/waiting` finds it), which releases the thread, lets the zombie
|
||||||
|
be reaped and frees the `cache.db` lock. A reboot also clears it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## B89 — The recorded SDK dependency is three minor versions out of date in the working memory
|
## B89 — The recorded SDK dependency is three minor versions out of date in the working memory
|
||||||
|
|
||||||
**Status:** Open — documentation only, but it misdirects every reader of the SDK code.
|
**Status:** Open — documentation only, but it misdirects every reader of the SDK code.
|
||||||
@@ -2650,6 +2980,16 @@ collisions without losing the source. Simple rename-only and move-only calls
|
|||||||
remain synchronous. The managed live suite subsequently passed the replacement
|
remain synchronous. The managed live suite subsequently passed the replacement
|
||||||
and combined move/rename cases.
|
and combined move/rename cases.
|
||||||
|
|
||||||
|
**Follow-up (2026-09-15, unverified):** With SDK 0.6.3 the drain sends the
|
||||||
|
move and the rename as one `move-multiple` request carrying the target name,
|
||||||
|
instead of renaming in the source folder and then moving. That removes the
|
||||||
|
second call the stale requirements could hit, and the SDK retries a remaining
|
||||||
|
`InvalidRequirements` once with the server's current name hash. A destination
|
||||||
|
name collision now moves under a conflict name in one step. Same-folder renames
|
||||||
|
are unchanged. The synchronous FUSE `rename` path (`serve_rename`) still renames
|
||||||
|
then moves with its bounded retry and has not been converted. Needs a managed
|
||||||
|
live run of the combined move/rename cases.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
|
## B47 — FUSE Accepts Path Components Longer Than NAME_MAX
|
||||||
|
|||||||
@@ -9,6 +9,537 @@ 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
|
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).
|
scratch (user data in `staging/` and `recovery/` is never touched by this).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
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: **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
|
||||||
|
- **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape:
|
||||||
|
photos are taken in capture order until they no longer fit the target height, and the row is
|
||||||
|
then scaled to land exactly on the right margin. Nothing is cropped and nothing is
|
||||||
|
letterboxed, which is what the square centre-cropped tiles used to do to portraits and
|
||||||
|
panoramas. The last row of a day stays at the target height rather than being stretched, and
|
||||||
|
Ctrl+scroll / Ctrl+± now changes that target height. Gaps and tile corners are smaller, so the
|
||||||
|
page reads as photos rather than as cards.
|
||||||
|
- **Thumbnails stay on screen while you scroll.** A decoded thumbnail now lands in the texture
|
||||||
|
cache even when the tile that asked for it has already been recycled, the cache is a real LRU
|
||||||
|
holding 1500 textures instead of 600, and the rows just past the edge of the window are
|
||||||
|
fetched ahead of the scroll direction. Scrolling fast through a large timeline no longer
|
||||||
|
leaves a screen of blank tiles behind.
|
||||||
|
- **Refresh on the Photos page really refreshes.** Instead of only clearing the freshness stamp
|
||||||
|
and letting the next read serve the stale page, it starts the timeline re-read and follows it:
|
||||||
|
the page paints from what the daemon holds, then paints again when the new timeline lands. The
|
||||||
|
timeline is also considered stale after 60 s rather than 5 minutes. `pdfs refresh photos`
|
||||||
|
blocks until the re-read is done, as it reads.
|
||||||
|
|
||||||
|
- **Restore puts back the whole tree.** The trash is a flat list, but you delete a shape: a
|
||||||
|
folder, and often things you had already deleted inside it. Restoring a folder now also
|
||||||
|
restores the items that were trashed inside it, and restoring one of those items first
|
||||||
|
restores the trashed folders above it — otherwise the file came back into a folder that is
|
||||||
|
still in the trash, which is to say nowhere you can see it. Parents are restored before their
|
||||||
|
children, and each node is still restored individually, so one node the server refuses does
|
||||||
|
not cancel the rest.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **A RAW and its JPEG are one photo.** A camera that writes both put two tiles of the same shot
|
||||||
|
in the timeline; now it puts one, badged `RAW`, and the lightbox has a control that switches
|
||||||
|
between the files. The Raw tab still lists every raw file individually, because that is what
|
||||||
|
the tab is for. Grouping follows the server's own photo relation where it has one — which
|
||||||
|
also collapses live photos and bursts — and otherwise pairs files with the same name taken on
|
||||||
|
the same day where one is raw and the other is not. Deleting the tile deletes the whole shot,
|
||||||
|
and the confirmation says how many files that is.
|
||||||
|
- **Delete from the gallery.** Select photos with the Select button, with Ctrl+click or with
|
||||||
|
Shift+click, then Move to Trash — or press Delete in the lightbox. The tiles leave the grid
|
||||||
|
immediately with an Undo toast, and the photos land in Proton's trash, where they stay
|
||||||
|
recoverable. A photo the server refuses to trash comes back into the grid.
|
||||||
|
- **A photo deleted on your phone disappears here.** The daemon now follows the photos volume's
|
||||||
|
own event stream, so a photo trashed on another device leaves the gallery within about ten
|
||||||
|
seconds instead of waiting for the timeline to go stale.
|
||||||
|
|
||||||
|
- **`pdfs diagnostics`.** Reports what the daemon is doing right now: every worker thread with the job
|
||||||
|
it holds and for how long, queue depths per lane, the control requests still in flight, resident
|
||||||
|
size and the pending-op count. Answers even while the daemon is wedged, which is the only time
|
||||||
|
anyone asks (B91).
|
||||||
|
- **A stall watchdog.** The daemon warns in the journal when a worker has held one job for two
|
||||||
|
minutes, when a control request has run that long, or when a queue is not draining — and it now
|
||||||
|
answers the systemd watchdog, but only after a real round trip over its own control socket. A
|
||||||
|
daemon that stops answering is restarted instead of sitting there looking healthy (B91).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A Pixel raw capture groups with its JPEG.** Pixel names the two files of one shot
|
||||||
|
`PXL_….RAW-01.COVER.jpg` and `PXL_….RAW-02.ORIGINAL.dng`, so their stems differ and the pairing
|
||||||
|
rule never saw them as one shot. The part-of-a-shot marker is now cut off before the names are
|
||||||
|
compared, and the grid shows the cover JPEG once. The grouping is computed by a timeline
|
||||||
|
refresh, so an existing library pairs up after the next one.
|
||||||
|
- **The Raw tab no longer decodes a raw it does not have to.** A raw file that was written next
|
||||||
|
to a JPEG of the same shot is now given that JPEG's thumbnail, which the server usually already
|
||||||
|
has. No raw download and no raw decode for those tiles.
|
||||||
|
- **Photos can be moved to Trash from the gallery again.** Photos live on the photo volume, which
|
||||||
|
the mount never holds, so the write check found no node to consult and refused every photo with
|
||||||
|
"trash access: not allowed". Membership of our own timeline now answers for them; an album photo
|
||||||
|
on someone else's volume is still refused.
|
||||||
|
- **A photos refresh no longer fails with "Resource temporarily unavailable" (B92).** Re-reading a
|
||||||
|
twelve-thousand-photo library resolves the nodes in batches of 200 and takes minutes, but the
|
||||||
|
daemon held the control reply until it had finished — well past the client's 120 s read bound.
|
||||||
|
The caller therefore saw `io: Resource temporarily unavailable (os error 11)` for a refresh that
|
||||||
|
was running perfectly, and the gallery reloaded from the old timeline. The daemon now answers as
|
||||||
|
soon as the refresh is running, and the new `PhotosRefreshStatus` request reports when it is
|
||||||
|
done. Each refresh also logs its photo count, batch count and duration.
|
||||||
|
- **A worker no longer panics during shutdown (B91).** The FUSE worker pool is joined before the
|
||||||
|
tokio runtime is dropped, so a worker still inside a job does not find a runtime that is going
|
||||||
|
away underneath it.
|
||||||
|
- **A permanently rejected block upload is no longer retried (SDK 0.6.5).** The SDK now knows
|
||||||
|
which API errors are worth replaying: 4xx is permanent, except 404, 408 and 429.
|
||||||
|
- **Long downloads no longer fail on expired block URLs (SDK 0.6.5).** A download whose block
|
||||||
|
URLs lapse mid-transfer re-mints them once instead of failing, on the whole-file path and on
|
||||||
|
public links, as reads already did.
|
||||||
|
- **A long rate-limit answer can no longer look like a hang (SDK 0.6.5).** A server-supplied
|
||||||
|
`Retry-After` is now capped at 120 s.
|
||||||
|
|
||||||
|
## [1.10.2] — 2026-09-16
|
||||||
|
|
||||||
|
Fixes a daemon that could not be stopped or restarted. No schema change.
|
||||||
|
Schema: **28**; no SDK change.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A dead network mount in your home directory no longer wedges the daemon (B90).** The
|
||||||
|
"This computer" file indexer walked `$HOME` and descended into other FUSE mounts — an sshfs
|
||||||
|
whose server had gone away, an unresponsive rclone. Stat'ing one of those blocks
|
||||||
|
uninterruptibly, so the indexer thread stopped responding to SIGKILL, the daemon could never
|
||||||
|
exit, `systemctl --user stop` timed out, and the half-dead process kept the cache database lock
|
||||||
|
that the next start needed: every restart then failed with "another Proton Drive daemon is
|
||||||
|
already using cache.db". The indexer now skips mountpoints below your home directory by path,
|
||||||
|
without touching them. Nothing changes about which files are indexed — those mounts were always
|
||||||
|
excluded from the results, just not before they had been stat'ed.
|
||||||
|
|
||||||
|
## [1.10.1] — 2026-09-15
|
||||||
|
|
||||||
|
Dependency upgrade release, clearing both open Dependabot advisories. No schema change.
|
||||||
|
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.4**.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **`lru` IterMut Stacked Borrows advisory.** The SDK now depends on `lru` 0.18.
|
||||||
|
- **`atty` unaligned read advisory.** The tray moved to `ksni` 0.3, which no longer pulls
|
||||||
|
`dbus-codegen` → `clap` 2 → `atty`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Keyring backend is `keyring-core` + `dbus-secret-service-keyring-store`** (keyring 4's split).
|
||||||
|
Same libdbus Secret Service as before; stored sessions written by earlier releases are still
|
||||||
|
found, so no re-login is needed.
|
||||||
|
- **The tray waits for a late StatusNotifierWatcher** instead of failing to start when it beats
|
||||||
|
the desktop's tray host onto the session bus.
|
||||||
|
- Upgraded `gtk4` 0.11, `libadwaita` 0.9, `webkit6` 0.6, `rusqlite` 0.40 (bundled SQLite),
|
||||||
|
`fuser` 0.18, `directories` 6, `sha1`/`sha2` 0.11, `base64` 0.23.
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-09-15
|
||||||
|
|
||||||
|
One-request move-and-rename in the drain, and who shared an item with you. No schema change.
|
||||||
|
Schema: **28**; SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.3**.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared with me says who shared an item, and when.** The GUI row subtitle and `pdfs
|
||||||
|
shared-with-me` show the inviter and invite date for a share root. The invitation's signature is
|
||||||
|
checked against the claimed inviter's keys; when it does not verify, the GUI shows a warning
|
||||||
|
and the CLI prints one, since the sender may be forged. `DirEntry` gains `shared_by`,
|
||||||
|
`shared_at` and `shared_by_unverified` (defaulted, so older front-ends and daemons interoperate).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A queued move-and-rename lands as one request.** `drain_rename` used to rename in the source
|
||||||
|
folder and then move, with a rename-out-of-the-way step when the destination held the name. It
|
||||||
|
now calls `move-multiple` with a target name, so there is no half-applied state between the two
|
||||||
|
halves and no second call for the encrypted-name requirements to go stale against (B46). A
|
||||||
|
rename that stays in the same folder still uses the rename endpoint.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Moving a node with no signature email no longer fails.** Such nodes (created anonymously, e.g.
|
||||||
|
through a public link) need the passphrase signed by the mover; SDK 0.6.3 sends that signature.
|
||||||
|
- **A node trashed through the newer API field is recognised as trashed.** The SDK now reads
|
||||||
|
`TrashTime` as well as the legacy `Trashed` field, so the drain no longer tries to rename a node
|
||||||
|
that is already in the trash.
|
||||||
|
|
||||||
|
## [1.9.3] — 2026-09-01
|
||||||
|
|
||||||
|
The daemon's wait-for-login loop, and the latency that fixing it introduced. No schema change.
|
||||||
|
Schema: **28**; SDK unchanged at `proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **An enabled service with no saved session no longer polls the keyring 28,800 times a day.**
|
||||||
|
`wait_for_session` slept a flat 3 s between checks, and a check is not just a sleep — `auth::load`
|
||||||
|
builds a fresh `keyring::Entry`, so every iteration opened a new D-Bus connection to the Secret
|
||||||
|
Service and negotiated a session on it. The wait now backs off, and "not logged in" is said once
|
||||||
|
at `info` instead of on every pass, which also stops the journal filling with tens of thousands
|
||||||
|
of identical lines (`#21`).
|
||||||
|
- **Signing in wakes the parked daemon instead of leaving the mount up to an interval late.** The
|
||||||
|
backoff had no wake-up path: a unit started at boot reaches the ceiling within ~90 s, and
|
||||||
|
`enable --now` is a no-op on an already-active unit, so the GUI login and the tray's Connect left
|
||||||
|
it sleeping. `service::enable_start` now runs `enable` + `restart`, which interrupts the sleep and
|
||||||
|
still starts a unit that is down, and `pdfs login` restarts the unit when it is already active —
|
||||||
|
deliberately without touching `enable`, so a headless setup driving `pdfs daemon` by hand is left
|
||||||
|
alone. The ceiling also drops from 60 s to 15 s, still an 80% cut in keyring/D-Bus wakeups
|
||||||
|
(28,800/day → 5,760/day), bounding the worst case for paths that never reach systemd.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The wait is extracted into `wait_for_session()`, taking the load and sleep calls as parameters,
|
||||||
|
so the schedule is testable without a keyring and without real time passing.
|
||||||
|
|
||||||
|
## [1.9.2] — 2026-08-28
|
||||||
|
|
||||||
|
Repair for photos a Takeout import filed under the day they were imported, an `fzf` launcher front
|
||||||
|
end, and a gallery that survives 1,600 photos on one date. No schema change. Schema: **28**; SDK
|
||||||
|
`proton-sdk` / `proton-drive-rs` **0.6.2**.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`pdfs redate-photos`: repair photos filed under the day they were imported.** A Takeout whose
|
||||||
|
metadata sidecars did not match imports with no capture time at all, and the SDK then seals
|
||||||
|
"now" into every revision — a decade of photos lands on one date. The date is still in the file
|
||||||
|
name, so this finds photos whose name disagrees with their stored capture time by more than a
|
||||||
|
day and rewrites them: download, re-upload at the right time, carry favourites and album
|
||||||
|
memberships across, then **trash** (never delete) the original — Proton has no API for editing a
|
||||||
|
sealed capture time. `--dry-run` reports without touching anything, `--from`/`--to` aim the run
|
||||||
|
at the days an import landed on, and `--limit` caps it. Followed with `pdfs redate-status`,
|
||||||
|
stopped with `pdfs cancel-redate`.
|
||||||
|
|
||||||
|
- **`pdfs-prompt --fzf`: launcher search that updates as you type.** A dmenu-style launcher
|
||||||
|
(fuzzel, rofi, wofi, …) is handed one static list and filters it itself, which is why `--dmenu`
|
||||||
|
has to make searching two steps — type, Enter, *then* results. `fzf` can re-run a command on
|
||||||
|
every keystroke, so the new mode re-queries the daemon per keystroke (debounced to match the GTK
|
||||||
|
prompt) and keeps `--disabled` on so the daemon's ranking, not fzf's matcher, decides the order.
|
||||||
|
Abbreviation and typo tolerance therefore work here exactly as they do in the built-in window.
|
||||||
|
- Started without a tty — from a WM keybinding — `--fzf` spawns a terminal for itself (foot,
|
||||||
|
ghostty, kitty, alacritty, wezterm or xterm, whichever is installed), each with the app-id/class
|
||||||
|
`pdfs-prompt` so a single window rule floats it. Override with `"prompt": { "terminal": [...] }`,
|
||||||
|
where a `{cmd}` token places the command and its absence appends it. `"prompt": { "mode": "fzf" }`
|
||||||
|
makes it the default front end for an existing keybinding.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Google Photos Takeout: sidecars with a localized suffix are matched again.** Google localizes
|
||||||
|
the `.supplemental-metadata` it appends to a photo's JSON sidecar — a German export writes
|
||||||
|
`.ergänzende-Metadaten` — and the scanner only knew the English spelling. No sidecar matched, so
|
||||||
|
no photo had a capture time, so every photo in the export was stamped with the moment it was
|
||||||
|
uploaded. Matching now hangs on the photo name in front of the suffix rather than on the suffix
|
||||||
|
itself, which is locale-agnostic, and covers the misspelt variants Google has also shipped.
|
||||||
|
- **A photo with no usable sidecar now takes its date from its file name.** `PXL_20260818_171030868.jpg`,
|
||||||
|
`IMG_20230219_171030.jpg`, `Screenshot_20230101-102950.png`, `IMG-20230219-WA0001.jpg` and
|
||||||
|
`Screenshot from 2023-01-01 10-29-50.png` all carry the date the photo was taken. Read as UTC, so
|
||||||
|
it can be hours out — but never the years out that "the moment it was imported" is. A sidecar
|
||||||
|
still wins.
|
||||||
|
|
||||||
|
- **An opened file or folder no longer dies with the window that opened it.** Everything launched
|
||||||
|
through `open_with` was started in the caller's process group with the caller's stdio, which was
|
||||||
|
invisible from a resident GUI but fatal from `--fzf`: the prompt exits the moment you choose
|
||||||
|
something, its terminal closes, and the pty hangup killed the application it had just started —
|
||||||
|
so an `@dir` rule like `alacritty --working-directory {}` opened nothing at all. Opened children
|
||||||
|
now get their own process group and no inherited stdio. Affects every front end; only `--fzf`
|
||||||
|
could observe the bug.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The gallery virtualises at the row, not the day.** The Photos ListView had one item per day,
|
||||||
|
and a bind built every one of that day's tiles — fine for a normal day, ruinous for the ~1,600
|
||||||
|
photos a Takeout import drops onto a single date, which was 1,600 widgets constructed on the main
|
||||||
|
thread before the section could be shown. Days are now flattened into heading rows and grid rows,
|
||||||
|
so a bind is a handful of widgets whatever the day holds. A resize or a zoom step rebuilds the row
|
||||||
|
model (the column count decides which photos share a row) and scrolls back to the photo that was
|
||||||
|
at the top.
|
||||||
|
- Updated to `proton-sdk` / `proton-drive-rs` 0.6.2 (photos save-to-timeline, `enumerate_events`).
|
||||||
|
|
||||||
|
- The daemon side of both launcher front ends — search, pins, mountpoint lookup and the
|
||||||
|
open-a-result policy — moved out of `pdfs-gui/src/dmenu.rs` into a shared `pdfs-gui/src/query.rs`,
|
||||||
|
so `--dmenu` and `--fzf` cannot drift on what a result set is or what "open" means. No behaviour
|
||||||
|
change to `--dmenu`.
|
||||||
|
|
||||||
|
## [1.9.1] — 2026-08-21
|
||||||
|
|
||||||
|
Thumbnails everywhere the GUI lists files, a status bar under the Files browser, and the
|
||||||
|
repository's contribution/branding files. No schema change. Schema: **28**; SDK unchanged at
|
||||||
|
`proton-sdk` / `proton-drive-rs` **0.6.1**.
|
||||||
|
|
||||||
|
`exiftool` (`libimage-exiftool-perl` / `perl-image-exiftool` / `perl-Image-ExifTool`) is a new
|
||||||
|
runtime dependency, declared in the `.deb`, `.rpm` and `PKGBUILD` packaging. It is only used to
|
||||||
|
pull the embedded preview out of a camera RAW; without it every other image format still gets a
|
||||||
|
thumbnail.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Thumbnails for ordinary Drive files**, not just the Photos timeline — painted in Files (both
|
||||||
|
the grid and the Name column of the list), Shared, Shared by me, and Trash. Standard raster
|
||||||
|
formats (`bmp`, `gif`, `jpeg`, `png`, `tiff`, `webp`) are decoded in the daemon; camera RAWs
|
||||||
|
(`arw`, `cr2`, `cr3`, `dng`, `nef`, `orf`, `raf`, `rw2`, and the rest of the concrete list) are
|
||||||
|
served from their embedded preview via `exiftool`. Previews are cached beside the existing photo
|
||||||
|
thumbnails, outside the cache-budget scan, and tagged with the node's modification time — so a
|
||||||
|
new revision of an image can never be shown with its predecessor's thumbnail.
|
||||||
|
- Thumbnails for visible rows are requested opportunistically as tiles scroll in, and the daemon
|
||||||
|
drops work for listings the user has already navigated away from. The listing generation is
|
||||||
|
issued by the daemon (`ReserveFileThumbGeneration`), not seeded from the front-end's clock.
|
||||||
|
- **Build a whole folder tree ahead of time** from the Files toolbar: one recursive local-thumbnail
|
||||||
|
job per daemon, with live scan/found/completed/failed progress and a Cancel that appears only
|
||||||
|
once the daemon has acknowledged the start. The job outlives navigation — it is deliberate work,
|
||||||
|
separate from the opportunistic per-tile requests.
|
||||||
|
- **A Dolphin-style status bar under the Files browser**: the current listing's counts, a Zoom
|
||||||
|
slider for the icon grid, and account storage usage. Zoom resizes the realised grid cells rather
|
||||||
|
than rebuilding the model, so dragging the slider does not tear down the selection; the default
|
||||||
|
size reproduces the previous fixed 72 px tiles. Capacity stays out of the bar until a real
|
||||||
|
reading lands, and the quota fetch (60 s TTL) now also runs while Files is on screen instead of
|
||||||
|
only in Settings.
|
||||||
|
- Repository housekeeping: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and pull
|
||||||
|
request templates, a project logo, and workspace crate metadata (`repository`, `homepage`,
|
||||||
|
`readme`, `keywords`, `categories`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A remote thumbnail miss no longer suppresses local generation. The two negative results are now
|
||||||
|
held in separate bounded caches: "Proton has no thumbnail for this node" and "this exact revision
|
||||||
|
was decoded locally and could not be read" mean different things, and only the second is a
|
||||||
|
permanent verdict for that revision.
|
||||||
|
- A RAW whose preview could not be extracted because of the *environment* — `exiftool` missing, a
|
||||||
|
failed download — is retried instead of being remembered as undecodable. Only bytes conclusively
|
||||||
|
outside the supported formats are cached as a negative.
|
||||||
|
- A recursive build that finds a uid an opportunistic tile job already owns waits briefly for it
|
||||||
|
rather than either duplicating the work or blocking on it forever, and a cancellation racing a
|
||||||
|
waiter's registration is caught by a backstop instead of leaving the waiter parked.
|
||||||
|
- Starting a build for a root that is already building attaches to that job; starting one for a
|
||||||
|
*different* root is refused explicitly rather than silently reporting the running job's progress
|
||||||
|
as the caller's.
|
||||||
|
- Decrypted camera RAWs are staged for `exiftool` in an owner-only (`0700`) directory of their own,
|
||||||
|
removed as soon as the preview is out and swept at cache open after an unclean shutdown.
|
||||||
|
- The RAW extension list had `rw` (not a format) and was missing `raw`; both classification helpers
|
||||||
|
now read from one shared table instead of two hand-maintained `matches!` arms.
|
||||||
|
- The tray and desktop-entry icons were wrong/missing; `.desktop` files and the packaged SVG were
|
||||||
|
corrected.
|
||||||
|
|
||||||
|
## [1.9.0] — 2026-08-18
|
||||||
|
|
||||||
|
Google Photos import, and a pass over the desktop app's interaction gaps. No schema change.
|
||||||
|
Schema: **28**. Builds against the published `proton-sdk` / `proton-drive-rs` **0.6.1** — the album
|
||||||
|
writes this release needs are in the registry crates, so the local `[patch.crates-io]` shim the
|
||||||
|
feature was developed against is gone and no local path dependency remains.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Import a Google Photos Takeout export into Proton Photos** — `pdfs import-google-photos
|
||||||
|
takeout-*.zip` (add `--dry-run` to see what it would do, `--wait` to follow it), or the import
|
||||||
|
button in the Photos header, which multi-selects the export's zips. Albums in the export are
|
||||||
|
recreated as Proton albums and their photos filed into them; year folders and `Archive/` import
|
||||||
|
into the timeline; `Trash/` is skipped. Capture times and favourites come from Google's per-photo
|
||||||
|
JSON sidecars, including the truncated and `(N)`-swapped sidecar names Google produces.
|
||||||
|
- Photos already on the account are **not re-uploaded**: the import asks the photos volume which of
|
||||||
|
the export's names exist (batched, 150 per request), reads and SHA-1s only those files, and skips
|
||||||
|
the ones whose content digest matches too — Proton's own duplicate rule. A library already partly
|
||||||
|
synced by Proton Photos on a phone therefore imports only what is missing, and an interrupted
|
||||||
|
import resumes by being run again. Google keeps a full copy of a photo in every album folder it
|
||||||
|
appears in; those fold into one upload that joins several albums.
|
||||||
|
- The import streams each photo straight out of its zip, so an export needs no scratch space to
|
||||||
|
unpack into, and reports as a job in `pdfs transfers` and the GUI's activity view. `pdfs
|
||||||
|
import-status` shows the counts, `pdfs cancel-import` stops it — a cancelled run still files what
|
||||||
|
it uploaded into its albums.
|
||||||
|
- **An Import page in the GUI** (Settings → *Import from Google Photos*, or the import button in
|
||||||
|
the Photos header). Archives are staged by drag-and-drop onto the page or through the file
|
||||||
|
chooser, listed with their sizes so the set can be checked before anything is sent, and launched
|
||||||
|
as either a Scan (dry run) or a confirmed Import. The page shows live progress off the import's
|
||||||
|
job, keeps the finished run's report on screen, and can stop a run in flight; because the import
|
||||||
|
outlives the page, it is polled wherever the user navigates and its completion is reported as a
|
||||||
|
desktop notification.
|
||||||
|
|
||||||
|
- SDK (`proton-sdk-rs` **0.6.1**): album **writes** — `ProtonPhotosClient::create_album` and
|
||||||
|
`add_photos_to_album`, ported from the TypeScript SDK (upstream C# has no album write API), plus
|
||||||
|
batch duplicate detection (`find_duplicates_many`, `name_collisions`). The workspace deps moved
|
||||||
|
`"0.6.0"` → `"0.6.1"` and `Cargo.lock` re-resolved to the registry crates. Adding a photo from an
|
||||||
|
album *shared with us* (a different volume) is still unported and fails that photo's outcome.
|
||||||
|
- **Multi-select in Files.** Both views select a batch (click-drag, Ctrl-click, Shift-click); a bar
|
||||||
|
slides in reporting the count and offering Move to Trash and offline on/off for the whole
|
||||||
|
selection. Delete acts on everything selected, Esc clears it, and right-clicking inside a
|
||||||
|
selection offers the batch actions rather than the one row under the pointer. Actions that need a
|
||||||
|
single subject — rename, move, share, revisions — stay single-entry, and the details pane steps
|
||||||
|
aside for a batch instead of speaking for it.
|
||||||
|
- **Undo on trash.** "Moved *X* to Trash" now carries an Undo that restores the batch, single files
|
||||||
|
included.
|
||||||
|
- **The empty states are a place to act**: an empty folder offers Upload files / New folder, and an
|
||||||
|
empty photo timeline offers Upload photos / Import from Google Photos, instead of describing what
|
||||||
|
the user could go and do elsewhere.
|
||||||
|
- **Ctrl+F works from any page**, navigating to Files and focusing the search box, rather than being
|
||||||
|
silently inert everywhere but Files.
|
||||||
|
- Staged Takeout archives survive closing the window; archives that have since been moved or deleted
|
||||||
|
are dropped from the restored set.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Settings showed the local cache as "X of 0 B used" when no cache budget was set. An unset budget
|
||||||
|
means *unlimited*, so it now reads "X cached — no limit set" rather than implying a zero cap.
|
||||||
|
- The cache-budget editor fired a `SetCacheBudget` round-trip — and a toast — on every step, so a
|
||||||
|
run of `+` clicks applied a series of caps the user never asked for and stacked a column of
|
||||||
|
toasts. It now waits for the value to settle.
|
||||||
|
- A long pin list pushed everything below it off the end of Settings. It shows the first six with a
|
||||||
|
"Show all N" row for the rest.
|
||||||
|
|
||||||
## [1.8.2] — 2026-08-17
|
## [1.8.2] — 2026-08-17
|
||||||
|
|
||||||
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
|
Closes the two findings 1.8.1's live verification produced (B85, B86) and finishes the data-safety
|
||||||
@@ -439,6 +970,15 @@ 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
|
- The outstanding FUSE defects tracked in `docs/BUGS.md`, plus a truncate defect, validated
|
||||||
by a new POSIX compliance suite for the filesystem.
|
by a new POSIX compliance suite for the filesystem.
|
||||||
|
|
||||||
|
[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
|
||||||
|
[1.9.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.9.0
|
||||||
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
[1.8.2]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.2
|
||||||
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
[1.8.1]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.1
|
||||||
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
[1.8.0]: https://github.com/narrrl/proton-drive-linux/releases/tag/v1.8.0
|
||||||
|
|||||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user