mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16: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 | ||
|
|
078774f6a1 | ||
|
|
326a9b4e92 | ||
|
|
78dc72a34d | ||
|
|
438b2b463e | ||
|
|
4cd49168c4 | ||
|
|
481c3fe0c7 | ||
|
|
098da2e97a | ||
|
|
d03d3eac3f | ||
|
|
433e206203 | ||
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 |
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in the client misbehaves
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Please check [`docs/BUGS.md`](../blob/main/docs/BUGS.md) first — the issue may already be
|
||||||
|
tracked there with context.
|
||||||
|
|
||||||
|
**Do not report a security vulnerability here.** See [SECURITY.md](../blob/main/SECURITY.md).
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
placeholder: |
|
||||||
|
1. …
|
||||||
|
2. …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Output of `pdfs --version`, and how you installed it (deb / rpm / AUR / source).
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Distribution and desktop
|
||||||
|
placeholder: "Fedora 44, GNOME 48 (Wayland)"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where does it happen
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount / file operations
|
||||||
|
- Sync or backed-up folders (Computers)
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Login / keyring
|
||||||
|
- Photos
|
||||||
|
- Packaging / installation
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: diagnose
|
||||||
|
attributes:
|
||||||
|
label: "`pdfs diagnose` output"
|
||||||
|
description: Redact paths or the account address if you prefer.
|
||||||
|
render: console
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Daemon logs
|
||||||
|
description: "`journalctl --user -u proton-drive.service -n 200 --no-pager`"
|
||||||
|
render: console
|
||||||
|
- type: checkboxes
|
||||||
|
id: dataloss
|
||||||
|
attributes:
|
||||||
|
label: Data
|
||||||
|
options:
|
||||||
|
- label: This bug involved missing, truncated, or overwritten file content.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/security/advisories/new
|
||||||
|
about: Report privately — never in a public issue.
|
||||||
|
- name: Known issues
|
||||||
|
url: https://github.com/narrrl/proton-drive-linux/blob/main/docs/BUGS.md
|
||||||
|
about: The authoritative ledger of tracked bugs and their status.
|
||||||
|
- name: Proton Drive service problems
|
||||||
|
url: https://proton.me/support
|
||||||
|
about: This is an unofficial client. Account or service issues go to Proton AG.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a capability or an improvement
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that the client makes hard or impossible today?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: What you'd like to see
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Which part of the client
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- FUSE mount
|
||||||
|
- Sync / Computers
|
||||||
|
- CLI (pdfs)
|
||||||
|
- Desktop app (pdfs-app)
|
||||||
|
- Tray (pdfs-tray)
|
||||||
|
- Search launcher (pdfs-prompt)
|
||||||
|
- Photos
|
||||||
|
- Packaging
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives you considered
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- The user-visible effect, in a sentence or two. -->
|
||||||
|
|
||||||
|
**Crates touched:**
|
||||||
|
**Related `docs/BUGS.md` entries:**
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Plus whatever else you ran: acceptance suite, a live mount, manual steps. -->
|
||||||
|
|
||||||
|
## Implications
|
||||||
|
|
||||||
|
- [ ] Adds a SQLite migration (new `MIGRATION_V*`, `SCHEMA_VERSION` bumped — no shipped migration edited)
|
||||||
|
- [ ] Touches recovery, staging, or anything that can be the only copy of user data
|
||||||
|
- [ ] Changes packaging, the systemd unit, or desktop entries
|
||||||
|
- [ ] Changes the control protocol (daemon and front ends updated together)
|
||||||
|
- [ ] None of the above
|
||||||
|
|
||||||
|
<!-- Screenshots for GTK changes. -->
|
||||||
@@ -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
+568
-789
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.6.0"
|
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.5.1" }
|
proton-sdk = { version = "0.6.6" }
|
||||||
proton-drive-rs = { version = "0.5.1" }
|
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]]
|
||||||
|
|||||||
+1113
-37
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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1240
-94
File diff suppressed because it is too large.
Load diff
@@ -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(),
|
||||||
|
|||||||
+776
-12
File diff suppressed because it is too large.
Load diff
@@ -33,7 +33,7 @@ impl Db {
|
|||||||
/// whose stored `kind` no longer parses (written by an older build) are
|
/// whose stored `kind` no longer parses (written by an older build) are
|
||||||
/// skipped rather than failing the whole read.
|
/// skipped rather than failing the whole read.
|
||||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT time, kind, target, detail, ok FROM activity
|
"SELECT time, kind, target, detail, ok FROM activity
|
||||||
ORDER BY id DESC LIMIT ?1",
|
ORDER BY id DESC LIMIT ?1",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ impl Db {
|
|||||||
|
|
||||||
/// The persisted album listing, in stored order.
|
/// The persisted album listing, in stored order.
|
||||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||||
FROM albums ORDER BY seq",
|
FROM albums ORDER BY seq",
|
||||||
@@ -98,7 +98,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of albums in the persisted listing.
|
/// Number of albums in the persisted listing.
|
||||||
pub fn albums_count(&self) -> Result<usize> {
|
pub fn albums_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ impl Db {
|
|||||||
offset: usize,
|
offset: usize,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||||
@@ -178,19 +178,36 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which albums hold `uid`, by album node uid. Read by the photo re-date,
|
||||||
|
/// which has to put the re-uploaded copy back into the albums the original
|
||||||
|
/// was in.
|
||||||
|
pub fn albums_of_photo(&self, uid: &str) -> Result<Vec<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT DISTINCT album_uid FROM album_photos WHERE uid = ?1")?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([uid], |r| r.get(0))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many photos of `album_uid` are persisted.
|
/// How many photos of `album_uid` are persisted.
|
||||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row(
|
let n: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||||
[album_uid],
|
[album_uid],
|
||||||
@@ -207,7 +224,7 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||||
@@ -222,10 +239,14 @@ impl Db {
|
|||||||
ratio: r.get(3)?,
|
ratio: r.get(3)?,
|
||||||
thumb_state: r.get(4)?,
|
thumb_state: r.get(4)?,
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||||
// Album rows have no favourite state of their own: an album
|
// Album rows have no favorite state of their own: an album
|
||||||
// photo on someone else's volume is not in our timeline, and
|
// photo on someone else's volume is not in our timeline, and
|
||||||
// favouriting one is not supported yet anyway.
|
// favouriting one is not supported yet anyway.
|
||||||
favorite: false,
|
favorite: false,
|
||||||
|
// Nor are album rows grouped: an album is a list the user
|
||||||
|
// made, and it shows exactly what they put in it.
|
||||||
|
group_size: 1,
|
||||||
|
has_raw: false,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<_>>()?;
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
|||||||
@@ -42,6 +42,30 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bump `last_accessed` for many keys in one transaction.
|
||||||
|
///
|
||||||
|
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
|
||||||
|
/// per 4 MiB block — and each of those was its own write transaction under
|
||||||
|
/// the single connection mutex, contending with every `lookup` and
|
||||||
|
/// `readdir`. Callers accumulate touches in memory and flush them here.
|
||||||
|
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
|
||||||
|
if touches.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
{
|
||||||
|
let mut stmt = tx.prepare_cached(
|
||||||
|
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
|
||||||
|
)?;
|
||||||
|
for (key, at) in touches {
|
||||||
|
stmt.execute(params![key, at])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop a single cache-entry row (one evicted blob or block).
|
/// Drop a single cache-entry row (one evicted blob or block).
|
||||||
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
pub fn cache_remove(&self, key: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
@@ -75,7 +99,7 @@ impl Db {
|
|||||||
/// because `enforce_block_budget` runs on every cached block — once per
|
/// because `enforce_block_budget` runs on every cached block — once per
|
||||||
/// 4 MiB of every cold read — while holding the shared connection.
|
/// 4 MiB of every cold read — while holding the shared connection.
|
||||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let total: i64 = conn.query_row(
|
let total: i64 = conn.query_row(
|
||||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||||
params![kind],
|
params![kind],
|
||||||
@@ -95,7 +119,7 @@ impl Db {
|
|||||||
kind: &str,
|
kind: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<(String, u64)>> {
|
) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn.prepare_cached(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||||
@@ -115,7 +139,7 @@ impl Db {
|
|||||||
/// (`clear_unpinned`); incremental eviction wants
|
/// (`clear_unpinned`); incremental eviction wants
|
||||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT cache_key, size_bytes FROM cache_entries
|
"SELECT cache_key, size_bytes FROM cache_entries
|
||||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -41,32 +41,43 @@ impl Db {
|
|||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write one batch of walked entries under scan generation `generation`. The
|
/// Write one batch of walked entries under scan generation `generation`,
|
||||||
/// FTS index is *not* touched here — it is rebuilt once in
|
/// keeping the FTS index in step with them.
|
||||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
///
|
||||||
|
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||||
|
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||||
|
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||||
|
/// the single shared connection, which stalls the whole mount for as long as
|
||||||
|
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||||
|
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||||
|
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||||
|
/// content can never change. Only insertions and deletions touch the index;
|
||||||
|
/// a re-scan that finds the same files does no index work at all.
|
||||||
|
///
|
||||||
|
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||||
|
/// distinction between "already indexed" and "new row" is exactly what
|
||||||
|
/// decides whether the index needs a write.
|
||||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
{
|
{
|
||||||
let mut stmt = tx.prepare_cached(
|
let mut update = tx.prepare_cached(
|
||||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
"UPDATE local_files
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||||
ON CONFLICT(path) DO UPDATE SET
|
WHERE path = ?1",
|
||||||
name = excluded.name,
|
|
||||||
is_dir = excluded.is_dir,
|
|
||||||
size = excluded.size,
|
|
||||||
mtime = excluded.mtime,
|
|
||||||
scan_gen = excluded.scan_gen",
|
|
||||||
)?;
|
)?;
|
||||||
|
let mut insert = tx.prepare_cached(
|
||||||
|
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
)?;
|
||||||
|
let mut index =
|
||||||
|
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||||
for e in entries {
|
for e in entries {
|
||||||
stmt.execute(params![
|
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||||
e.path,
|
if update.execute(row)? == 0 {
|
||||||
e.name,
|
insert.execute(row)?;
|
||||||
e.is_dir as i64,
|
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||||
e.size,
|
}
|
||||||
e.mtime,
|
|
||||||
generation
|
|
||||||
])?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
@@ -74,16 +85,25 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||||
/// and stamp the completion time. Returns the number of indexed entries.
|
/// completion time. Returns the number of indexed entries.
|
||||||
|
///
|
||||||
|
/// The FTS deletes name the old values explicitly, which is how an
|
||||||
|
/// external-content FTS5 table is told to retract a row — see
|
||||||
|
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||||
|
/// per-row instead of by rebuilding the index.
|
||||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||||
let mut conn = self.conn.lock();
|
let mut conn = self.conn.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||||
|
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||||
|
params![generation],
|
||||||
|
)?;
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||||
params![generation],
|
params![generation],
|
||||||
)?;
|
)?;
|
||||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -98,7 +118,7 @@ impl Db {
|
|||||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||||
/// needs an immediate rescan or can serve the existing index.
|
/// needs an immediate rescan or can serve the existing index.
|
||||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||||
@@ -118,7 +138,7 @@ impl Db {
|
|||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt;
|
let mut stmt;
|
||||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
@@ -157,7 +177,7 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use super::Db;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// Current schema version. Bump on every forward migration added below.
|
/// Current schema version. Bump on every forward migration added below.
|
||||||
pub(super) const SCHEMA_VERSION: i64 = 21;
|
pub(super) const SCHEMA_VERSION: i64 = 31;
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub(super) fn migrate(&self) -> Result<()> {
|
pub(super) fn migrate(&self) -> Result<()> {
|
||||||
@@ -113,6 +113,163 @@ impl Db {
|
|||||||
tx.execute_batch(MIGRATION_V21)?;
|
tx.execute_batch(MIGRATION_V21)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 22 {
|
||||||
|
// Older fixtures (and a database that never grew a table because
|
||||||
|
// its feature was never used) may be missing any of these, and an
|
||||||
|
// index on an absent table is a hard error rather than a no-op.
|
||||||
|
for (table, sql) in MIGRATION_V22 {
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||||
|
[table],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch(sql)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `activity_time` indexes `time DESC` while both activity queries
|
||||||
|
// order by `id`: it has only ever cost writes.
|
||||||
|
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
|
||||||
|
}
|
||||||
|
if current < 23 {
|
||||||
|
// One last full rebuild of the local index, because from here on the
|
||||||
|
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||||
|
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||||
|
// Whatever it costs, it costs once, at startup, before the mount
|
||||||
|
// serves anything.
|
||||||
|
let present: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if present {
|
||||||
|
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 24 {
|
||||||
|
tx.execute_batch(MIGRATION_V24)?;
|
||||||
|
}
|
||||||
|
if current < 25 {
|
||||||
|
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||||
|
// and there is no path to materialise without it.
|
||||||
|
let has_nodes: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_nodes {
|
||||||
|
tx.execute_batch(MIGRATION_V25)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 26 {
|
||||||
|
// Same guard as V22: a fixture that never exercised the queue may
|
||||||
|
// not have `pending_op`, and altering an absent table is an error
|
||||||
|
// rather than a no-op.
|
||||||
|
// A fixture built from the current schema and then rewound to an
|
||||||
|
// older `schema_version` already has the column, and `ADD COLUMN`
|
||||||
|
// has no `IF NOT EXISTS`.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') WHERE name = 'claimed_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V26)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 27 {
|
||||||
|
// Same guards as V26, for the same two reasons: the table may not
|
||||||
|
// exist in an old fixture, and a fixture rewound from the current
|
||||||
|
// schema already has the column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('pending_op') \
|
||||||
|
WHERE name = 'access_deferred_since'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_ops: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'pending_op'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_ops && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V27)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 28 {
|
||||||
|
// Same guards as V26/V27, for the same two reasons.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||||
|
WHERE name = 'local_mtime_ns'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_entries: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_entries && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V28)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 29 {
|
||||||
|
// Same guards as V26/V27/V28: an old fixture may not have `trash`,
|
||||||
|
// and a fixture rewound from the current schema already has the
|
||||||
|
// column.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('trash') WHERE name = 'parent_uid'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_trash: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'trash'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_trash && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V29)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 30 {
|
||||||
|
// Same guards as V26-V29: the column may already be there on a
|
||||||
|
// fixture rewound from the current schema, and a very old fixture
|
||||||
|
// may not have `photos` at all.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'group_key'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V30)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current < 31 {
|
||||||
|
// Same guards as V26-V30.
|
||||||
|
let has_column: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
let has_photos: bool = tx.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? > 0;
|
||||||
|
if has_photos && !has_column {
|
||||||
|
tx.execute_batch(MIGRATION_V31)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
@@ -527,7 +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
|
||||||
@@ -583,3 +740,215 @@ SELECT n.rowid, n.name, paths.path
|
|||||||
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
FROM nodes n JOIN paths ON paths.rowid = n.rowid
|
||||||
WHERE paths.trashed = 0;
|
WHERE paths.trashed = 0;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// V22: indexes for the lookups that were scanning, each paired with the table
|
||||||
|
/// it needs to exist for.
|
||||||
|
///
|
||||||
|
/// Each backs a query on a hot path that had no index to use:
|
||||||
|
///
|
||||||
|
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
|
||||||
|
/// local entry once per node, per pass.
|
||||||
|
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
|
||||||
|
/// after a local index scan.
|
||||||
|
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
|
||||||
|
/// index keeping it to the live rows a listing actually wants.
|
||||||
|
///
|
||||||
|
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
|
||||||
|
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
|
||||||
|
/// first due row; offering the planner a `next_attempt_at` index makes it scan
|
||||||
|
/// that index and then sort the matches by `id` instead —
|
||||||
|
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
|
||||||
|
const MIGRATION_V22: [(&str, &str); 3] = [
|
||||||
|
(
|
||||||
|
"sync_entry",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"local_files",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"nodes",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||||
|
/// the conflict hole B70 layer B closed.
|
||||||
|
///
|
||||||
|
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||||
|
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||||
|
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||||
|
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||||
|
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||||
|
///
|
||||||
|
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||||
|
/// a stall→resume is plausible are pruned on write.
|
||||||
|
const MIGRATION_V24: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
revision_id TEXT NOT NULL,
|
||||||
|
sealed_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||||
|
/// walked.
|
||||||
|
///
|
||||||
|
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||||
|
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||||
|
/// per descendant of every folder upsert. The value only changes when a node's
|
||||||
|
/// name or parent changes, which is exactly when the write path is already
|
||||||
|
/// walking the subtree — so it belongs in a column.
|
||||||
|
///
|
||||||
|
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||||
|
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||||
|
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||||
|
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||||
|
///
|
||||||
|
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||||
|
/// so the recursive walk runs once instead of once per row.
|
||||||
|
const MIGRATION_V25: &str = "
|
||||||
|
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||||
|
|
||||||
|
INSERT INTO node_paths (uid, path)
|
||||||
|
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||||
|
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.parent_uid IS NULL
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.uid,
|
||||||
|
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||||
|
paths.depth + 1
|
||||||
|
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||||
|
WHERE paths.depth < 256
|
||||||
|
)
|
||||||
|
SELECT uid, path FROM paths;
|
||||||
|
|
||||||
|
UPDATE nodes
|
||||||
|
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||||
|
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||||
|
|
||||||
|
DROP TABLE node_paths;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// V26: `pending_op.claimed_at` — the drain's claim column.
|
||||||
|
///
|
||||||
|
/// The drain was one thread taking one op at a time, so a 10 GiB upload held
|
||||||
|
/// every queued rename and small write behind it for as long as it ran. Several
|
||||||
|
/// workers can share the queue only if a claim is visible to the others: this
|
||||||
|
/// column is what makes "already being drained by someone" a fact about the row
|
||||||
|
/// rather than about one thread's local variable.
|
||||||
|
///
|
||||||
|
/// `0` is unclaimed; anything else is the ms at which a worker took it. A
|
||||||
|
/// claim is process-local state that outlives nothing — the single-writer lock
|
||||||
|
/// means a non-zero value found at open belongs to a crashed run, and
|
||||||
|
/// [`Db::clear_op_claims`](super::Db::clear_op_claims) drops the lot.
|
||||||
|
///
|
||||||
|
/// The index is partial so it costs nothing on the ordinary row: only the
|
||||||
|
/// handful of claimed rows are in it, which is exactly the set the claim query
|
||||||
|
/// has to subtract to keep two workers off one node.
|
||||||
|
const MIGRATION_V26: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN claimed_at INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||||
|
ON pending_op(uid) WHERE claimed_at <> 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v27: when an op was first deferred by the local access check.
|
||||||
|
///
|
||||||
|
/// An access deferral consumes no attempt and records no error — the op has not
|
||||||
|
/// failed remotely, it has not been sent at all. That is right for a permission
|
||||||
|
/// change the user is about to undo, and wrong for a condition that never
|
||||||
|
/// clears: the row is re-deferred every
|
||||||
|
/// [`DRAIN_ACCESS_RECHECK`](../../../pdfs_fuse/drain/constant.DRAIN_ACCESS_RECHECK.html)
|
||||||
|
/// forever while `attempts`, `last_error`, `failing` and the logs all stay
|
||||||
|
/// empty, so a queue that can never drain is indistinguishable from a busy one.
|
||||||
|
/// Found in the field as 28 revision ops holding 18 GiB of accepted writes for
|
||||||
|
/// 30 days, none of them visible anywhere.
|
||||||
|
///
|
||||||
|
/// `0` means "not currently access-deferred"; anything else is the ms at which
|
||||||
|
/// the first consecutive deferral happened, which is what
|
||||||
|
/// [`Db::defer_op_for_access`](super::Db::defer_op_for_access) measures the
|
||||||
|
/// deferral against.
|
||||||
|
const MIGRATION_V27: &str = "
|
||||||
|
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v28: sub-second precision for a mirror folder's local baseline.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides whether the local side of a path changed by
|
||||||
|
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
|
||||||
|
/// that comparison were whole seconds, so an edit that landed in the same second
|
||||||
|
/// as the last sync and left the file the same length — a fixed-size record
|
||||||
|
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||||
|
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||||
|
/// older content until something else about the file moved (bugs.md B25).
|
||||||
|
///
|
||||||
|
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||||
|
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||||
|
/// zero for it would make every already-synced file compare as changed and
|
||||||
|
/// re-upload an entire mirror on first start. `NULL` means "this row predates
|
||||||
|
/// the column", which the comparison reads as "fall back to whole seconds" —
|
||||||
|
/// exactly the behaviour that row was written under. Each row gains a real value
|
||||||
|
/// the next time its path is synced.
|
||||||
|
const MIGRATION_V28: &str = "
|
||||||
|
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v29: the trash listing remembers each trashed node's parent, so a
|
||||||
|
/// restore can reason about the shape of what it is putting back.
|
||||||
|
///
|
||||||
|
/// Trash is a flat list of uids on the wire, but the user deletes a *tree*: a
|
||||||
|
/// folder and, often, items inside it that were trashed separately. Restoring
|
||||||
|
/// one of those in isolation is what the parent link fixes — a child restored
|
||||||
|
/// under a still-trashed parent lands somewhere invisible, and a folder restored
|
||||||
|
/// without its separately-trashed contents comes back empty.
|
||||||
|
///
|
||||||
|
/// `NULL` means "this row predates the column" (or the node is a volume root);
|
||||||
|
/// the next trash refresh fills it in, and until then the restore behaves as it
|
||||||
|
/// did before.
|
||||||
|
const MIGRATION_V29: &str = "
|
||||||
|
ALTER TABLE trash ADD COLUMN parent_uid TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trash_parent ON trash(parent_uid);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v30: a RAW and the JPEG of the same shot are one photo to the person
|
||||||
|
/// who took it, so the timeline records what ties them together.
|
||||||
|
///
|
||||||
|
/// `content_hash` and `main_uid` come from the server's own photo properties —
|
||||||
|
/// the duplicate-detection hash, and the main photo a related one belongs to.
|
||||||
|
/// `group_key` is what the gallery pages by: every member of one shot carries
|
||||||
|
/// the same key, and the member the grid shows is the one whose `main_uid` is
|
||||||
|
/// its own uid.
|
||||||
|
///
|
||||||
|
/// All three are `NULL` until the next timeline refresh resolves the photo's
|
||||||
|
/// node, and a row without a `group_key` is its own group — which is exactly how
|
||||||
|
/// the gallery behaved before this column existed.
|
||||||
|
const MIGRATION_V30: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN content_hash TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||||
|
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Schema v31: which photos a refresh still has to read from the server.
|
||||||
|
///
|
||||||
|
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||||
|
/// favorite tag, content hash and photo relation — minutes of round-trips for a
|
||||||
|
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||||
|
/// records that the work was already done, so a refresh only reads the photos it
|
||||||
|
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||||
|
///
|
||||||
|
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||||
|
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||||
|
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||||
|
/// ever learned about.
|
||||||
|
const MIGRATION_V31: &str = "
|
||||||
|
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||||
|
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||||
|
";
|
||||||
@@ -2,9 +2,17 @@
|
|||||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||||
//!
|
//!
|
||||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||||
//! the same data through the control socket. The connection is wrapped in a
|
//! the same data through the control socket. There is one write connection
|
||||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||||
//! behind the `State` lock, so a connection pool would be overkill.
|
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||||
|
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||||
|
//!
|
||||||
|
//! The pool is what makes WAL worth having. The original justification for a
|
||||||
|
//! single connection was that "FUSE callbacks already serialize behind the
|
||||||
|
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||||
|
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||||
|
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||||
|
//! only thing left serialising them was this process's own mutex.
|
||||||
//!
|
//!
|
||||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||||
@@ -18,8 +26,10 @@
|
|||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::Result;
|
use crate::{Error, Result};
|
||||||
use std::path::Path;
|
use std::fs::File;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
mod activity;
|
mod activity;
|
||||||
mod albums;
|
mod albums;
|
||||||
@@ -46,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;
|
||||||
@@ -66,18 +76,208 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
|
|||||||
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
|
||||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
|
||||||
|
/// rather than in pages). 32 MiB against a database that is typically tens of
|
||||||
|
/// MiB: large enough to hold the working set of a listing-heavy session, small
|
||||||
|
/// enough to be unremarkable in a desktop daemon's RSS.
|
||||||
|
const CACHE_SIZE_KIB: i64 = 32 * 1024;
|
||||||
|
|
||||||
|
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
|
||||||
|
/// any realistic index while keeping the mapping bounded.
|
||||||
|
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
|
||||||
|
/// so the synchronous checkpoint a committing thread inherits stays short.
|
||||||
|
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
|
||||||
|
|
||||||
/// Handle to the unified metadata database.
|
/// Handle to the unified metadata database.
|
||||||
///
|
///
|
||||||
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
|
||||||
/// the inner `Mutex<Connection>`.
|
/// the inner `Mutex<Connection>`.
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
conn: Mutex<Connection>,
|
conn: Mutex<Connection>,
|
||||||
|
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||||
|
/// database, where a second connection would open a different, empty one —
|
||||||
|
/// those fall back to the write connection.
|
||||||
|
readers: Option<ReadPool>,
|
||||||
|
/// The single-writer lock, held open for as long as this handle lives.
|
||||||
|
///
|
||||||
|
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||||
|
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||||
|
/// in-memory databases, which nothing else can reach.
|
||||||
|
_single_writer: Option<File>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||||
|
///
|
||||||
|
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||||
|
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||||
|
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||||
|
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||||
|
/// does not leave a connection per thread parked forever.
|
||||||
|
struct ReadPool {
|
||||||
|
path: PathBuf,
|
||||||
|
idle: Mutex<Vec<Connection>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many idle read connections to keep. Enough that the steady state never
|
||||||
|
/// reopens, small enough to be unremarkable.
|
||||||
|
const MAX_IDLE_READERS: usize = 4;
|
||||||
|
|
||||||
|
impl ReadPool {
|
||||||
|
fn take(&self) -> Option<Connection> {
|
||||||
|
if let Some(conn) = self.idle.lock().pop() {
|
||||||
|
return Some(conn);
|
||||||
|
}
|
||||||
|
match open_read_only(&self.path) {
|
||||||
|
Ok(conn) => Some(conn),
|
||||||
|
Err(error) => {
|
||||||
|
// Falling back to the write connection is correct, just slower,
|
||||||
|
// so this is a warning and not a failed read.
|
||||||
|
tracing::warn!(%error, "could not open a read-only connection");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&self, conn: Connection) {
|
||||||
|
let mut idle = self.idle.lock();
|
||||||
|
if idle.len() < MAX_IDLE_READERS {
|
||||||
|
idle.push(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||||
|
/// connection when there is no pool (in-memory) or opening one failed.
|
||||||
|
///
|
||||||
|
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||||
|
/// for `self.read()` and nothing else.
|
||||||
|
pub(super) struct Reader<'a> {
|
||||||
|
pooled: Option<Connection>,
|
||||||
|
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||||
|
pool: Option<&'a ReadPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for Reader<'_> {
|
||||||
|
type Target = Connection;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Connection {
|
||||||
|
self.pooled
|
||||||
|
.as_ref()
|
||||||
|
.or(self.writer.as_deref())
|
||||||
|
.expect("a Reader always holds one of the two")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Reader<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||||
|
pool.put(conn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a second connection to the same file for reads only.
|
||||||
|
///
|
||||||
|
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||||
|
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||||
|
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||||
|
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||||
|
/// writer's.
|
||||||
|
///
|
||||||
|
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||||
|
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||||
|
/// it anyway.
|
||||||
|
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||||
|
use rusqlite::OpenFlags;
|
||||||
|
let conn = Connection::open_with_flags(
|
||||||
|
path,
|
||||||
|
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||||
|
)?;
|
||||||
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
conn.pragma_update(None, "query_only", true)?;
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||||
/// the schema up to [`SCHEMA_VERSION`].
|
/// the schema up to [`SCHEMA_VERSION`].
|
||||||
|
///
|
||||||
|
/// Takes the single-instance lock first (see
|
||||||
|
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||||
|
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let single_writer = acquire_single_writer_lock(path)?;
|
||||||
|
let conn = match Self::open_configured(path) {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(e) if is_corrupt(&e) => {
|
||||||
|
// Every byte in here is derived from the account or from the
|
||||||
|
// content cache directory, so the recovery is to start over —
|
||||||
|
// loudly, and keeping the damaged file for a post-mortem rather
|
||||||
|
// than deleting evidence.
|
||||||
|
tracing::error!(
|
||||||
|
db = %path.display(),
|
||||||
|
error = %e,
|
||||||
|
"cache database is corrupt — moving it aside and rebuilding \
|
||||||
|
(metadata will be re-fetched; no user data is stored here)"
|
||||||
|
);
|
||||||
|
quarantine_corrupt_db(path)?;
|
||||||
|
Self::open_configured(path)?
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let db = Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
// No pool for `:memory:`. A second connection to that name is a
|
||||||
|
// second, empty database rather than another view of this one, so
|
||||||
|
// every routed read would find a schema that does not exist.
|
||||||
|
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
idle: Mutex::new(Vec::new()),
|
||||||
|
}),
|
||||||
|
_single_writer: single_writer,
|
||||||
|
};
|
||||||
|
// A drain claim names a worker thread in the process that took it, and
|
||||||
|
// the single-writer lock above says that process is not running. Any
|
||||||
|
// claim still on disk is therefore a crashed run's, and leaving it would
|
||||||
|
// hide those ops from every worker for good.
|
||||||
|
match db.clear_op_claims() {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(ops = n, "released drain claims left by a previous run"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "clearing stale drain claims failed"),
|
||||||
|
}
|
||||||
|
Ok(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A connection for a `SELECT`-only method.
|
||||||
|
///
|
||||||
|
/// Reads see the last committed state, which is the same thing they saw
|
||||||
|
/// through the write connection: every method here commits before it
|
||||||
|
/// returns, and nothing holds a transaction open across calls.
|
||||||
|
pub(super) fn read(&self) -> Reader<'_> {
|
||||||
|
if let Some(pool) = &self.readers
|
||||||
|
&& let Some(conn) = pool.take()
|
||||||
|
{
|
||||||
|
return Reader {
|
||||||
|
pooled: Some(conn),
|
||||||
|
writer: None,
|
||||||
|
pool: Some(pool),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Reader {
|
||||||
|
pooled: None,
|
||||||
|
writer: Some(self.conn.lock()),
|
||||||
|
pool: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||||
|
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||||
|
fn open_configured(path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(path)?;
|
let conn = Connection::open(path)?;
|
||||||
// WAL: readers never block the single writer. NORMAL sync is the
|
// WAL: readers never block the single writer. NORMAL sync is the
|
||||||
// standard durability/throughput tradeoff for WAL.
|
// standard durability/throughput tradeoff for WAL.
|
||||||
@@ -102,12 +302,39 @@ impl Db {
|
|||||||
// (Bare SQLite does default to 0, which is where the belief that this
|
// (Bare SQLite does default to 0, which is where the belief that this
|
||||||
// was unset came from. That default is not what we get.)
|
// was unset came from. That default is not what we get.)
|
||||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||||
|
// Page cache. The default is 2 MiB, against a schema whose hot table
|
||||||
|
// stores a JSON blob per node — a listing of a few thousand files
|
||||||
|
// evicts itself while it is being read.
|
||||||
|
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||||
|
// Sorts and recursive CTEs materialise their intermediate results.
|
||||||
|
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
|
||||||
|
// spills those to a file in `/tmp` by default; they are small and
|
||||||
|
// short-lived, so memory is both faster and less surprising.
|
||||||
|
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||||
|
// Read the database through the page cache of the OS rather than
|
||||||
|
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
|
||||||
|
// does not track the database size.
|
||||||
|
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||||
|
// Checkpointing is synchronous in whichever thread happens to commit
|
||||||
|
// the transaction that crosses the threshold — often a FUSE callback.
|
||||||
|
// A smaller autocheckpoint makes that debt smaller and more frequent
|
||||||
|
// rather than rare and multi-second.
|
||||||
|
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db.into_conn())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unwrap the connection back out of a temporary handle. Only
|
||||||
|
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||||
|
/// (which is written against `&self`) before the real handle exists.
|
||||||
|
fn into_conn(self) -> Connection {
|
||||||
|
self.conn.into_inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open an in-memory database. For tests.
|
/// Open an in-memory database. For tests.
|
||||||
@@ -120,6 +347,8 @@ impl Db {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
let db = Self {
|
let db = Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
|
readers: None,
|
||||||
|
_single_writer: None,
|
||||||
};
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
@@ -133,5 +362,102 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `path` names the in-memory database rather than a file. Both
|
||||||
|
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||||
|
/// corruption story.
|
||||||
|
fn is_in_memory(path: &Path) -> bool {
|
||||||
|
let p = path.as_os_str();
|
||||||
|
p == ":memory:" || p.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the single-instance write lock for the database at `path`.
|
||||||
|
///
|
||||||
|
/// The daemon is the only writer by design — the front-ends go through the
|
||||||
|
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||||
|
/// alongside the systemd unit, at which point two processes own the same inode
|
||||||
|
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||||
|
/// locking makes each *statement* safe and does nothing about that.
|
||||||
|
///
|
||||||
|
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||||
|
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||||
|
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||||
|
/// the process, however it exits.
|
||||||
|
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||||
|
if is_in_memory(path) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let lock_path = lock_path_for(path);
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(&lock_path)?;
|
||||||
|
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||||
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||||
|
if rc != 0 {
|
||||||
|
let e = std::io::Error::last_os_error();
|
||||||
|
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||||
|
return Err(Error::Other(format!(
|
||||||
|
"another Proton Drive daemon is already using {} — \
|
||||||
|
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(Error::Io(e));
|
||||||
|
}
|
||||||
|
Ok(Some(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_path_for(path: &Path) -> PathBuf {
|
||||||
|
let mut name = path.as_os_str().to_owned();
|
||||||
|
name.push(".lock");
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||||
|
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||||
|
/// permission problem or a newer-than-supported schema must all still fail.
|
||||||
|
fn is_corrupt(e: &Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
e,
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||||
|
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||||
|
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||||
|
/// open starts from empty.
|
||||||
|
///
|
||||||
|
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||||
|
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||||
|
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||||
|
let stamp = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
for suffix in ["", "-wal", "-shm"] {
|
||||||
|
let mut from = path.as_os_str().to_owned();
|
||||||
|
from.push(suffix);
|
||||||
|
let from = PathBuf::from(from);
|
||||||
|
if !from.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut to = path.as_os_str().to_owned();
|
||||||
|
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||||
|
let to = PathBuf::from(to);
|
||||||
|
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||||
|
// WAL whose database header no longer matches — but failing to move the
|
||||||
|
// database itself is not, because the retry would hit the same file.
|
||||||
|
match std::fs::rename(&from, &to) {
|
||||||
|
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||||
|
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||||
|
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -74,7 +74,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every local location, with device fields joined from `sync_folder`.
|
/// Every local location, with device fields joined from `sync_folder`.
|
||||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
m.id,
|
m.id,
|
||||||
@@ -171,6 +171,7 @@ impl Db {
|
|||||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||||
mounted: false,
|
mounted: false,
|
||||||
progress: None,
|
progress: None,
|
||||||
|
paused: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(mounts)
|
Ok(mounts)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use crate::{Access, Result};
|
|||||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||||
use proton_drive_rs::{Node, NodeKind};
|
use proton_drive_rs::{Node, NodeKind};
|
||||||
|
|
||||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
use super::utils::{
|
||||||
|
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
|
||||||
|
};
|
||||||
|
|
||||||
pub struct StoredNode {
|
pub struct StoredNode {
|
||||||
pub node: Node,
|
pub node: Node,
|
||||||
@@ -59,7 +61,7 @@ impl Db {
|
|||||||
/// flag. Synthetic shared listings use this to serve the last completed
|
/// flag. Synthetic shared listings use this to serve the last completed
|
||||||
/// snapshot while offline, including after a live event expired its TTL.
|
/// snapshot while offline, including after a live event expired its TTL.
|
||||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json FROM nodes
|
"SELECT node_json FROM nodes
|
||||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||||
@@ -285,7 +287,7 @@ impl Db {
|
|||||||
/// Check if a folder node has any non-trashed children in the database.
|
/// Check if a folder node has any non-trashed children in the database.
|
||||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||||
let uid_str = parent_uid.to_string();
|
let uid_str = parent_uid.to_string();
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
||||||
params![uid_str],
|
params![uid_str],
|
||||||
@@ -294,6 +296,25 @@ impl Db {
|
|||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A node's mountpoint-relative path, or `None` when it is not cached.
|
||||||
|
///
|
||||||
|
/// One indexed read of the stored `path`. Callers that need to relate many
|
||||||
|
/// nodes to a handful of roots — search decorating each hit with its
|
||||||
|
/// mountpoint — resolve the roots once through this and do the rest as
|
||||||
|
/// string arithmetic, instead of a `path_relative_to` walk per pair.
|
||||||
|
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let exists: Option<i64> = conn
|
||||||
|
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
if exists.is_none() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(path_of(&conn, uid)?))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve `uid` relative to an ancestor node.
|
/// Resolve `uid` relative to an ancestor node.
|
||||||
///
|
///
|
||||||
/// This is used when a remote subtree has its own local sync mount: the
|
/// This is used when a remote subtree has its own local sync mount: the
|
||||||
@@ -302,7 +323,7 @@ impl Db {
|
|||||||
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
||||||
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
||||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
@@ -334,19 +355,36 @@ impl Db {
|
|||||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||||
/// nothing below 3 chars.
|
/// nothing below 3 chars.
|
||||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||||
|
self.search_in(query, limit, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`search`](Self::search) restricted to the subtree under the
|
||||||
|
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||||
|
/// everything.
|
||||||
|
pub fn search_in(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
scope: Option<&str>,
|
||||||
|
) -> Result<Vec<SearchHit>> {
|
||||||
|
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||||
|
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||||
|
// checked once their path is walked below.
|
||||||
|
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||||
let query = query.trim();
|
let query = query.trim();
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
|
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
// Escape double quotes and quote each term, then combine with AND so
|
// Escape double quotes and quote each term, then combine with AND so
|
||||||
// all terms must match but can appear in any order or position.
|
// all terms must match but can appear in any order or position.
|
||||||
@@ -356,18 +394,29 @@ impl Db {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
|
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hits = Vec::with_capacity(rows.len());
|
let mut hits = Vec::with_capacity(rows.len());
|
||||||
for (json, uid) in rows {
|
for (json, uid, path) in rows {
|
||||||
let node: Node = serde_json::from_str(&json)?;
|
let node: Node = serde_json::from_str(&json)?;
|
||||||
let path = path_of(&conn, &uid)?;
|
let path = match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
};
|
||||||
|
if let Some(scope) = scope
|
||||||
|
&& !path
|
||||||
|
.strip_prefix(scope)
|
||||||
|
.is_some_and(|rest| rest.starts_with('/'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
hits.push(SearchHit { node, path });
|
hits.push(SearchHit { node, path });
|
||||||
}
|
}
|
||||||
Ok(hits)
|
Ok(hits)
|
||||||
@@ -382,26 +431,26 @@ impl Db {
|
|||||||
if query.is_empty() || limit == 0 {
|
if query.is_empty() || limit == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let terms = candidate_trigrams(query);
|
let terms = candidate_trigrams(query);
|
||||||
let lane_limit = limit.div_ceil(2);
|
let lane_limit = limit.div_ceil(2);
|
||||||
let mut rows: Vec<(String, String)> = if terms.is_empty() {
|
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||||
let pat = format!("%{}%", like_escape(query));
|
let pat = format!("%{}%", like_escape(query));
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name LIMIT ?2",
|
ORDER BY name LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||||
} else {
|
} else {
|
||||||
let expression = terms.join(" OR ");
|
let expression = terms.join(" OR ");
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT n.node_json, n.uid
|
"SELECT n.node_json, n.uid, n.path
|
||||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||||
ORDER BY f.rank LIMIT ?2",
|
ORDER BY f.rank LIMIT ?2",
|
||||||
)?;
|
)?;
|
||||||
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
|
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||||
};
|
};
|
||||||
// Trigram rank does not know that a name *starts* with the query, and a
|
// Trigram rank does not know that a name *starts* with the query, and a
|
||||||
// short substitution can destroy every trigram outright (`vedio` vs
|
// short substitution can destroy every trigram outright (`vedio` vs
|
||||||
@@ -411,7 +460,7 @@ impl Db {
|
|||||||
&& let Some(first) = query.chars().next()
|
&& let Some(first) = query.chars().next()
|
||||||
{
|
{
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT node_json, uid FROM nodes
|
"SELECT node_json, uid, path FROM nodes
|
||||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||||
AND trashed = 0 AND node_json IS NOT NULL
|
AND trashed = 0 AND node_json IS NOT NULL
|
||||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||||
@@ -421,23 +470,26 @@ impl Db {
|
|||||||
format!("{}%", like_escape(query)),
|
format!("{}%", like_escape(query)),
|
||||||
format!("{}%", like_escape(&first.to_string())),
|
format!("{}%", like_escape(&first.to_string())),
|
||||||
] {
|
] {
|
||||||
extra.extend(collect_pairs(
|
extra.extend(collect_hits(
|
||||||
stmt.query_map(params![pattern, lane_limit as i64], pair)?,
|
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
let trigram = std::mem::take(&mut rows);
|
let trigram = std::mem::take(&mut rows);
|
||||||
for row in extra.into_iter().chain(trigram) {
|
for row in extra.into_iter().chain(trigram) {
|
||||||
if !rows.iter().any(|(_, uid)| uid == &row.1) {
|
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.truncate(limit);
|
rows.truncate(limit);
|
||||||
}
|
}
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|(json, uid)| {
|
.map(|(json, uid, path)| {
|
||||||
Ok(SearchHit {
|
Ok(SearchHit {
|
||||||
node: serde_json::from_str(&json)?,
|
node: serde_json::from_str(&json)?,
|
||||||
path: path_of(&conn, &uid)?,
|
path: match path {
|
||||||
|
Some(path) => path,
|
||||||
|
None => walk_path_of(&conn, &uid)?,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -457,7 +509,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt =
|
let mut stmt =
|
||||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
@@ -478,7 +530,7 @@ impl Db {
|
|||||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||||
/// (offline.md Phase 1).
|
/// (offline.md Phase 1).
|
||||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let json: Option<String> = conn
|
let json: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||||
@@ -496,7 +548,7 @@ impl Db {
|
|||||||
/// The daemon resumes from this on restart instead of reseeding to the
|
/// The daemon resumes from this on restart instead of reseeding to the
|
||||||
/// server head, so changes made while unmounted are still applied (P2).
|
/// server head, so changes made while unmounted are still applied (P2).
|
||||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let listed: Option<i64> = conn
|
let listed: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||||
@@ -529,13 +581,57 @@ impl Db {
|
|||||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a node's row looked like before this upsert, for the fields that decide
|
||||||
|
/// whether the subtree below it has to be touched at all.
|
||||||
|
struct PriorRow {
|
||||||
|
parent_uid: Option<String>,
|
||||||
|
name: String,
|
||||||
|
path: Option<String>,
|
||||||
|
trashed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||||
let json = serde_json::to_string(node)?;
|
let json = serde_json::to_string(node)?;
|
||||||
let uid = node.uid.to_string();
|
let uid = node.uid.to_string();
|
||||||
|
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||||
|
|
||||||
|
let prior: Option<PriorRow> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
|
||||||
|
params![uid],
|
||||||
|
|row| {
|
||||||
|
Ok(PriorRow {
|
||||||
|
parent_uid: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
trashed: row.get::<_, i64>(3)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
|
// A node's path is its parent's path plus its own name — one indexed lookup,
|
||||||
|
// where resolving it from scratch is a recursive walk to the root. A node
|
||||||
|
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||||
|
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||||
|
let path = match &parent_uid {
|
||||||
|
None => String::new(),
|
||||||
|
Some(parent) => {
|
||||||
|
let parent_path: Option<Option<String>> = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT path FROM nodes WHERE uid = ?1",
|
||||||
|
params![parent],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO nodes
|
"INSERT INTO nodes
|
||||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(uid) DO UPDATE SET
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
parent_uid = excluded.parent_uid,
|
parent_uid = excluded.parent_uid,
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
@@ -543,65 +639,116 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
|||||||
size = excluded.size,
|
size = excluded.size,
|
||||||
mtime = excluded.mtime,
|
mtime = excluded.mtime,
|
||||||
trashed = excluded.trashed,
|
trashed = excluded.trashed,
|
||||||
node_json = excluded.node_json",
|
node_json = excluded.node_json,
|
||||||
|
path = excluded.path",
|
||||||
params![
|
params![
|
||||||
uid,
|
uid,
|
||||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
parent_uid,
|
||||||
node.name,
|
node.name,
|
||||||
node.is_folder() as i64,
|
node.is_folder() as i64,
|
||||||
node_size(node),
|
node_size(node),
|
||||||
node.modification_time,
|
node.modification_time,
|
||||||
node.trashed as i64,
|
node.trashed as i64,
|
||||||
json,
|
json,
|
||||||
|
path,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
|
|
||||||
// refresh the whole affected subtree while keeping rowid deletes indexed.
|
|
||||||
let rowid: i64 = tx.query_row(
|
let rowid: i64 = tx.query_row(
|
||||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
let mut affected = vec![rowid];
|
|
||||||
if node.is_folder() {
|
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||||
// The depth cap is a liveness guard, not a policy: `UNION ALL` over a
|
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||||
// parent cycle (a's parent is b, b's parent is a — corrupt data, but the
|
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||||
// API can hand it to us) never terminates, and this runs inside the
|
if indexable {
|
||||||
// write transaction that holds the daemon's only SQLite connection.
|
|
||||||
// Real Drive trees are nowhere near this deep.
|
|
||||||
let mut stmt = tx.prepare(
|
|
||||||
"WITH RECURSIVE descendants(rowid, uid, depth) AS (
|
|
||||||
SELECT rowid, uid, 0 FROM nodes WHERE parent_uid = ?1
|
|
||||||
UNION ALL
|
|
||||||
SELECT n.rowid, n.uid, d.depth + 1 FROM nodes n
|
|
||||||
JOIN descendants d ON n.parent_uid = d.uid
|
|
||||||
WHERE d.depth < 256
|
|
||||||
) SELECT rowid FROM descendants",
|
|
||||||
)?;
|
|
||||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
|
||||||
affected.push(descendant?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for affected_rowid in affected {
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||||
params![affected_rowid],
|
params![rowid, node.name, path],
|
||||||
)?;
|
)?;
|
||||||
let indexed: Option<(String, String)> = tx
|
}
|
||||||
.query_row(
|
|
||||||
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
// Everything below only matters when the subtree's paths or reachability
|
||||||
params![affected_rowid],
|
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
// full descendant walk on each of those was most of what a large tree spent
|
||||||
)
|
// its time on.
|
||||||
.optional()?;
|
let subtree_moved = prior.is_none_or(|prior| {
|
||||||
if let Some((name, indexed_uid)) = indexed
|
prior.parent_uid != parent_uid
|
||||||
&& node_is_indexable_tx(tx, &indexed_uid)?
|
|| prior.name != node.name
|
||||||
{
|
|| prior.path.as_deref() != Some(path.as_str())
|
||||||
let path = path_of(tx, &indexed_uid)?;
|
|| prior.trashed != node.trashed
|
||||||
tx.execute(
|
});
|
||||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
if node.is_folder() && subtree_moved {
|
||||||
params![affected_rowid, name, path],
|
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||||
)?;
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite every descendant's stored path and search-index row after `folder`
|
||||||
|
/// moved, was renamed, or changed reachability.
|
||||||
|
///
|
||||||
|
/// One recursive walk carries both the new path and whether anything on the way
|
||||||
|
/// down is trashed, so each descendant costs two trivial statements instead of a
|
||||||
|
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
|
||||||
|
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
|
||||||
|
/// subtree is the same for every node in it.
|
||||||
|
///
|
||||||
|
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
|
||||||
|
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
|
||||||
|
/// it to us) never terminates, and this runs inside the write transaction that
|
||||||
|
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
|
||||||
|
/// this deep.
|
||||||
|
fn reindex_subtree_tx(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
folder: &str,
|
||||||
|
folder_path: &str,
|
||||||
|
folder_indexable: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let descendants: Vec<(i64, String, bool)> = {
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
|
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||||
|
n.trashed, 0
|
||||||
|
FROM nodes n WHERE n.parent_uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.rowid, n.uid, n.name,
|
||||||
|
sub.path || '/' || n.name,
|
||||||
|
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||||
|
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||||
|
WHERE sub.depth < 256
|
||||||
|
)
|
||||||
|
SELECT rowid, path, blocked FROM sub",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![folder, folder_path], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, i64>(2)? != 0,
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
out.push(row?);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
if descendants.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||||
|
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||||
|
let mut index = tx.prepare(
|
||||||
|
"INSERT INTO nodes_fts (rowid, name, path)
|
||||||
|
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||||
|
)?;
|
||||||
|
for (rowid, path, blocked) in descendants {
|
||||||
|
set_path.execute(params![rowid, path])?;
|
||||||
|
unindex.execute(params![rowid])?;
|
||||||
|
if folder_indexable && !blocked {
|
||||||
|
index.execute(params![rowid, path])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+364
-12
@@ -84,6 +84,25 @@ pub const LOCAL_VOLUME: &str = "local";
|
|||||||
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
/// stall+resume, fork a `(sync-conflict)` copy (docs/BUGS.md B70).
|
||||||
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
pub const PARK_UNTIL: i64 = 8_000_000_000_000;
|
||||||
|
|
||||||
|
/// How long a create may stay parked before the park is treated as permanent
|
||||||
|
/// and the bytes are let through anyway (ms).
|
||||||
|
///
|
||||||
|
/// The park has exactly one exit: a rename from the transient name to the
|
||||||
|
/// finished one. Every writer that reaches that rename does so in seconds — an
|
||||||
|
/// atomic-write scratch file immediately, a download when it completes. A park
|
||||||
|
/// still standing an hour later belongs to a writer that will never perform
|
||||||
|
/// that rename: an editor that deletes its swap file instead, a download the
|
||||||
|
/// user abandoned, or a file that simply *is* named `report.tmp` and is never
|
||||||
|
/// going to be called anything else.
|
||||||
|
///
|
||||||
|
/// Before this existed such a row sat at [`PARK_UNTIL`] for the life of the
|
||||||
|
/// database, holding the only copy of the file's bytes in `staging/` and
|
||||||
|
/// counting against the queue while promising progress that could not come. The
|
||||||
|
/// sweep un-parks it instead of dropping it: bytes the user can see in the mount
|
||||||
|
/// are bytes the user expects on Drive, and invariant 1 of the drain (a staged
|
||||||
|
/// blob is dropped only once its op has landed) admits no other answer.
|
||||||
|
pub const PARK_EXPIRY_MS: i64 = 60 * 60 * 1000;
|
||||||
|
|
||||||
/// A mutation that has been accepted locally but not yet performed against the
|
/// A mutation that has been accepted locally but not yet performed against the
|
||||||
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
/// API — the durable half of the write-back queue (offline.md Phase 3).
|
||||||
///
|
///
|
||||||
@@ -119,15 +138,32 @@ pub struct PendingOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How much work the queue owes the server, by whether it carries bytes.
|
/// How much work the queue owes the server, by whether it carries bytes.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct PendingCounts {
|
pub struct PendingCounts {
|
||||||
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
||||||
pub uploads: i64,
|
pub uploads: i64,
|
||||||
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
||||||
/// told about.
|
/// told about.
|
||||||
pub changes: i64,
|
pub changes: i64,
|
||||||
|
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
|
||||||
|
/// the rename that gives it its finished name. They are queued but will
|
||||||
|
/// never drain on their own, so counting them as "waiting to upload" would
|
||||||
|
/// have the front-end promise progress that is not coming.
|
||||||
|
pub parked: i64,
|
||||||
|
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||||
|
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||||
|
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||||
|
pub failing: i64,
|
||||||
|
/// The most recent error text from a failing op, for the front-end to show
|
||||||
|
/// instead of the bare count.
|
||||||
|
pub last_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Failed attempts after which an op is reported as failing rather than merely
|
||||||
|
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
|
||||||
|
/// is retrying at the slowest rate it ever will and has been for minutes.
|
||||||
|
pub const FAILING_ATTEMPTS: i64 = 6;
|
||||||
|
|
||||||
/// The outcome of folding freshly written bytes into a queued create.
|
/// The outcome of folding freshly written bytes into a queued create.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AttachedBlob {
|
pub struct AttachedBlob {
|
||||||
@@ -140,7 +176,7 @@ pub struct AttachedBlob {
|
|||||||
impl Db {
|
impl Db {
|
||||||
/// Whether a specific desired-state operation is still queued for a node.
|
/// Whether a specific desired-state operation is still queued for a node.
|
||||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||||
@@ -153,7 +189,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read an existing operation's metadata before a superseding enqueue.
|
/// Read an existing operation's metadata before a superseding enqueue.
|
||||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||||
params![uid, kind],
|
params![uid, kind],
|
||||||
@@ -352,7 +388,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Check if a queued create or mkdir op exists for `uid`.
|
/// Check if a queued create or mkdir op exists for `uid`.
|
||||||
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
||||||
params![uid, OP_CREATE, OP_MKDIR],
|
params![uid, OP_CREATE, OP_MKDIR],
|
||||||
@@ -368,7 +404,7 @@ impl Db {
|
|||||||
/// bytes are still owed an upload, and dropping it would discard the staged
|
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||||
/// blob that holds them (`docs/BUGS.md` B71).
|
/// blob that holds them (`docs/BUGS.md` B71).
|
||||||
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let count: i64 = conn.query_row(
|
let count: i64 = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||||
params![uid],
|
params![uid],
|
||||||
@@ -464,7 +500,7 @@ impl Db {
|
|||||||
/// sort, and no separate index to maintain. The common case (something is
|
/// sort, and no separate index to maintain. The common case (something is
|
||||||
/// due) exits on the first row.
|
/// due) exits on the first row.
|
||||||
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare_cached(&format!(
|
let mut stmt = conn.prepare_cached(&format!(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -495,17 +531,115 @@ impl Db {
|
|||||||
Ok(op)
|
Ok(op)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take the oldest due op for this worker, marking it claimed so no other
|
||||||
|
/// drain worker takes it, or `None` when there is nothing to do.
|
||||||
|
///
|
||||||
|
/// The drain is several workers over one queue, which is only safe because
|
||||||
|
/// the claim is a column: a row is offered to exactly one worker, and the
|
||||||
|
/// worker that took it is the only one that may retire, defer or fail it.
|
||||||
|
///
|
||||||
|
/// Two exclusions on top of [`next_due_op`](Self::next_due_op)'s:
|
||||||
|
///
|
||||||
|
/// * `claimed_at = 0` — not already in flight somewhere else.
|
||||||
|
/// * no *other* claimed op shares this `uid`. Ordering only has to hold per
|
||||||
|
/// node, but there it has to hold absolutely: a queued rename and a queued
|
||||||
|
/// revision of one file must land in the order they were made, and two
|
||||||
|
/// workers on one node would also race over its staged blob and its
|
||||||
|
/// pending entry. `ORDER BY id` plus this exclusion gives per-uid serial
|
||||||
|
/// order for free — the older op is claimed first and the newer one is
|
||||||
|
/// invisible until it retires.
|
||||||
|
///
|
||||||
|
/// Select and mark are one transaction, so two workers cannot both see the
|
||||||
|
/// row as unclaimed.
|
||||||
|
pub fn claim_next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||||
|
let mut conn = self.conn.lock();
|
||||||
|
let tx = conn.transaction()?;
|
||||||
|
let op = {
|
||||||
|
let mut stmt = tx.prepare_cached(&format!(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at <= ?1
|
||||||
|
AND claimed_at = 0
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')
|
||||||
|
AND uid NOT IN (SELECT uid FROM pending_op WHERE claimed_at <> 0)
|
||||||
|
ORDER BY id LIMIT 1",
|
||||||
|
v = LOCAL_VOLUME,
|
||||||
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
|
))?;
|
||||||
|
stmt.query_row(params![now], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
if let Some(op) = op.as_ref() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = ?2 WHERE id = ?1",
|
||||||
|
params![op.id, now.max(1)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give a claimed op back to the queue, whatever became of the attempt.
|
||||||
|
///
|
||||||
|
/// Called on every path out of a drain attempt rather than only the failing
|
||||||
|
/// ones: a handler that retires its own row leaves nothing to release (the
|
||||||
|
/// `UPDATE` matches nothing), and a handler that returns without retiring
|
||||||
|
/// *and* without failing would otherwise leave the row claimed by a worker
|
||||||
|
/// that has moved on — invisible to every worker, forever.
|
||||||
|
pub fn release_op_claim(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every claim in the table.
|
||||||
|
///
|
||||||
|
/// A claim describes a worker in *this* process, so one surviving on disk
|
||||||
|
/// means the previous run died holding it. The single-writer lock
|
||||||
|
/// ([`Db::open`]) is what makes that inference safe: nobody else can be
|
||||||
|
/// draining this queue while we are opening it.
|
||||||
|
pub fn clear_op_claims(&self) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
Ok(conn.execute(
|
||||||
|
"UPDATE pending_op SET claimed_at = 0 WHERE claimed_at <> 0",
|
||||||
|
[],
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
/// The earliest `next_attempt_at` among all queued ops, or `None` if the
|
||||||
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
/// queue is empty. Used by the drain loop to sleep exactly until the next
|
||||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||||
/// idle-poll interval.
|
/// idle-poll interval.
|
||||||
|
///
|
||||||
|
/// Claimed ops are excluded: another worker is already on them, so they are
|
||||||
|
/// not work this one is waiting for, and counting them would have an idle
|
||||||
|
/// worker spin on a row it cannot have.
|
||||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let ts: Option<i64> = conn
|
let ts: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
&format!(
|
&format!(
|
||||||
"SELECT MIN(next_attempt_at) FROM pending_op \
|
"SELECT MIN(next_attempt_at) FROM pending_op \
|
||||||
WHERE parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~'",
|
WHERE claimed_at = 0 \
|
||||||
|
AND (parent_uid IS NULL OR substr(parent_uid, 1, {n}) <> '{v}~')",
|
||||||
v = LOCAL_VOLUME,
|
v = LOCAL_VOLUME,
|
||||||
n = LOCAL_VOLUME.len() + 1,
|
n = LOCAL_VOLUME.len() + 1,
|
||||||
),
|
),
|
||||||
@@ -520,7 +654,7 @@ impl Db {
|
|||||||
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
||||||
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
||||||
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
attempts, last_error, next_attempt_at
|
attempts, last_error, next_attempt_at
|
||||||
@@ -546,6 +680,40 @@ impl Db {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every parked create/mkdir, oldest first, for the park sweep.
|
||||||
|
///
|
||||||
|
/// Parked rows are invisible to [`next_due_op`](Self::next_due_op) by
|
||||||
|
/// construction, so nothing else in the drain ever looks at them. This is
|
||||||
|
/// the one query that does.
|
||||||
|
pub fn parked_create_ops(&self) -> Result<Vec<PendingOp>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||||
|
attempts, last_error, next_attempt_at
|
||||||
|
FROM pending_op
|
||||||
|
WHERE next_attempt_at >= ?1 AND kind IN (?2, ?3)
|
||||||
|
ORDER BY created_at",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![PARK_UNTIL, OP_CREATE, OP_MKDIR], |r| {
|
||||||
|
Ok(PendingOp {
|
||||||
|
id: r.get(0)?,
|
||||||
|
kind: r.get(1)?,
|
||||||
|
uid: r.get(2)?,
|
||||||
|
parent_uid: r.get(3)?,
|
||||||
|
name: r.get(4)?,
|
||||||
|
blob_path: r.get(5)?,
|
||||||
|
meta_json: r.get(6)?,
|
||||||
|
created_at: r.get(7)?,
|
||||||
|
attempts: r.get(8)?,
|
||||||
|
last_error: r.get(9)?,
|
||||||
|
next_attempt_at: r.get(10)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
/// How many queued ops there are of each kind, for `Response::Status`.
|
/// How many queued ops there are of each kind, for `Response::Status`.
|
||||||
///
|
///
|
||||||
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
/// Split because "3 uploads queued" has to mean three files whose bytes are
|
||||||
@@ -553,7 +721,7 @@ impl Db {
|
|||||||
/// mount owes the server, but it carries no bytes and reporting it as an
|
/// mount owes the server, but it carries no bytes and reporting it as an
|
||||||
/// upload is simply untrue.
|
/// upload is simply untrue.
|
||||||
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let uploads = conn.query_row(
|
let uploads = conn.query_row(
|
||||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
@@ -564,7 +732,33 @@ impl Db {
|
|||||||
params![OP_REVISION, OP_CREATE],
|
params![OP_REVISION, OP_CREATE],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)?;
|
)?;
|
||||||
Ok(PendingCounts { uploads, changes })
|
let parked = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
|
||||||
|
params![PARK_UNTIL],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let failing = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
let last_error = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_error FROM pending_op
|
||||||
|
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||||
|
ORDER BY attempts DESC LIMIT 1",
|
||||||
|
params![FAILING_ATTEMPTS],
|
||||||
|
|r| r.get::<_, Option<String>>(0),
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.flatten();
|
||||||
|
Ok(PendingCounts {
|
||||||
|
uploads,
|
||||||
|
changes,
|
||||||
|
parked,
|
||||||
|
failing,
|
||||||
|
last_error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||||
@@ -578,13 +772,43 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make one backed-off op due now, for the user's "Retry now". Parked ops are
|
||||||
|
/// left alone: they wait for a rename, not for time, and un-parking one would
|
||||||
|
/// upload a file its writer has not finished. Returns whether a row changed.
|
||||||
|
pub fn retry_op_now(&self, id: i64, now: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?2
|
||||||
|
WHERE id = ?1 AND next_attempt_at > ?2 AND next_attempt_at < ?3",
|
||||||
|
params![id, now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`retry_op_now`](Self::retry_op_now) for every op that has failed at
|
||||||
|
/// least once. Returns how many rows changed.
|
||||||
|
pub fn retry_failed_ops_now(&self, now: i64) -> Result<usize> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
let changed = conn.execute(
|
||||||
|
"UPDATE pending_op SET next_attempt_at = ?1
|
||||||
|
WHERE attempts > 0 AND next_attempt_at > ?1 AND next_attempt_at < ?2",
|
||||||
|
params![now, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a failed attempt and when to next try. Leaves the row in place —
|
/// Record a failed attempt and when to next try. Leaves the row in place —
|
||||||
/// the staged bytes are still the only copy of the user's write.
|
/// the staged bytes are still the only copy of the user's write.
|
||||||
|
///
|
||||||
|
/// Clears any access-deferral window: whatever the op was waiting for has
|
||||||
|
/// now been reported, so the next uncleared deferral starts its own window
|
||||||
|
/// rather than escalating again on the following recheck.
|
||||||
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
pub fn record_op_failure(&self, id: i64, error: &str, next_attempt_at: i64) -> Result<()> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pending_op
|
"UPDATE pending_op
|
||||||
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3
|
SET attempts = attempts + 1, last_error = ?2, next_attempt_at = ?3,
|
||||||
|
access_deferred_since = 0
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![id, error, next_attempt_at],
|
params![id, error, next_attempt_at],
|
||||||
)?;
|
)?;
|
||||||
@@ -609,4 +833,132 @@ impl Db {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defer an op the local access check refused, and report how long it has
|
||||||
|
/// been refused for.
|
||||||
|
///
|
||||||
|
/// Identical to [`defer_op_without_attempt`](Self::defer_op_without_attempt)
|
||||||
|
/// on the retry clock, plus the one thing that was missing: the first
|
||||||
|
/// deferral of a run stamps `access_deferred_since`, and every deferral
|
||||||
|
/// returns that stamp. A caller can therefore tell a deferral that is one
|
||||||
|
/// recheck old from one that has been repeating unchanged for days, which is
|
||||||
|
/// the difference between a permission the user is mid-way through changing
|
||||||
|
/// and a queue that will never drain.
|
||||||
|
///
|
||||||
|
/// Returns the ms at which the current run of deferrals began — `now` on the
|
||||||
|
/// first one.
|
||||||
|
pub fn defer_op_for_access(&self, id: i64, now: i64, next_attempt_at: i64) -> Result<i64> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op
|
||||||
|
SET next_attempt_at = CASE
|
||||||
|
WHEN next_attempt_at >= ?4 THEN next_attempt_at
|
||||||
|
ELSE ?3
|
||||||
|
END,
|
||||||
|
access_deferred_since = CASE
|
||||||
|
WHEN access_deferred_since = 0 THEN ?2
|
||||||
|
ELSE access_deferred_since
|
||||||
|
END
|
||||||
|
WHERE id = ?1",
|
||||||
|
params![id, now, next_attempt_at, PARK_UNTIL],
|
||||||
|
)?;
|
||||||
|
let since = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT access_deferred_since FROM pending_op WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
// A row that drained out from under this call leaves nothing to measure;
|
||||||
|
// reporting `now` says "this run just started", which is the reading that
|
||||||
|
// never escalates something that no longer exists.
|
||||||
|
Ok(since.filter(|since| *since != 0).unwrap_or(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget an op's access-deferral window, after an attempt the access check
|
||||||
|
/// let through. The op is no longer blocked, so a later deferral is the
|
||||||
|
/// start of a new run rather than the continuation of an old one.
|
||||||
|
pub fn clear_op_access_deferral(&self, id: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pending_op SET access_deferred_since = 0
|
||||||
|
WHERE id = ?1 AND access_deferred_since <> 0",
|
||||||
|
params![id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every staged blob the queue still refers to.
|
||||||
|
///
|
||||||
|
/// The startup staging reconcile subtracts this from what is actually in
|
||||||
|
/// `staging/`: the difference is bytes the kernel was told were written and
|
||||||
|
/// that nothing is going to upload.
|
||||||
|
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
|
||||||
|
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||||
|
let mut out = std::collections::HashSet::new();
|
||||||
|
for row in rows {
|
||||||
|
out.insert(row?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
|
||||||
|
/// past [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// One row per node, overwritten as the node advances. See
|
||||||
|
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
|
||||||
|
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
revision_id = excluded.revision_id,
|
||||||
|
sealed_at = excluded.sealed_at",
|
||||||
|
params![uid, revision_id, now_ms],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
|
||||||
|
params![now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last revision of `uid` this daemon sealed itself, if it is still
|
||||||
|
/// within [`OWN_SEALED_TTL_MS`].
|
||||||
|
///
|
||||||
|
/// The drain asks before treating a moved-on remote as a conflict: a
|
||||||
|
/// revision we sealed means one writer stalled and resumed, which
|
||||||
|
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
|
||||||
|
/// B70 layer B).
|
||||||
|
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
|
||||||
|
let conn = self.read();
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT revision_id FROM own_sealed_rev
|
||||||
|
WHERE uid = ?1 AND sealed_at >= ?2",
|
||||||
|
params![uid, now_ms - OWN_SEALED_TTL_MS],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget what we sealed on `uid` — it has been trashed, so there is no
|
||||||
|
/// revision left to chain onto.
|
||||||
|
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
|
||||||
|
let conn = self.conn.lock();
|
||||||
|
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a revision this daemon sealed stays recognisable as its own.
|
||||||
|
///
|
||||||
|
/// Bounds a table that would otherwise grow with every write for the life of
|
||||||
|
/// the install. A week is far longer than any stall→resume — a browser download
|
||||||
|
/// that pauses and continues, an editor that reopens its handle — and short
|
||||||
|
/// enough that the table tracks recent work rather than accumulating history.
|
||||||
|
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -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,
|
||||||
@@ -115,26 +468,27 @@ impl Db {
|
|||||||
range: Option<(i64, i64)>,
|
range: Option<(i64, i64)>,
|
||||||
favorites: bool,
|
favorites: bool,
|
||||||
) -> Result<Vec<StoredPhoto>> {
|
) -> Result<Vec<StoredPhoto>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
// Built up so any combination of the optional filters is one indexed
|
// Built up so any combination of the optional filters is one indexed
|
||||||
// query rather than a statement per case.
|
// query rather than a statement per case.
|
||||||
let mut sql = String::from(
|
let mut sql = String::from(PHOTO_SELECT);
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
|
||||||
);
|
|
||||||
let mut binds: Vec<i64> = Vec::new();
|
let mut binds: Vec<i64> = Vec::new();
|
||||||
let mut conds: Vec<String> = Vec::new();
|
let mut conds: Vec<String> = Vec::new();
|
||||||
|
if kind != Some(crate::control::PhotoKind::Raw) {
|
||||||
|
conds.push("p.uid = COALESCE(p.group_key, p.uid)".to_string());
|
||||||
|
}
|
||||||
if let Some(k) = kind {
|
if let Some(k) = kind {
|
||||||
binds.push(k.as_i64());
|
binds.push(k.as_i64());
|
||||||
conds.push(format!("kind = ?{}", binds.len()));
|
conds.push(format!("p.kind = ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if let Some((from, to)) = range {
|
if let Some((from, to)) = range {
|
||||||
binds.push(from);
|
binds.push(from);
|
||||||
conds.push(format!("capture_time >= ?{}", binds.len()));
|
conds.push(format!("p.capture_time >= ?{}", binds.len()));
|
||||||
binds.push(to);
|
binds.push(to);
|
||||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
conds.push(format!("p.capture_time < ?{}", binds.len()));
|
||||||
}
|
}
|
||||||
if favorites {
|
if favorites {
|
||||||
conds.push("favorite = 1".to_string());
|
conds.push("p.favorite = 1".to_string());
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -174,10 +547,16 @@ impl Db {
|
|||||||
&self,
|
&self,
|
||||||
kind: Option<crate::control::PhotoKind>,
|
kind: Option<crate::control::PhotoKind>,
|
||||||
) -> Result<Vec<(i32, i32, usize)>> {
|
) -> Result<Vec<(i32, i32, usize)>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
|
// The scrubber has to agree with the page it scrubs, so it counts what
|
||||||
|
// the page shows: groups everywhere except on the Raw tab.
|
||||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
Some(crate::control::PhotoKind::Raw) => (" WHERE kind = 2", Vec::new()),
|
||||||
None => ("", Vec::new()),
|
Some(k) => (
|
||||||
|
" WHERE uid = COALESCE(group_key, uid) AND kind = ?1",
|
||||||
|
vec![k.as_i64()],
|
||||||
|
),
|
||||||
|
None => (" WHERE uid = COALESCE(group_key, uid)", Vec::new()),
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
"SELECT CAST(strftime('%Y', capture_time, 'unixepoch', 'localtime') AS INTEGER) AS y, \
|
||||||
@@ -201,8 +580,14 @@ impl Db {
|
|||||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||||
use crate::control::PhotoKind;
|
use crate::control::PhotoKind;
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
// Photos and Videos count groups — one shot is one entry, however many
|
||||||
|
// files it was stored as. Raw counts files, because the Raw tab lists
|
||||||
|
// them individually.
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT kind, COUNT(*) FROM photos \
|
||||||
|
WHERE uid = COALESCE(group_key, uid) OR kind = 2 GROUP BY kind",
|
||||||
|
)?;
|
||||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -223,23 +608,11 @@ impl Db {
|
|||||||
if uids.is_empty() {
|
if uids.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let placeholders = vec!["?"; uids.len()].join(",");
|
let placeholders = vec!["?"; uids.len()].join(",");
|
||||||
let mut stmt = conn.prepare(&format!(
|
let sql = format!("{PHOTO_SELECT} WHERE p.uid IN ({placeholders})");
|
||||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
WHERE uid IN ({placeholders})"
|
let rows = stmt.query_map(rusqlite::params_from_iter(uids), stored_photo)?;
|
||||||
))?;
|
|
||||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
|
||||||
Ok(StoredPhoto {
|
|
||||||
uid: r.get(0)?,
|
|
||||||
capture_time: r.get(1)?,
|
|
||||||
name: r.get(2)?,
|
|
||||||
ratio: r.get(3)?,
|
|
||||||
thumb_state: r.get(4)?,
|
|
||||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
|
||||||
favorite: r.get::<_, i64>(6)? != 0,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
let mut photos = Vec::new();
|
let mut photos = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
photos.push(row?);
|
photos.push(row?);
|
||||||
@@ -247,7 +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<()> {
|
||||||
@@ -273,7 +646,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Number of photos in the persisted timeline.
|
/// Number of photos in the persisted timeline.
|
||||||
pub fn photos_count(&self) -> Result<usize> {
|
pub fn photos_count(&self) -> Result<usize> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||||
Ok(n.max(0) as usize)
|
Ok(n.max(0) as usize)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
use rusqlite::{OptionalExtension, params};
|
use rusqlite::{OptionalExtension, params};
|
||||||
|
|
||||||
use super::Db;
|
use super::Db;
|
||||||
|
use super::utils::MAX_PATH_DEPTH;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
|
||||||
@@ -39,7 +40,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every directly-pinned entry, ordered by uid.
|
/// Every directly-pinned entry, ordered by uid.
|
||||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||||
// NULL for a pin whose node was never cached.
|
// NULL for a pin whose node was never cached.
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
@@ -66,8 +67,13 @@ impl Db {
|
|||||||
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
|
||||||
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
|
||||||
/// yet (e.g. a CLI that never hydrates the node cache).
|
/// yet (e.g. a CLI that never hydrates the node cache).
|
||||||
|
///
|
||||||
|
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
|
||||||
|
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
|
||||||
|
/// terminates — while holding the daemon's only SQLite connection, on a path
|
||||||
|
/// that runs per cached read.
|
||||||
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let direct: Option<i64> = conn
|
let direct: Option<i64> = conn
|
||||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||||
r.get(0)
|
r.get(0)
|
||||||
@@ -78,14 +84,18 @@ impl Db {
|
|||||||
}
|
}
|
||||||
let anc: Option<i64> = conn
|
let anc: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid) AS (
|
&format!(
|
||||||
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
|
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
|
||||||
UNION ALL
|
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
|
||||||
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
UNION ALL
|
||||||
)
|
SELECT n.uid, n.parent_uid, anc.depth + 1
|
||||||
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||||
WHERE a.uid != ?1 AND p.recursive = 1
|
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||||
LIMIT 1",
|
)
|
||||||
|
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
|
||||||
|
WHERE a.uid != ?1 AND p.recursive = 1
|
||||||
|
LIMIT 1"
|
||||||
|
),
|
||||||
params![uid],
|
params![uid],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
@@ -97,7 +107,7 @@ impl Db {
|
|||||||
/// every descendant of a recursively-pinned folder. Used to build the
|
/// every descendant of a recursively-pinned folder. Used to build the
|
||||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM pins WHERE recursive = 1
|
SELECT uid FROM pins WHERE recursive = 1
|
||||||
@@ -119,7 +129,7 @@ impl Db {
|
|||||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"WITH RECURSIVE sub(uid) AS (
|
"WITH RECURSIVE sub(uid) AS (
|
||||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ impl Db {
|
|||||||
/// remains the restart/offline authority and every explicit role change is
|
/// remains the restart/offline authority and every explicit role change is
|
||||||
/// written through before this cache is updated.
|
/// written through before this cache is updated.
|
||||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
@@ -42,7 +42,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read the effective access recorded for a shared-tree root.
|
/// Read the effective access recorded for a shared-tree root.
|
||||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let value = conn
|
let value = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||||
@@ -96,7 +96,7 @@ impl Db {
|
|||||||
/// share root. `None` means the node itself is not persisted, which callers
|
/// share root. `None` means the node itself is not persisted, which callers
|
||||||
/// must not treat as owned: stale handles need to fail closed.
|
/// must not treat as owned: stale handles need to fail closed.
|
||||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let exists: bool = conn.query_row(
|
let exists: bool = conn.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||||
[uid.to_string()],
|
[uid.to_string()],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::Result;
|
|||||||
|
|
||||||
impl Db {
|
impl Db {
|
||||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||||
@@ -32,7 +32,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Read a `sync_state` value as a string.
|
/// Read a `sync_state` value as a string.
|
||||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v = conn
|
let v = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
@@ -66,7 +66,7 @@ impl Db {
|
|||||||
/// dropping it would leave a file that exists nowhere but this machine with
|
/// dropping it would leave a file that exists nowhere but this machine with
|
||||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let v: Option<String> = conn
|
let v: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT value FROM sync_state WHERE key = ?1",
|
"SELECT value FROM sync_state WHERE key = ?1",
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
|||||||
pub remote_uid: Option<String>,
|
pub remote_uid: Option<String>,
|
||||||
pub local_mtime: i64,
|
pub local_mtime: i64,
|
||||||
pub local_size: i64,
|
pub local_size: i64,
|
||||||
|
/// The same modification time to nanosecond precision, when it is known.
|
||||||
|
///
|
||||||
|
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||||
|
/// second as the last sync — and left the file the same length — from no
|
||||||
|
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||||
|
/// written before schema 28, which the comparison reads as "compare whole
|
||||||
|
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||||
|
pub local_mtime_ns: Option<i64>,
|
||||||
pub remote_rev: Option<String>,
|
pub remote_rev: Option<String>,
|
||||||
pub remote_hash: Option<String>,
|
pub remote_hash: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -57,7 +65,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Every synced folder, oldest first.
|
/// Every synced folder, oldest first.
|
||||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder ORDER BY id",
|
FROM sync_folder ORDER BY id",
|
||||||
@@ -83,7 +91,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Look up one synced folder by id.
|
/// Look up one synced folder by id.
|
||||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||||
FROM sync_folder WHERE id = ?1",
|
FROM sync_folder WHERE id = ?1",
|
||||||
@@ -166,9 +174,10 @@ impl Db {
|
|||||||
|
|
||||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||||
|
local_mtime_ns
|
||||||
FROM sync_entry WHERE folder_id = ?1",
|
FROM sync_entry WHERE folder_id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![folder_id], |r| {
|
let rows = stmt.query_map(params![folder_id], |r| {
|
||||||
@@ -179,6 +188,7 @@ impl Db {
|
|||||||
local_size: r.get(3)?,
|
local_size: r.get(3)?,
|
||||||
remote_rev: r.get(4)?,
|
remote_rev: r.get(4)?,
|
||||||
remote_hash: r.get(5)?,
|
remote_hash: r.get(5)?,
|
||||||
|
local_mtime_ns: r.get(6)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
@@ -196,7 +206,7 @@ impl Db {
|
|||||||
/// `sync_folder` and descendants come from the last successful
|
/// `sync_folder` and descendants come from the last successful
|
||||||
/// `sync_entry` baseline.
|
/// `sync_entry` baseline.
|
||||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT EXISTS(
|
"SELECT EXISTS(
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -216,14 +226,16 @@ impl Db {
|
|||||||
let conn = self.conn.lock();
|
let conn = self.conn.lock();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_entry
|
"INSERT INTO sync_entry
|
||||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
remote_hash, local_mtime_ns)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||||
remote_uid = excluded.remote_uid,
|
remote_uid = excluded.remote_uid,
|
||||||
local_mtime = excluded.local_mtime,
|
local_mtime = excluded.local_mtime,
|
||||||
local_size = excluded.local_size,
|
local_size = excluded.local_size,
|
||||||
remote_rev = excluded.remote_rev,
|
remote_rev = excluded.remote_rev,
|
||||||
remote_hash = excluded.remote_hash",
|
remote_hash = excluded.remote_hash,
|
||||||
|
local_mtime_ns = excluded.local_mtime_ns",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
e.rel_path,
|
e.rel_path,
|
||||||
@@ -232,6 +244,7 @@ impl Db {
|
|||||||
e.local_size,
|
e.local_size,
|
||||||
e.remote_rev,
|
e.remote_rev,
|
||||||
e.remote_hash,
|
e.remote_hash,
|
||||||
|
e.local_mtime_ns,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+1399
-12
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
])?;
|
])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,9 +45,9 @@ impl Db {
|
|||||||
/// The persisted trash listing, folders first then by name — the order the
|
/// The persisted trash listing, folders first then by name — the order the
|
||||||
/// Trash page shows it in.
|
/// Trash page shows it in.
|
||||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||||
let conn = self.conn.lock();
|
let conn = self.read();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
"SELECT uid, name, is_dir, size, mtime, parent_uid 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) -----------------------------------------
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Small query helpers shared across the table modules.
|
//! Small query helpers shared across the table modules.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
@@ -8,14 +8,19 @@ use crate::Result;
|
|||||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||||
|
|
||||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||||
Ok((row.get(0)?, row.get(1)?))
|
/// only for a row written before the `path` column existed, which
|
||||||
|
/// [`path_of`] repairs by walking.
|
||||||
|
pub(super) type HitRow = (String, String, Option<String>);
|
||||||
|
|
||||||
|
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||||
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||||
pub(super) fn collect_pairs(
|
pub(super) fn collect_hits(
|
||||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||||
) -> Result<Vec<(String, String)>> {
|
) -> Result<Vec<HitRow>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
out.push(r?);
|
out.push(r?);
|
||||||
@@ -35,18 +40,44 @@ pub(super) fn like_escape(s: &str) -> String {
|
|||||||
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
|
||||||
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
/// path of every search result, including the `LIKE` lane that reads `nodes`
|
||||||
/// directly and so never passed through the index's cycle check.
|
/// directly and so never passed through the index's cycle check.
|
||||||
const MAX_PATH_DEPTH: usize = 256;
|
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||||
|
|
||||||
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
|
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||||
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
|
/// whose name is the mount and so contributes nothing.
|
||||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||||
|
if parent_path.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent_path}/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||||
|
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||||
|
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||||
|
///
|
||||||
|
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||||
|
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||||
|
/// column or was never written through — a search serving a keystroke used to
|
||||||
|
/// run that walk once per candidate, which is the reason the column exists.
|
||||||
|
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
|
let stored: Option<Option<String>> = conn
|
||||||
|
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
match stored.flatten() {
|
||||||
|
Some(path) => Ok(path),
|
||||||
|
None => walk_path_of(conn, uid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
||||||
///
|
///
|
||||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
||||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||||
/// and returns the truncated path rather than hanging the caller. That caller is
|
/// and returns the truncated path rather than hanging the caller.
|
||||||
/// usually a search serving a keystroke, holding the daemon's only SQLite
|
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||||
/// connection.
|
|
||||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub enum Error {
|
|||||||
Proton(#[from] proton_sdk::ProtonError),
|
Proton(#[from] proton_sdk::ProtonError),
|
||||||
|
|
||||||
#[error("keyring: {0}")]
|
#[error("keyring: {0}")]
|
||||||
Keyring(#[from] keyring::Error),
|
Keyring(#[from] keyring_core::Error),
|
||||||
|
|
||||||
#[error("serialization: {0}")]
|
#[error("serialization: {0}")]
|
||||||
Serde(#[from] serde_json::Error),
|
Serde(#[from] serde_json::Error),
|
||||||
@@ -45,6 +45,27 @@ pub enum Error {
|
|||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Error {
|
||||||
|
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
|
||||||
|
/// other I/O failure.
|
||||||
|
///
|
||||||
|
/// Worth distinguishing because it is the one storage failure the user can
|
||||||
|
/// act on, and because the caller's response differs: a full disk is met by
|
||||||
|
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
|
||||||
|
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
|
||||||
|
/// compares the raw code.
|
||||||
|
pub fn is_disk_full(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
|
||||||
|
// SQLite reports it as its own code rather than surfacing the errno.
|
||||||
|
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
|
||||||
|
e.code == rusqlite::ErrorCode::DiskFull
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
/// A failure on its way to a front-end: prose for the user, plus the
|
/// A failure on its way to a front-end: prose for the user, plus the
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Remote-event invalidation and local-index background workers.
|
//! Remote-event invalidation and local-index background workers.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::shutdown::Shutdown;
|
||||||
|
|
||||||
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
/// Drop `uid` from one mount's tree and tell that mount's kernel session the
|
||||||
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
/// entry is gone. Shared by the trash and delete paths, which differ only in
|
||||||
@@ -8,35 +9,20 @@ use super::*;
|
|||||||
///
|
///
|
||||||
/// Returns whether this mount actually held the node, so the caller can evict
|
/// Returns whether this mount actually held the node, so the caller can evict
|
||||||
/// its content blob once if *any* mount did.
|
/// its content blob once if *any* mount did.
|
||||||
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||||
// Capture the inode before `forget` clears the uid mapping.
|
// Capture the inode before `forget` clears the uid mapping.
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let Some((parent, name)) = st.forget(uid) else {
|
let Some((parent, name)) = st.forget(uid) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if let Some(notifier) = notifier {
|
match child {
|
||||||
match child {
|
Some(child) => notify.delete(parent, child, name),
|
||||||
Some(child) => {
|
None => notify.inval_entry(parent, name),
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
fn hide_foreign_deleted_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) {
|
||||||
let Some(notifier) = notifier else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for &ino in changed {
|
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
|
|
||||||
let child = st.by_uid.get(uid).copied();
|
let child = st.by_uid.get(uid).copied();
|
||||||
let dentry = child.and_then(|ino| {
|
let dentry = child.and_then(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -45,9 +31,9 @@ fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>,
|
|||||||
});
|
});
|
||||||
let changed = st.downgrade_shared_subtree(uid);
|
let changed = st.downgrade_shared_subtree(uid);
|
||||||
st.hide_foreign_subtree(uid);
|
st.hide_foreign_subtree(uid);
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
if let (Some((parent, name)), Some(child)) = (dentry, child) {
|
||||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
notify.delete(parent, child, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,18 +118,18 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
&& foreign_delete
|
&& foreign_delete
|
||||||
{
|
{
|
||||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
hide_foreign_deleted_and_notify(st, notifier, uid);
|
hide_foreign_deleted_and_notify(st, notify, uid);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
apply_access_downgrade(&core.db, event, |root| {
|
apply_access_downgrade(&core.db, event, |root| {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let changed = match root {
|
let changed = match root {
|
||||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||||
None => st.downgrade_known_shared_access(),
|
None => st.downgrade_known_shared_access(),
|
||||||
};
|
};
|
||||||
invalidate_access_changes(notifier, &changed);
|
notify.extend_inodes(&changed);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -173,19 +159,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||||
}
|
}
|
||||||
let mut had_node = false;
|
let mut had_node = false;
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
if *is_trashed {
|
if *is_trashed {
|
||||||
// Trashing makes a node vanish from its parent listing.
|
// Trashing makes a node vanish from its parent listing.
|
||||||
had_node |= forget_and_notify(st, notifier, node_uid);
|
had_node |= forget_and_notify(st, notify, node_uid);
|
||||||
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||||
// Known node changed: drop its cached attrs/data (and
|
// Known node changed: drop its cached attrs/data (and
|
||||||
// listing if it is a directory) so the next access
|
// listing if it is a directory) so the next access
|
||||||
// re-fetches. Its content blob may now be stale too.
|
// re-fetches. Its content blob may now be stale too.
|
||||||
had_node = true;
|
had_node = true;
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Once, not once per mount: the content cache is per-daemon.
|
// Once, not once per mount: the content cache is per-daemon.
|
||||||
@@ -209,8 +193,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||||
core.cache.evict(node_uid);
|
core.cache.evict(node_uid);
|
||||||
if !foreign_delete {
|
if !foreign_delete {
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
forget_and_notify(st, notifier, node_uid);
|
forget_and_notify(st, notify, node_uid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,13 +207,11 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
|||||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||||
core.for_each_mount(|st, notifier| {
|
core.for_each_mount(|st, notify| {
|
||||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||||
for &ino in &dirs {
|
for &ino in &dirs {
|
||||||
st.invalidate_listing(ino);
|
st.invalidate_listing(ino);
|
||||||
if let Some(notifier) = notifier {
|
notify.inval_inode(ino);
|
||||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,18 +285,16 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
core.for_each_mount(|st, notifier| {
|
fn drop_listing(st: &mut State, notify: &mut NotifyBatch, parent_uid: &NodeUid) {
|
||||||
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
return;
|
||||||
return;
|
|
||||||
};
|
|
||||||
st.invalidate_listing(parent);
|
|
||||||
if let Some(notifier) = notifier {
|
|
||||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
st.invalidate_listing(parent);
|
||||||
|
notify.inval_inode(parent);
|
||||||
|
}
|
||||||
|
core.for_each_mount(|st, notify| {
|
||||||
for parent_uid in &dirty.foreign {
|
for parent_uid in &dirty.foreign {
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
for (parent_uid, children) in &dirty.ours {
|
for (parent_uid, children) in &dirty.ours {
|
||||||
if dirty.foreign.contains(parent_uid) {
|
if dirty.foreign.contains(parent_uid) {
|
||||||
@@ -328,7 +308,7 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
drop_listing(st, parent_uid);
|
drop_listing(st, notify, parent_uid);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -445,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.
|
||||||
@@ -458,6 +597,7 @@ pub(super) fn run_local_index(
|
|||||||
indexing: Arc<AtomicBool>,
|
indexing: Arc<AtomicBool>,
|
||||||
transfers: Arc<TransferRegistry>,
|
transfers: Arc<TransferRegistry>,
|
||||||
mountpoint: PathBuf,
|
mountpoint: PathBuf,
|
||||||
|
shutdown: Arc<Shutdown>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
let age = db.local_indexed_at().ok().flatten();
|
let age = db.local_indexed_at().ok().flatten();
|
||||||
@@ -466,7 +606,11 @@ pub(super) fn run_local_index(
|
|||||||
if stale {
|
if stale {
|
||||||
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
scan_local_once(&db, &indexing, &transfers, &mountpoint);
|
||||||
}
|
}
|
||||||
std::thread::sleep(LOCAL_INDEX_CHECK);
|
// Interruptible, so teardown joins this thread instead of leaving it to
|
||||||
|
// keep rewriting the index for a mount that is gone (bugs.md B44).
|
||||||
|
if !shutdown.sleep(LOCAL_INDEX_CHECK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +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();
|
||||||
|
|
||||||
@@ -556,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+602
-75
@@ -11,25 +11,87 @@
|
|||||||
|
|
||||||
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
|
||||||
|
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||||
|
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||||
|
/// responses (photo bytes) over a local socket, finite so a wedged peer can't
|
||||||
|
/// hold a permit.
|
||||||
|
const CONTROL_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const MAX_CONTROL_HANDLERS: usize = 64;
|
const MAX_CONTROL_HANDLERS: usize = 64;
|
||||||
|
/// Ceiling on any client-supplied row `limit`. Every in-tree caller asks for far
|
||||||
|
/// less (200 for activity, 60 for a photo page), but the value goes straight
|
||||||
|
/// into `SQL LIMIT ?` under the daemon's single connection — an unclamped one
|
||||||
|
/// materialises the whole account, and then serialises it into one JSON line.
|
||||||
|
const MAX_ROW_LIMIT: usize = 1000;
|
||||||
|
|
||||||
|
/// Clamp a client-supplied row limit to [`MAX_ROW_LIMIT`].
|
||||||
|
fn clamp_limit(limit: usize) -> usize {
|
||||||
|
limit.min(MAX_ROW_LIMIT)
|
||||||
|
}
|
||||||
|
/// Answer an album add or remove: parse the uids, run `change`, and report
|
||||||
|
/// per photo.
|
||||||
|
fn album_change(
|
||||||
|
uid: &str,
|
||||||
|
photos: &[String],
|
||||||
|
change: impl FnOnce(&NodeUid, &[NodeUid]) -> CoreResult<super::albums::AlbumOutcome>,
|
||||||
|
) -> CtlResponse {
|
||||||
|
let Some(album) = parse_uid(uid) else {
|
||||||
|
return CtlResponse::error(CoreError::invalid(format!("bad album uid: {uid}")));
|
||||||
|
};
|
||||||
|
let photos = match Core::parse_uids(photos) {
|
||||||
|
Ok(photos) => photos,
|
||||||
|
Err(e) => return CtlResponse::error(e),
|
||||||
|
};
|
||||||
|
match change(&album, &photos) {
|
||||||
|
Ok((changed, failed)) => CtlResponse::AlbumChanged {
|
||||||
|
changed,
|
||||||
|
failed: failed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(uid, message)| TrashFailure { uid, message })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
struct ControlHandlerPermit;
|
struct ControlHandlerPermit;
|
||||||
@@ -51,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 {
|
||||||
@@ -88,27 +160,109 @@ fn read_request_line(reader: &mut impl BufRead) -> std::io::Result<Option<String
|
|||||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a CLI-supplied path into the mount that owns it and the path relative
|
||||||
|
/// to *that* mount's root.
|
||||||
|
///
|
||||||
|
/// [`rel_to_mount`] is the older, primary-only reading, and it is what every
|
||||||
|
/// mutating request still uses. It rejects anything outside the primary
|
||||||
|
/// mountpoint, which leaves a secondary on-demand mount — a synced folder
|
||||||
|
/// switched out of mirror mode — with no way to be named at all: `pdfs ls` and
|
||||||
|
/// `pdfs refresh` both answered "is not under the mountpoint", so a folder
|
||||||
|
/// serving a stale listing had no manual escape hatch (`docs/BUGS.md` B86).
|
||||||
|
///
|
||||||
|
/// A relative path keeps the old meaning, relative to the primary mount, since
|
||||||
|
/// there is nothing in it to choose a mount by.
|
||||||
|
fn route_to_mount(core: &Core, mountpoint: &Path, path: &str) -> CoreResult<(Core, PathBuf)> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
if !p.is_absolute() {
|
||||||
|
return Ok((core.clone(), p.to_path_buf()));
|
||||||
|
}
|
||||||
|
if let Some(routed) = core.rooted_at(p) {
|
||||||
|
return Ok(routed);
|
||||||
|
}
|
||||||
|
// Fall back to the primary reading so the error text stays the familiar one
|
||||||
|
// when the path is simply outside everything this daemon has mounted.
|
||||||
|
rel_to_mount(mountpoint, path).map(|rel| (core.clone(), rel))
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
/// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path
|
||||||
/// must live under `mountpoint`; a relative path is taken as already relative to
|
/// must live under `mountpoint`; a relative path is taken as already relative to
|
||||||
/// the mount root.
|
/// the mount root.
|
||||||
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult<PathBuf> {
|
||||||
let p = Path::new(path);
|
let p = Path::new(path);
|
||||||
if p.is_absolute() {
|
let relative = if p.is_absolute() {
|
||||||
p.strip_prefix(mountpoint)
|
p.strip_prefix(mountpoint)
|
||||||
.map(Path::to_path_buf)
|
.map(Path::to_path_buf)
|
||||||
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
.map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint")))
|
||||||
} else {
|
} else {
|
||||||
Ok(p.to_path_buf())
|
Ok(p.to_path_buf())
|
||||||
|
}?;
|
||||||
|
|
||||||
|
if relative
|
||||||
|
.components()
|
||||||
|
.all(|component| matches!(component, Component::Normal(_) | Component::CurDir))
|
||||||
|
{
|
||||||
|
Ok(relative)
|
||||||
|
} else {
|
||||||
|
Err(CoreError::invalid(format!("{path} escapes the mountpoint")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle one control-socket connection: read a single JSON request line,
|
/// Handle one control-socket connection: read a single JSON request line,
|
||||||
/// dispatch it against `core`, and write back a JSON response line.
|
/// dispatch it against `core`, and write back a JSON response line.
|
||||||
|
/// Refresh scopes being served right now, one entry per running refresh.
|
||||||
|
///
|
||||||
|
/// A `Vec` because there are three scopes and at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// handlers, so a set would buy nothing.
|
||||||
|
static REFRESHING: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// What makes two refreshes the same work: the scope, and for a directory the
|
||||||
|
/// folder, since refreshing one folder says nothing about another. A full
|
||||||
|
/// photos refresh is not the same work as an ordinary one — it forgets what the
|
||||||
|
/// daemon knows first — so it keys apart.
|
||||||
|
fn scope_key(scope: &RefreshScope) -> String {
|
||||||
|
match scope {
|
||||||
|
RefreshScope::Dir { path } => format!("dir:{path}"),
|
||||||
|
RefreshScope::Trash => "trash".to_string(),
|
||||||
|
RefreshScope::Photos { full: true } => "photos:full".to_string(),
|
||||||
|
RefreshScope::Photos { full: false } => "photos".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds one refresh scope for as long as it is being served. `acquire`
|
||||||
|
/// returns `None` when that scope is already in flight.
|
||||||
|
struct RefreshGuard(String);
|
||||||
|
|
||||||
|
impl RefreshGuard {
|
||||||
|
fn acquire(scope: &RefreshScope) -> Option<Self> {
|
||||||
|
let key = scope_key(scope);
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if running.contains(&key) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
running.push(key.clone());
|
||||||
|
Some(Self(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RefreshGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let mut running = REFRESHING.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(at) = running.iter().position(|key| *key == self.0) {
|
||||||
|
running.swap_remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if let Err(e) = stream.set_write_timeout(Some(CONTROL_WRITE_TIMEOUT)) {
|
||||||
|
warn!(error = %e, "control: could not set response timeout");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let mut reader = BufReader::new(match stream.try_clone() {
|
let mut reader = BufReader::new(match stream.try_clone() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -124,11 +278,22 @@ 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();
|
||||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||||
|
let staging = core.cache.staging_usage();
|
||||||
CtlResponse::Status {
|
CtlResponse::Status {
|
||||||
|
parked_uploads: queued.parked.max(0) as u64,
|
||||||
|
failing_ops: queued.failing.max(0) as u64,
|
||||||
|
failing_error: queued.last_error.clone(),
|
||||||
|
staged_bytes: staging.bytes,
|
||||||
|
staged_oldest_secs: staging.oldest_secs,
|
||||||
|
paused: core.sync_pause().is_some(),
|
||||||
|
paused_until: core.sync_pause().flatten(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
mountpoint: mountpoint.display().to_string(),
|
mountpoint: mountpoint.display().to_string(),
|
||||||
pinned: pins.len(),
|
pinned: pins.len(),
|
||||||
@@ -165,46 +330,85 @@ 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 rel_to_mount(mountpoint, &path) {
|
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||||
Ok(rel) => match core.list_dir(&rel) {
|
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||||
Ok(entries) => CtlResponse::Entries { entries },
|
Ok(entries) => CtlResponse::Entries { entries },
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
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 } => {
|
// Running it again cannot make the answer newer, and the duplicate
|
||||||
rel_to_mount(mountpoint, path).and_then(|rel| core.refresh_dir(&rel))
|
// would spend its whole run contending for the state and database
|
||||||
|
// locks the first one holds — which is how five stacked photo
|
||||||
|
// refreshes sat in flight for twenty-three minutes at mount time.
|
||||||
|
None => {
|
||||||
|
debug!(scope = %scope_key(&scope), "a refresh of this scope is already running");
|
||||||
|
CtlResponse::Ok {
|
||||||
|
message: "refreshing".to_string(),
|
||||||
}
|
}
|
||||||
RefreshScope::Trash => {
|
|
||||||
core.invalidate_trash();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
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,
|
||||||
kind,
|
kind,
|
||||||
range,
|
range,
|
||||||
favorites,
|
favorites,
|
||||||
}) => match core.photos_timeline(offset, limit, kind, range, favorites) {
|
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
||||||
Ok(Some(items)) => CtlResponse::Photos {
|
Ok(Some(items)) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -217,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),
|
||||||
@@ -245,7 +457,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
// An album page is a timeline page as far as the front-end is
|
// An album page is a timeline page as far as the front-end is
|
||||||
// concerned, so it comes back in the same reply — with `counts`
|
// concerned, so it comes back in the same reply — with `counts`
|
||||||
// unset, which describe the whole timeline rather than this album.
|
// unset, which describe the whole timeline rather than this album.
|
||||||
Some(album) => match core.album_photos(&album, offset, limit) {
|
Some(album) => match core.album_photos(&album, offset, clamp_limit(limit)) {
|
||||||
Ok(items) => CtlResponse::Photos {
|
Ok(items) => CtlResponse::Photos {
|
||||||
available: true,
|
available: true,
|
||||||
items,
|
items,
|
||||||
@@ -255,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
|
||||||
@@ -270,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 {
|
||||||
@@ -279,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,
|
||||||
@@ -346,22 +709,28 @@ 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, 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),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
|
Ok(CtlRequest::SearchLocal { query, limit }) => {
|
||||||
Ok(hits) => CtlResponse::LocalResults {
|
match core.search_local(&query, clamp_limit(limit)) {
|
||||||
hits,
|
Ok(hits) => CtlResponse::LocalResults {
|
||||||
indexing: core.indexing.load(Ordering::Relaxed),
|
hits,
|
||||||
},
|
indexing: core.indexing.load(Ordering::Relaxed),
|
||||||
Err(e) => CtlResponse::error(e),
|
},
|
||||||
},
|
Err(e) => CtlResponse::error(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(CtlRequest::SearchV2 {
|
Ok(CtlRequest::SearchV2 {
|
||||||
query,
|
query,
|
||||||
limit,
|
limit,
|
||||||
filters,
|
filters,
|
||||||
}) => match core.search_v2(&query, limit, &filters) {
|
}) => match core.search_v2(&query, clamp_limit(limit), &filters) {
|
||||||
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
|
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
|
||||||
drive_hits,
|
drive_hits,
|
||||||
local_hits,
|
local_hits,
|
||||||
@@ -493,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),
|
||||||
@@ -522,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),
|
||||||
@@ -540,19 +917,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
||||||
Ok(stats) => {
|
Ok(stats) => {
|
||||||
// The deep check reads every page, so it runs only on request;
|
// The deep check reads every page — tens of seconds on a real
|
||||||
// `integrity_checked` tells the caller which answer it is
|
// database, all of it holding the connection every FUSE handler
|
||||||
// holding, since "no problems found" and "never looked" are
|
// needs — so it runs on a thread and this request returns
|
||||||
// both an empty list.
|
// whatever the last completed check found. `integrity_checked`
|
||||||
let (integrity_problems, integrity_checked) = if deep {
|
// tells the caller which answer it is holding, since "no
|
||||||
match core.db.integrity_check() {
|
// problems found" and "never looked" are both an empty list;
|
||||||
Ok(problems) => (problems, true),
|
// `integrity_running` says a fresh answer is on its way.
|
||||||
Err(e) => (vec![format!("integrity check failed: {e:?}")], true),
|
let (integrity_problems, integrity_checked, integrity_running) = if deep {
|
||||||
}
|
core.start_or_read_integrity_check()
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), false)
|
(Vec::new(), false, false)
|
||||||
};
|
};
|
||||||
CtlResponse::CacheReport {
|
CtlResponse::CacheReport {
|
||||||
|
integrity_running,
|
||||||
schema_version: stats.schema_version,
|
schema_version: stats.schema_version,
|
||||||
db_bytes: stats.total_bytes(),
|
db_bytes: stats.total_bytes(),
|
||||||
db_reclaimable_bytes: stats.reclaimable_bytes(),
|
db_reclaimable_bytes: stats.reclaimable_bytes(),
|
||||||
@@ -565,17 +943,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
|
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::CacheVacuum) => match core.db.vacuum() {
|
// Ack and vacuum on a thread. `VACUUM` rewrites the whole database
|
||||||
Ok(outcome) => CtlResponse::Ok {
|
// under the connection mutex — minutes on a large one — and the
|
||||||
message: format!(
|
// request's own socket timeout is 10 s, so running it inline both froze
|
||||||
"vacuumed: {:.1} MiB freed ({:.1} MiB → {:.1} MiB), {} WAL frame(s) checkpointed",
|
// the mount and lost its own answer. Progress shows up as a job in
|
||||||
outcome.freed_bytes() as f64 / 1_048_576.0,
|
// `GetQueueStatus`; the outcome goes to the log.
|
||||||
outcome.before_bytes as f64 / 1_048_576.0,
|
Ok(CtlRequest::CacheVacuum) => match core.start_vacuum() {
|
||||||
outcome.after_bytes as f64 / 1_048_576.0,
|
Ok(()) => CtlResponse::Ok {
|
||||||
outcome.wal_frames_checkpointed,
|
message: "compacting the cache database in the background".into(),
|
||||||
),
|
|
||||||
},
|
},
|
||||||
Err(e) => CtlResponse::error(CoreError::internal(format!("vacuum: {e:?}"))),
|
Err(e) => CtlResponse::error(e),
|
||||||
},
|
},
|
||||||
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
||||||
items: core.transfers.snapshot(),
|
items: core.transfers.snapshot(),
|
||||||
@@ -604,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),
|
||||||
@@ -687,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 {
|
||||||
@@ -696,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 {
|
||||||
@@ -882,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 },
|
||||||
@@ -1023,7 +1494,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::ListActivity { limit }) => CtlResponse::Activity {
|
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
||||||
items: core.list_activity(limit),
|
items: core.list_activity(clamp_limit(limit)),
|
||||||
},
|
},
|
||||||
// The request did not parse: the caller sent something malformed.
|
// The request did not parse: the caller sent something malformed.
|
||||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||||
@@ -1056,6 +1527,12 @@ pub(crate) fn run_control_socket(
|
|||||||
) {
|
) {
|
||||||
info!("control socket listening");
|
info!("control socket listening");
|
||||||
for conn in listener.incoming() {
|
for conn in listener.incoming() {
|
||||||
|
// Checked on every accept, including the deliberate poke teardown sends
|
||||||
|
// to wake this thread out of a blocking accept (bugs.md B44).
|
||||||
|
if core.shutdown.is_stopping() {
|
||||||
|
info!("control socket closing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
match conn {
|
match conn {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
let Some(permit) = ControlHandlerPermit::acquire() else {
|
let Some(permit) = ControlHandlerPermit::acquire() else {
|
||||||
@@ -1118,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())
|
||||||
}
|
}
|
||||||
@@ -536,7 +571,14 @@ impl Core {
|
|||||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||||
// Tear it down first — including before trashing the remote tree it
|
// Tear it down first — including before trashing the remote tree it
|
||||||
// serves, which would otherwise leave it answering for deleted nodes.
|
// serves, which would otherwise leave it answering for deleted nodes.
|
||||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
// Taken in its own statement so the registry lock is released before the
|
||||||
|
// teardown: an edition-2024 `if let` chain holds the guard for the whole
|
||||||
|
// body, and `teardown` unmounts and joins the session thread — which can
|
||||||
|
// be inside a multi-second transfer. The shutdown path takes the same
|
||||||
|
// lock, so holding it here risks systemd's stop timeout landing mid-
|
||||||
|
// unmount and stranding the endpoint.
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
@@ -554,6 +596,7 @@ impl Core {
|
|||||||
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
return Err(CoreError::invalid(format!("no synced folder with id {id}")));
|
||||||
}
|
}
|
||||||
drop(_guard);
|
drop(_guard);
|
||||||
|
self.clear_sync_folder_paused(id);
|
||||||
// Stop watching the folder we just dropped.
|
// Stop watching the folder we just dropped.
|
||||||
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
let _ = self.sync_tx.send(sync::SyncMsg::Rewatch);
|
||||||
if delete_remote
|
if delete_remote
|
||||||
@@ -584,19 +627,8 @@ impl Core {
|
|||||||
pub(crate) fn fork_state(&self) -> Core {
|
pub(crate) fn fork_state(&self) -> Core {
|
||||||
let mut fork = self.clone();
|
let mut fork = self.clone();
|
||||||
fork.primary = false;
|
fork.primary = false;
|
||||||
let share_access = self.state.lock().share_access.clone();
|
let share_access = self.state().share_access.clone();
|
||||||
fork.state = Arc::new(Mutex::new(State {
|
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: std::collections::HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: self.db.clone(),
|
|
||||||
}));
|
|
||||||
// A fresh inode space needs a fresh notification channel: this fork's
|
// A fresh inode space needs a fresh notification channel: this fork's
|
||||||
// session is the only one that knows these inodes, so it must be the one
|
// session is the only one that knows these inodes, so it must be the one
|
||||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||||
@@ -629,6 +661,13 @@ impl Core {
|
|||||||
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
let current_mode = ApplicableMountMode::parse(&folder.mode)
|
||||||
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
.map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?;
|
||||||
|
// A switch waits on the folder's reconcile, which a pause skips; refuse
|
||||||
|
// it up front rather than queue it indefinitely.
|
||||||
|
if current_mode != target_mode && self.sync_folder_paused(id) {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this folder is paused; resume it before changing its mode",
|
||||||
|
));
|
||||||
|
}
|
||||||
if current_mode == target_mode {
|
if current_mode == target_mode {
|
||||||
let mode_str = target_mode.as_str();
|
let mode_str = target_mode.as_str();
|
||||||
let lock = self.sync_lock(id);
|
let lock = self.sync_lock(id);
|
||||||
@@ -866,8 +905,11 @@ impl Core {
|
|||||||
Ok(format!("{} is now on-demand", local.display()))
|
Ok(format!("{} is now on-demand", local.display()))
|
||||||
}
|
}
|
||||||
ApplicableMountMode::Mirror => {
|
ApplicableMountMode::Mirror => {
|
||||||
// ondemand→mirror: tear down the secondary mount first.
|
// ondemand→mirror: tear down the secondary mount first. Taken
|
||||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
// before the teardown so the registry lock is not held across
|
||||||
|
// the unmount and thread join (see `remove_sync_folder`).
|
||||||
|
let taken = self.mounts.lock().remove(&id);
|
||||||
|
if let Some(mount) = taken
|
||||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||||
{
|
{
|
||||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! What the daemon is doing right now, for the case where it has stopped doing
|
||||||
|
//! anything at all.
|
||||||
|
//!
|
||||||
|
//! On 2026-09-18 the daemon hung: it consumed 43 minutes of CPU over 20 hours,
|
||||||
|
//! peaked at 5.2 GiB resident, went silent at 17:10, and only came back after a
|
||||||
|
//! manual `systemctl --user restart`. There was no panic, so nothing in the
|
||||||
|
//! journal said what it had been waiting on — and nothing in the daemon could be
|
||||||
|
//! asked, either. This module is the answer to "what was it stuck on?".
|
||||||
|
//!
|
||||||
|
//! Everything here is built to work *while* the daemon is wedged, which rules
|
||||||
|
//! out the obvious implementations: no lock that a stuck job might hold, no
|
||||||
|
//! database query on the calling thread, no waiting on the worker pool. A
|
||||||
|
//! diagnostic that blocks on the hang it is diagnosing reports nothing at
|
||||||
|
//! exactly the moment it matters.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
|
||||||
|
/// When this process started. Read at the first touch, which
|
||||||
|
/// [`super::mount::mount`] makes happen during startup so the uptime is the
|
||||||
|
/// daemon's, not the first diagnostics request's.
|
||||||
|
static STARTED: LazyLock<Instant> = LazyLock::new(Instant::now);
|
||||||
|
|
||||||
|
/// Control requests currently being served, keyed by a monotonic ticket.
|
||||||
|
///
|
||||||
|
/// A `Vec` rather than a map because it holds at most `MAX_CONTROL_HANDLERS`
|
||||||
|
/// entries, and the only operations are push, remove-by-ticket and read-all.
|
||||||
|
static INFLIGHT: Mutex<Vec<(u64, String, Instant)>> = Mutex::new(Vec::new());
|
||||||
|
static NEXT_TICKET: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Start the daemon's uptime clock. Called once, from the mount path.
|
||||||
|
pub(crate) fn start_clock() {
|
||||||
|
LazyLock::force(&STARTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers one in-flight control request for as long as it is held.
|
||||||
|
pub(crate) struct InflightGuard(u64);
|
||||||
|
|
||||||
|
impl InflightGuard {
|
||||||
|
pub(crate) fn new(kind: String) -> Self {
|
||||||
|
let ticket = NEXT_TICKET.fetch_add(1, Ordering::Relaxed);
|
||||||
|
INFLIGHT.lock().push((ticket, kind, Instant::now()));
|
||||||
|
Self(ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InflightGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
INFLIGHT.lock().retain(|(ticket, _, _)| *ticket != self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name of a serialised [`pdfs_core::control::Request`], without
|
||||||
|
/// parsing it into one.
|
||||||
|
///
|
||||||
|
/// serde's external tagging makes this a property of the text: a unit variant is
|
||||||
|
/// the bare string `"Status"`, anything else is an object whose single key is
|
||||||
|
/// the variant name. Reading the name off the line rather than matching on 80
|
||||||
|
/// variants keeps this cheap, and keeps it correct when a variant is added.
|
||||||
|
pub(crate) fn request_kind(line: &str) -> String {
|
||||||
|
let line = line.trim();
|
||||||
|
let name = if let Some(rest) = line.strip_prefix('{') {
|
||||||
|
rest.trim_start()
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
} else {
|
||||||
|
line.strip_prefix('"')
|
||||||
|
.and_then(|rest| rest.split('"').next())
|
||||||
|
};
|
||||||
|
let name = name.unwrap_or("unknown");
|
||||||
|
// A bare `Refresh` says nothing about which view wedged, and the three
|
||||||
|
// scopes cost wildly different amounts of work: a directory refresh is two
|
||||||
|
// cheap calls, a photos refresh can pull a whole library. The scope is one
|
||||||
|
// more peek into the same line.
|
||||||
|
if name == "Refresh"
|
||||||
|
&& let Some(scope) = tagged_name_after(line, "\"scope\"")
|
||||||
|
{
|
||||||
|
return format!("Refresh({scope})");
|
||||||
|
}
|
||||||
|
name.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The variant name serde wrote for the value of `key`, whether that value is a
|
||||||
|
/// bare string (a unit variant) or an object whose single key is the name.
|
||||||
|
fn tagged_name_after(line: &str, key: &str) -> Option<String> {
|
||||||
|
let rest = line.split_once(key)?.1.trim_start();
|
||||||
|
let rest = rest.strip_prefix(':')?.trim_start();
|
||||||
|
let rest = rest.strip_prefix('{').map_or(rest, str::trim_start);
|
||||||
|
rest.strip_prefix('"')?
|
||||||
|
.split('"')
|
||||||
|
.next()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resident set size in bytes, or `0` when `/proc` did not answer.
|
||||||
|
///
|
||||||
|
/// From `VmRSS` in `/proc/self/status`, which is already in kB — `statm` would
|
||||||
|
/// need the page size, and this needs no dependency at all.
|
||||||
|
pub(crate) fn rss_bytes() -> u64 {
|
||||||
|
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
parse_vm_rss(&status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `VmRSS` line of a `/proc/<pid>/status`, in bytes.
|
||||||
|
fn parse_vm_rss(status: &str) -> u64 {
|
||||||
|
status
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||||
|
.and_then(|rest| rest.split_whitespace().next())
|
||||||
|
.and_then(|kb| kb.parse::<u64>().ok())
|
||||||
|
.map_or(0, |kb| kb.saturating_mul(1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Assemble a [`Diagnostics`] report.
|
||||||
|
///
|
||||||
|
/// The pending-op count is the one field that needs the database, so it is
|
||||||
|
/// read on a throwaway thread with a short deadline: a daemon wedged *on*
|
||||||
|
/// the database must still be able to report everything else about itself.
|
||||||
|
pub(crate) fn diagnostics(&self) -> Diagnostics {
|
||||||
|
let pool = self.workers.snapshot();
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut inflight: Vec<InflightRequest> = INFLIGHT
|
||||||
|
.try_lock()
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|(_, kind, started)| InflightRequest {
|
||||||
|
kind: kind.clone(),
|
||||||
|
age_secs: now.duration_since(*started).as_secs(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
inflight.sort_by_key(|request| std::cmp::Reverse(request.age_secs));
|
||||||
|
|
||||||
|
Diagnostics {
|
||||||
|
uptime_secs: STARTED.elapsed().as_secs(),
|
||||||
|
rss_bytes: rss_bytes(),
|
||||||
|
workers: pool
|
||||||
|
.workers
|
||||||
|
.into_iter()
|
||||||
|
.map(|worker| WorkerState {
|
||||||
|
name: worker.name,
|
||||||
|
busy: worker.busy,
|
||||||
|
label: worker.label,
|
||||||
|
age_secs: worker.age_ms / 1000,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
meta_queued: pool.meta_queued,
|
||||||
|
transfer_queued: pool.transfer_queued,
|
||||||
|
meta_completed: pool.meta_completed,
|
||||||
|
transfer_completed: pool.transfer_completed,
|
||||||
|
inflight,
|
||||||
|
control_handlers: super::control::active_handlers(),
|
||||||
|
control_handler_limit: super::control::handler_limit(),
|
||||||
|
pending_ops: self.pending_ops_quickly(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queued mutations, or `0` if the database did not answer within
|
||||||
|
/// [`PENDING_COUNT_DEADLINE`].
|
||||||
|
fn pending_ops_quickly(&self) -> u64 {
|
||||||
|
let db = self.db.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
if std::thread::Builder::new()
|
||||||
|
.name("pdfs-diagnostics".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let counts = db.pending_op_counts().unwrap_or_default();
|
||||||
|
let total = counts.uploads.max(0) as u64 + counts.changes.max(0) as u64;
|
||||||
|
let _ = tx.send(total);
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
rx.recv_timeout(PENDING_COUNT_DEADLINE).unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the report waits for the database before reporting without it.
|
||||||
|
const PENDING_COUNT_DEADLINE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The request name is read off the wire format, so it must match both
|
||||||
|
/// shapes serde produces.
|
||||||
|
#[test]
|
||||||
|
fn the_request_kind_comes_off_both_wire_shapes() {
|
||||||
|
assert_eq!(request_kind("\"Status\""), "Status");
|
||||||
|
assert_eq!(request_kind("{\"Pin\":{\"path\":\"a\"}}"), "Pin");
|
||||||
|
assert_eq!(request_kind(" { \"Restore\" : {} } "), "Restore");
|
||||||
|
assert_eq!(request_kind("garbage"), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stuck `Refresh` is only actionable if the report says which view it
|
||||||
|
/// was refreshing, so the scope rides along with the name.
|
||||||
|
#[test]
|
||||||
|
fn a_refresh_reports_its_scope() {
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Photos\":{\"full\":true}}}}"),
|
||||||
|
"Refresh(Photos)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":{\"Dir\":{\"path\":\"a\"}}}}"),
|
||||||
|
"Refresh(Dir)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_kind("{\"Refresh\":{\"scope\":\"Trash\"}}"),
|
||||||
|
"Refresh(Trash)"
|
||||||
|
);
|
||||||
|
// A shape this does not recognise still names the request.
|
||||||
|
assert_eq!(request_kind("{\"Refresh\":{}}"), "Refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A guard registers for its lifetime and nothing longer: a handler that
|
||||||
|
/// returns must not leave a permanent "in flight" entry behind.
|
||||||
|
#[test]
|
||||||
|
fn a_guard_registers_only_while_it_lives() {
|
||||||
|
let before = INFLIGHT.lock().len();
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard::new("OpenFile".into());
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before + 1);
|
||||||
|
}
|
||||||
|
assert_eq!(INFLIGHT.lock().len(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whatever else it reports, the resident size has to be a real reading.
|
||||||
|
#[test]
|
||||||
|
fn rss_is_readable() {
|
||||||
|
assert!(rss_bytes() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmRSS` is in kB, and a status without one must read as unknown rather
|
||||||
|
/// than as a daemon using no memory.
|
||||||
|
#[test]
|
||||||
|
fn vm_rss_is_kilobytes_and_absence_is_zero() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_vm_rss("VmPeak:\t 100 kB\nVmRSS:\t 2048 kB\n"),
|
||||||
|
2048 * 1024
|
||||||
|
);
|
||||||
|
assert_eq!(parse_vm_rss("VmPeak:\t 100 kB\n"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+928
-110
File diff suppressed because it is too large.
Load diff
+535
-272
File diff suppressed because it is too large.
Load diff
+2071
-310
File diff suppressed because it is too large.
Load diff
+170
-41
@@ -241,6 +241,9 @@ pub struct MountOptions {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
/// What the background conflict sweep may do. See `docs/BUGS.md` B71.
|
||||||
pub sweep_mode: SweepMode,
|
pub sweep_mode: SweepMode,
|
||||||
|
/// Upload and download caps in bytes per second (`0` = no cap).
|
||||||
|
pub upload_limit: u64,
|
||||||
|
pub download_limit: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
/// Spawn one FUSE session rooted at an arbitrary remote node.
|
||||||
@@ -255,6 +258,14 @@ pub(super) fn spawn_session(
|
|||||||
root: Node,
|
root: Node,
|
||||||
) -> std::io::Result<BackgroundSession> {
|
) -> std::io::Result<BackgroundSession> {
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
|
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||||
|
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||||
|
// `release` currently relies on being serialised against the `open`+`read`
|
||||||
|
// an application issues right after `close(2)` — see the comment on
|
||||||
|
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||||
|
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||||
|
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||||
|
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||||
config.mount_options = vec![
|
config.mount_options = vec![
|
||||||
MountOption::FSName("protondrive".to_string()),
|
MountOption::FSName("protondrive".to_string()),
|
||||||
MountOption::Subtype("protondrive".to_string()),
|
MountOption::Subtype("protondrive".to_string()),
|
||||||
@@ -332,7 +343,12 @@ pub fn mount(
|
|||||||
let MountOptions {
|
let MountOptions {
|
||||||
username,
|
username,
|
||||||
sweep_mode,
|
sweep_mode,
|
||||||
|
upload_limit,
|
||||||
|
download_limit,
|
||||||
} = options;
|
} = options;
|
||||||
|
// Start the uptime clock here rather than at the first `pdfs diagnostics`,
|
||||||
|
// so the age it reports is the daemon's own.
|
||||||
|
super::diagnostics::start_clock();
|
||||||
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
let (root, online) = fetch_or_recall_root(&client, &rt, &db)?;
|
||||||
let scope = root.tree_event_scope_id();
|
let scope = root.tree_event_scope_id();
|
||||||
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
db.mount_upsert_my_files(&mountpoint.to_string_lossy(), &root.uid.to_string(), None)
|
||||||
@@ -346,35 +362,34 @@ pub fn mount(
|
|||||||
// handlers can trigger reconciles.
|
// handlers can trigger reconciles.
|
||||||
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
let (sync_tx, sync_rx) = std::sync::mpsc::channel::<sync::SyncMsg>();
|
||||||
|
|
||||||
|
let paused_until = pause::load_paused_until(&db);
|
||||||
let core = Core {
|
let core = Core {
|
||||||
client: client.clone(),
|
client: client.clone(),
|
||||||
rt: rt.clone(),
|
rt: rt.clone(),
|
||||||
|
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||||
primary_root_uid: root.uid.clone(),
|
primary_root_uid: root.uid.clone(),
|
||||||
primary: true,
|
primary: true,
|
||||||
state: Arc::new(Mutex::new(State {
|
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 2,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: db.clone(),
|
|
||||||
})),
|
|
||||||
cache: Arc::new(cache),
|
cache: Arc::new(cache),
|
||||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||||
|
block_flight: Arc::new(BlockFlight::default()),
|
||||||
|
content_repairs: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
|
prefetch: Arc::new(Prefetch::default()),
|
||||||
|
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||||
db,
|
db,
|
||||||
shared_publication: Arc::new(Mutex::new(())),
|
shared_publication: Arc::new(Mutex::new(())),
|
||||||
shared_generation: Arc::new(AtomicU64::new(0)),
|
shared_generation: Arc::new(AtomicU64::new(0)),
|
||||||
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
shared_refresh_deadlines: Arc::new(Mutex::new(SharedRefreshDeadlines::default())),
|
||||||
online: Arc::new(AtomicBool::new(online)),
|
online: Arc::new(AtomicBool::new(online)),
|
||||||
|
sync_paused_until: Arc::new(AtomicI64::new(paused_until)),
|
||||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
hidden: Arc::new(Mutex::new(HashSet::new())),
|
hidden: Arc::new(Mutex::new(HashSet::new())),
|
||||||
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
drain_wake: Arc::new((Mutex::new(false), Condvar::new())),
|
||||||
|
shutdown: Arc::new(crate::shutdown::Shutdown::default()),
|
||||||
|
upload_times: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
|
||||||
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
timeline_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
albums_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
trash_refreshing: Arc::new(AtomicBool::new(false)),
|
||||||
@@ -384,8 +399,18 @@ pub fn mount(
|
|||||||
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
own_sealed_revs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
thumb_gen_budget: Arc::new(tokio::sync::Semaphore::new(
|
||||||
|
super::photos::THUMB_GEN_CONCURRENCY,
|
||||||
|
)),
|
||||||
|
file_thumb_generation: Arc::new(AtomicU64::new(0)),
|
||||||
|
file_thumb_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_build: Arc::new(Mutex::new(Default::default())),
|
||||||
|
thumbnail_build_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
|
thumbnail_build_cancel: Arc::new(tokio::sync::Notify::new()),
|
||||||
|
thumbnail_misses: Arc::new(Mutex::new(Default::default())),
|
||||||
|
quota: Arc::new(Mutex::new(None)),
|
||||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
size_waiters: Arc::new(Default::default()),
|
||||||
notifier: Arc::new(OnceLock::new()),
|
notifier: Arc::new(OnceLock::new()),
|
||||||
session_live: Arc::new(AtomicBool::new(false)),
|
session_live: Arc::new(AtomicBool::new(false)),
|
||||||
transfers: TransferRegistry::new(),
|
transfers: TransferRegistry::new(),
|
||||||
@@ -399,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.
|
||||||
@@ -407,16 +433,36 @@ pub fn mount(
|
|||||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||||
// see an already-queued write to the same node.
|
// see an already-queued write to the same node.
|
||||||
core.recover_fsynced_writes();
|
core.recover_fsynced_writes();
|
||||||
{
|
// And finally the staged blobs neither of those accounts for: bytes a
|
||||||
|
// failed release left behind with nothing pointing at them.
|
||||||
|
core.reconcile_staging();
|
||||||
|
// Several drain workers over one queue. One thread meant a single large
|
||||||
|
// upload held every queued rename, trash and small write behind it for as
|
||||||
|
// long as it ran; the claim column (`Db::claim_next_due_op`) keeps the
|
||||||
|
// workers off each other's nodes, which is the only ordering the queue
|
||||||
|
// actually needs. Worker 0 additionally runs the queue's idle chores.
|
||||||
|
//
|
||||||
|
// Every long-lived thread started from here on is kept in `workers` and
|
||||||
|
// joined at the end of this function. Leaving them running turned an
|
||||||
|
// in-process remount into a second generation of drain workers, sync engines
|
||||||
|
// and sweeps all operating on a mount that no longer exists (bugs.md B44).
|
||||||
|
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||||
|
for worker in 0..DRAIN_WORKERS {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-drain".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_pending_drain())?;
|
.name(format!("pdfs-drain-{worker}"))
|
||||||
|
.spawn(move || core.run_pending_drain(worker == 0))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the folder-sync engine. It watches every mirror folder, polls the
|
// Start the folder-sync engine. It watches every mirror folder, polls the
|
||||||
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
// remotes, and reconciles on its own thread — never in front of a FUSE call.
|
||||||
sync::spawn(core.clone(), sync_rx);
|
workers.extend(sync::spawn(core.clone(), sync_rx));
|
||||||
|
// A timed pause persisted by an earlier run still has to end on time.
|
||||||
|
if pause::paused_at(paused_until, now_secs()) && paused_until != pause::PAUSED_INDEFINITELY {
|
||||||
|
core.arm_pause_timer(paused_until);
|
||||||
|
}
|
||||||
|
|
||||||
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
// Reconcile leftover `(sync-conflict …)` copies: drop the ones that turned
|
||||||
// out identical to the live file, surface the ones that genuinely diverge.
|
// out identical to the live file, surface the ones that genuinely diverge.
|
||||||
@@ -428,18 +474,22 @@ pub fn mount(
|
|||||||
} else {
|
} else {
|
||||||
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
info!(mode = ?sweep_mode, "conflict sweep enabled");
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-conflict-sweep".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_conflict_sweep_loop())?;
|
.name("pdfs-conflict-sweep".into())
|
||||||
|
.spawn(move || core.run_conflict_sweep_loop())?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mounted from the cache: watch for the network coming back so the mount can
|
// Mounted from the cache: watch for the network coming back so the mount can
|
||||||
// stop being read-only-ish without the user restarting the daemon.
|
// stop being read-only-ish without the user restarting the daemon.
|
||||||
if !online {
|
if !online {
|
||||||
let core = core.clone();
|
let core = core.clone();
|
||||||
std::thread::Builder::new()
|
workers.push(
|
||||||
.name("pdfs-online-probe".into())
|
std::thread::Builder::new()
|
||||||
.spawn(move || core.run_online_probe())?;
|
.name("pdfs-online-probe".into())
|
||||||
|
.spawn(move || core.run_online_probe())?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
// Keep the launcher prompt's "This computer" index fresh. Its own thread:
|
||||||
@@ -449,9 +499,12 @@ pub fn mount(
|
|||||||
let indexing = core.indexing.clone();
|
let indexing = core.indexing.clone();
|
||||||
let transfers = core.transfers.clone();
|
let transfers = core.transfers.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
std::thread::Builder::new()
|
let shutdown = core.shutdown.clone();
|
||||||
.name("pdfs-localindex".into())
|
workers.push(
|
||||||
.spawn(move || run_local_index(db, indexing, transfers, mountpoint))?;
|
std::thread::Builder::new()
|
||||||
|
.name("pdfs-localindex".into())
|
||||||
|
.spawn(move || run_local_index(db, indexing, transfers, mountpoint, shutdown))?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the control socket before the FUSE session takes over the thread. A
|
// Bind the control socket before the FUSE session takes over the thread. A
|
||||||
@@ -488,25 +541,51 @@ pub fn mount(
|
|||||||
let control_core = core.clone();
|
let control_core = core.clone();
|
||||||
let username = username.clone();
|
let username = username.clone();
|
||||||
let mountpoint = mountpoint.to_path_buf();
|
let mountpoint = mountpoint.to_path_buf();
|
||||||
if let Err(error) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-control".into())
|
.name("pdfs-control".into())
|
||||||
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
.spawn(move || run_control_socket(control_core, username, mountpoint, listener))
|
||||||
{
|
{
|
||||||
let secondaries = core.mounts.lock().close_and_drain();
|
Ok(handle) => workers.push(handle),
|
||||||
for (id, mount) in secondaries {
|
Err(error) => {
|
||||||
if let Err(unmount_error) = mount.teardown() {
|
// A failure here abandons the mount, so the background threads
|
||||||
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
// started above have to be told to stop with it.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
|
let secondaries = core.mounts.lock().close_and_drain();
|
||||||
|
for (id, mount) in secondaries {
|
||||||
|
if let Err(unmount_error) = mount.teardown() {
|
||||||
|
warn!(id, error = %unmount_error, "secondary teardown after control startup failure failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
||||||
|
umount_session_unblocked(bg, main_conn)
|
||||||
|
}) {
|
||||||
|
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
return Err(error);
|
||||||
}
|
}
|
||||||
if let Err(unmount_error) = teardown_session(&core.session_live, || {
|
|
||||||
umount_session_unblocked(bg, main_conn)
|
|
||||||
}) {
|
|
||||||
warn!(error = %unmount_error, "unmount after control startup failure failed");
|
|
||||||
}
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
|
||||||
return Err(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The supervisor watches the pool and answers the systemd watchdog. Started
|
||||||
|
// after the listener, because its liveness probe is a round trip over that
|
||||||
|
// socket.
|
||||||
|
{
|
||||||
|
let supervisor_core = core.clone();
|
||||||
|
let socket = control_socket.to_path_buf();
|
||||||
|
match std::thread::Builder::new()
|
||||||
|
.name("pdfs-supervisor".into())
|
||||||
|
.spawn(move || supervisor::run(supervisor_core, socket))
|
||||||
|
{
|
||||||
|
Ok(handle) => workers.push(handle),
|
||||||
|
// Not fatal: the mount works without a supervisor, it just goes back
|
||||||
|
// to being undiagnosable if it wedges.
|
||||||
|
Err(error) => warn!(error = %error, "could not start the daemon supervisor"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The mount is live and the control socket answers: this is the moment the
|
||||||
|
// unit is actually usable, which is what `Type=notify` reports.
|
||||||
|
systemd::ready();
|
||||||
|
|
||||||
// Re-establish on-demand mounts only after the primary session is live.
|
// Re-establish on-demand mounts only after the primary session is live.
|
||||||
// Keep the worker so shutdown can close publication, wait for any in-flight
|
// Keep the worker so shutdown can close publication, wait for any in-flight
|
||||||
// fetch to observe that closure, and then drain a stable registry.
|
// fetch to observe that closure, and then drain a stable registry.
|
||||||
@@ -524,6 +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();
|
||||||
@@ -613,10 +695,57 @@ pub fn mount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = std::fs::remove_file(control_socket);
|
// Last, because the workers may legitimately be finishing an upload, and
|
||||||
|
// nothing they can still do is harmful once the mounts are down.
|
||||||
|
stop_workers(&core, control_socket, workers);
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long teardown waits for the FUSE worker pool before proceeding without
|
||||||
|
/// it. Long enough for an in-flight block to finish, short enough that
|
||||||
|
/// `systemctl --user stop` still returns inside `TimeoutStopSec`.
|
||||||
|
const WORKER_JOIN_DEADLINE: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Signal every background loop, wake the ones that are blocked, and join them.
|
||||||
|
///
|
||||||
|
/// The mount used to return with its drain workers, sync engine, sweep, online
|
||||||
|
/// probe, indexer and control listener all still running on `Core` clones. The
|
||||||
|
/// process usually exited straight afterwards and hid it; an in-process remount
|
||||||
|
/// did not, and started a second full set on top of the first (bugs.md B44).
|
||||||
|
///
|
||||||
|
/// Two of the waits need waking rather than merely flagging: the drain workers
|
||||||
|
/// block on their own condvar (`wake_drain`), and the control listener blocks
|
||||||
|
/// inside `accept`, which only returns when a connection arrives — so it gets
|
||||||
|
/// one, from us, after the flag is already set. A join that takes a while is
|
||||||
|
/// expected and correct: a worker part-way through an upload finishes it rather
|
||||||
|
/// than abandoning the user's bytes mid-flight.
|
||||||
|
fn stop_workers(core: &Core, control_socket: &Path, workers: Vec<std::thread::JoinHandle<()>>) {
|
||||||
|
// Say so before the joins: a teardown that takes a while must not be read as
|
||||||
|
// a missed watchdog ping and turned into a kill.
|
||||||
|
systemd::stopping();
|
||||||
|
core.shutdown.stop();
|
||||||
|
core.wake_drain();
|
||||||
|
let _ = core.sync_tx.send(sync::SyncMsg::Stop);
|
||||||
|
// Connect while the socket still exists — that is the whole point of the
|
||||||
|
// poke — then unlink it so nothing else can arrive behind us. The listener
|
||||||
|
// re-checks the flag after every accept, so even a connection that races the
|
||||||
|
// unlink cannot put it back to sleep.
|
||||||
|
let _ = std::os::unix::net::UnixStream::connect(control_socket);
|
||||||
|
let _ = std::fs::remove_file(control_socket);
|
||||||
|
for worker in workers {
|
||||||
|
if worker.join().is_err() {
|
||||||
|
warn!("a background worker panicked before shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The FUSE worker pool last, and *before* the caller drops the tokio
|
||||||
|
// runtime: a worker still inside a job when the runtime goes away panics on
|
||||||
|
// its next timer with "A Tokio 1.x context was found, but it is being
|
||||||
|
// shutdown" (seen 2026-09-18 09:58:35). Bounded, so a worker wedged on the
|
||||||
|
// network cannot hold the stop the user asked for.
|
||||||
|
core.workers.stop_and_join(WORKER_JOIN_DEADLINE);
|
||||||
|
debug!("background workers stopped");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//! Pausing sync: the user's "stop uploading for a while".
|
||||||
|
//!
|
||||||
|
//! A pause holds back the two things that push local state to the remote — the
|
||||||
|
//! drain worker's queued uploads and changes, and the mirror-folder reconcile.
|
||||||
|
//! Nothing else stops: reads through the mount keep hydrating on demand (a read
|
||||||
|
//! is not a sync), writes keep being accepted and staged, and the watcher and
|
||||||
|
//! poll keep noticing changes. Resuming lets all of it catch up at once.
|
||||||
|
//!
|
||||||
|
//! The pause is persisted in the database, so a restart does not quietly undo
|
||||||
|
//! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for
|
||||||
|
//! that, and [`Core::sync_paused`] compares against the clock anyway, so a
|
||||||
|
//! deadline that passed while the daemon was down is already over on mount.
|
||||||
|
//!
|
||||||
|
//! A single mirror folder can be paused on its own as well: its reconcile is
|
||||||
|
//! skipped, and with it any mode switch queued behind that reconcile, until the
|
||||||
|
//! folder is resumed. On-demand folders cannot be paused alone — their writes
|
||||||
|
//! go through the shared queue, which only the global pause holds back.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The `state` key holding the resume time, in Unix seconds.
|
||||||
|
const PAUSED_UNTIL_KEY: &str = "sync_paused_until";
|
||||||
|
|
||||||
|
/// The `state` key prefix marking one synced folder paused; its id follows.
|
||||||
|
const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:";
|
||||||
|
|
||||||
|
fn folder_paused_key(id: i64) -> String {
|
||||||
|
format!("{FOLDER_PAUSED_PREFIX}{id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether synced folder `id` is paused on its own. A row that cannot be read
|
||||||
|
/// counts as running, like the global pause.
|
||||||
|
fn load_folder_paused(db: &Db, id: i64) -> bool {
|
||||||
|
match db.state_i64(&folder_paused_key(id)) {
|
||||||
|
Ok(value) => value.is_some_and(|v| v != 0),
|
||||||
|
Err(error) => {
|
||||||
|
warn!(id, %error, "reading a folder pause failed; treating it as running");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist synced folder `id`'s own pause, or clear it.
|
||||||
|
fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> {
|
||||||
|
match paused {
|
||||||
|
true => db.set_state_i64(&folder_paused_key(id), 1),
|
||||||
|
false => db.clear_state(&folder_paused_key(id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pause with no end time: it lasts until the user resumes.
|
||||||
|
pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX;
|
||||||
|
|
||||||
|
/// The persisted pause, or `0` when there is none (or it cannot be read — a
|
||||||
|
/// broken state row must not keep sync paused forever).
|
||||||
|
pub(crate) fn load_paused_until(db: &Db) -> i64 {
|
||||||
|
match db.state_i64(PAUSED_UNTIL_KEY) {
|
||||||
|
Ok(Some(until)) => until,
|
||||||
|
Ok(None) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(%error, "reading the sync pause failed; treating sync as running");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a pause lasting until `until` is still in force at `now` (both Unix
|
||||||
|
/// seconds). `0` is "not paused".
|
||||||
|
pub(crate) fn paused_at(until: i64, now: i64) -> bool {
|
||||||
|
until != 0 && now < until
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// True while the user has sync paused.
|
||||||
|
pub(crate) fn sync_paused(&self) -> bool {
|
||||||
|
paused_at(self.sync_paused_until.load(Ordering::Relaxed), now_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pause's end, for status reporting: `None` when not paused,
|
||||||
|
/// `Some(None)` when paused until resumed, `Some(Some(t))` when it ends at
|
||||||
|
/// Unix second `t`.
|
||||||
|
pub(crate) fn sync_pause(&self) -> Option<Option<i64>> {
|
||||||
|
let until = self.sync_paused_until.load(Ordering::Relaxed);
|
||||||
|
match paused_at(until, now_secs()) {
|
||||||
|
false => None,
|
||||||
|
true if until == PAUSED_INDEFINITELY => Some(None),
|
||||||
|
true => Some(Some(until)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause sync until Unix second `until` (`None`: until resumed), or resume
|
||||||
|
/// it when `paused` is false.
|
||||||
|
pub(crate) fn set_sync_paused(
|
||||||
|
&self,
|
||||||
|
paused: bool,
|
||||||
|
until: Option<i64>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
let value = match (paused, until) {
|
||||||
|
(false, _) => 0,
|
||||||
|
(true, None) => PAUSED_INDEFINITELY,
|
||||||
|
(true, Some(until)) if until <= now_secs() => {
|
||||||
|
return Err(CoreError::invalid("the pause would already be over"));
|
||||||
|
}
|
||||||
|
(true, Some(until)) => until,
|
||||||
|
};
|
||||||
|
let persisted = match value {
|
||||||
|
0 => self.db.clear_state(PAUSED_UNTIL_KEY),
|
||||||
|
value => self.db.set_state_i64(PAUSED_UNTIL_KEY, value),
|
||||||
|
};
|
||||||
|
if let Err(error) = persisted {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the sync pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.sync_paused_until.store(value, Ordering::Relaxed);
|
||||||
|
match value {
|
||||||
|
0 => {
|
||||||
|
info!("sync resumed");
|
||||||
|
self.resume_sync_work();
|
||||||
|
}
|
||||||
|
PAUSED_INDEFINITELY => info!("sync paused until resumed"),
|
||||||
|
until => {
|
||||||
|
info!(until, "sync paused");
|
||||||
|
self.arm_pause_timer(until);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the user has this one synced folder paused. A row that cannot
|
||||||
|
/// be read counts as running, like the global pause.
|
||||||
|
pub(crate) fn sync_folder_paused(&self, id: i64) -> bool {
|
||||||
|
load_folder_paused(&self.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pause or resume one mirror folder's reconcile. Resuming reconciles it
|
||||||
|
/// straight away to catch up on what the watcher saw meanwhile.
|
||||||
|
pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> {
|
||||||
|
let folder = self
|
||||||
|
.db
|
||||||
|
.sync_folder_get(id)
|
||||||
|
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||||
|
.ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?;
|
||||||
|
if paused && folder.mode != "mirror" {
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"only a mirrored folder can be paused on its own; an on-demand folder uploads \
|
||||||
|
through the shared queue, so pause all sync instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, paused) {
|
||||||
|
return Err(CoreError::internal(format!(
|
||||||
|
"saving the folder pause: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync paused");
|
||||||
|
} else {
|
||||||
|
info!(id, path = %folder.local_path, "folder sync resumed");
|
||||||
|
self.sync_now(Some(id));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a removed folder's pause, so a later folder reusing its id does
|
||||||
|
/// not start out paused.
|
||||||
|
pub(crate) fn clear_sync_folder_paused(&self, id: i64) {
|
||||||
|
if let Err(error) = store_folder_paused(&self.db, id, false) {
|
||||||
|
warn!(id, %error, "clearing a removed folder's pause failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wake everything a pause held back: the drain worker, and a full
|
||||||
|
/// reconcile to pick up whatever the watcher saw meanwhile.
|
||||||
|
fn resume_sync_work(&self) {
|
||||||
|
self.wake_drain();
|
||||||
|
self.sync_now(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End a timed pause when its time comes. A later pause or resume replaces
|
||||||
|
/// the stored deadline, which the timer checks before acting, so a stale
|
||||||
|
/// timer does nothing.
|
||||||
|
pub(crate) fn arm_pause_timer(&self, until: i64) {
|
||||||
|
let core = self.clone();
|
||||||
|
let wait = Duration::from_secs(until.saturating_sub(now_secs()).max(0) as u64);
|
||||||
|
let spawned = std::thread::Builder::new()
|
||||||
|
.name("pdfs-pause".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if !core.shutdown.sleep(wait) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if core
|
||||||
|
.sync_paused_until
|
||||||
|
.compare_exchange(until, 0, Ordering::Relaxed, Ordering::Relaxed)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
if let Err(error) = core.db.clear_state(PAUSED_UNTIL_KEY) {
|
||||||
|
warn!(%error, "clearing an expired sync pause failed");
|
||||||
|
}
|
||||||
|
info!("sync pause ended");
|
||||||
|
core.resume_sync_work();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = spawned {
|
||||||
|
// The drain and the reconcile still read the clock, so the pause
|
||||||
|
// still ends — just at the next idle poll instead of on time.
|
||||||
|
warn!(%error, "starting the sync pause timer failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pause_is_in_force_until_its_deadline() {
|
||||||
|
assert!(!paused_at(0, 100));
|
||||||
|
assert!(paused_at(200, 100));
|
||||||
|
assert!(!paused_at(200, 200));
|
||||||
|
assert!(!paused_at(200, 300));
|
||||||
|
assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folder_pause_is_kept_per_folder_until_cleared() {
|
||||||
|
let db = Db::open(std::path::Path::new(":memory:")).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
store_folder_paused(&db, 3, true).unwrap();
|
||||||
|
assert!(load_folder_paused(&db, 3));
|
||||||
|
assert!(!load_folder_paused(&db, 4));
|
||||||
|
assert!(!load_folder_paused(&db, 33));
|
||||||
|
store_folder_paused(&db, 3, false).unwrap();
|
||||||
|
assert!(!load_folder_paused(&db, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+797
-107
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,332 @@
|
|||||||
|
//! Repairing photos whose capture time is when they were *imported* rather than
|
||||||
|
//! when they were taken.
|
||||||
|
//!
|
||||||
|
//! A Google Photos Takeout whose metadata sidecars did not match — a localized
|
||||||
|
//! `.supplemental-metadata` spelling, say — imports with no capture time at all,
|
||||||
|
//! and the SDK then seals "now" into every revision. A decade of photos lands on
|
||||||
|
//! one day.
|
||||||
|
//!
|
||||||
|
//! Proton has no API for editing a sealed capture time, so the repair is a
|
||||||
|
//! rewrite: download the photo, upload it again with the capture time its file
|
||||||
|
//! name implies, put the new copy back into the albums and favorites the old
|
||||||
|
//! one was in, then **trash** (not delete) the original. Trash rather than
|
||||||
|
//! delete so a bad run is recoverable from Proton's own trash for 30 days.
|
||||||
|
//!
|
||||||
|
//! A photo is a candidate only when its name carries a date that disagrees with
|
||||||
|
//! its stored capture time by more than [`DRIFT`]. That is deliberately narrow:
|
||||||
|
//! a photo whose name says nothing about its date is left alone, because there
|
||||||
|
//! is nothing better to set it to.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{RedateSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{capture_time_from_name, media_type_of};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::parse_uid as parse_node_uid;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// How far a stored capture time may sit from the one the file name implies
|
||||||
|
/// before the photo counts as mis-dated. A day, because a name usually carries
|
||||||
|
/// only the date and the stored time carries a clock — and because a timezone
|
||||||
|
/// can legitimately move a photo by hours.
|
||||||
|
const DRIFT: i64 = 86_400;
|
||||||
|
|
||||||
|
/// How many examples a report carries.
|
||||||
|
const SAMPLES: usize = 10;
|
||||||
|
|
||||||
|
/// The one re-date this daemon may be running, and how the last one ended.
|
||||||
|
/// Process-global for the same reasons as the Takeout import's state: one run
|
||||||
|
/// per daemon however many `Core` clones exist, and a front-end asking after it
|
||||||
|
/// finished must still find the report.
|
||||||
|
static REDATE: std::sync::LazyLock<parking_lot::Mutex<RedateState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(RedateState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RedateState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<RedateSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a re-date is running, and the last one's report.
|
||||||
|
pub(crate) fn redate_status() -> (bool, Option<RedateSummary>) {
|
||||||
|
let state = REDATE.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running re-date to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_redate() -> bool {
|
||||||
|
match &REDATE.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to repair.
|
||||||
|
struct Candidate {
|
||||||
|
uid: NodeUid,
|
||||||
|
name: String,
|
||||||
|
/// The capture time it carries now.
|
||||||
|
stored: i64,
|
||||||
|
/// The capture time its name implies.
|
||||||
|
wanted: i64,
|
||||||
|
favorite: bool,
|
||||||
|
/// Album node uids the original is filed under.
|
||||||
|
albums: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Re-date mis-dated photos. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary. A photo is only ever
|
||||||
|
/// trashed after its replacement has uploaded, so a cancel — or a crash —
|
||||||
|
/// can leave a duplicate, never a hole.
|
||||||
|
pub(crate) fn redate_photos(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<RedateSummary> {
|
||||||
|
let job = self.transfers.begin_job("Fixing photo dates");
|
||||||
|
job.detail("looking for mis-dated photos");
|
||||||
|
|
||||||
|
let stored = self
|
||||||
|
.db
|
||||||
|
.photos_all()
|
||||||
|
.map_err(|e| CoreError::internal(format!("read timeline: {e}")))?;
|
||||||
|
let mut summary = RedateSummary {
|
||||||
|
examined: stored.len(),
|
||||||
|
dry_run,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for photo in stored {
|
||||||
|
if let Some((from, to)) = range
|
||||||
|
&& !(from..to).contains(&photo.capture_time)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(name) = photo.name.filter(|n| !n.is_empty()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(wanted) = capture_time_from_name(&name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if (wanted - photo.capture_time).abs() <= DRIFT {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(uid) = parse_node_uid(&photo.uid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
candidates.push(Candidate {
|
||||||
|
uid,
|
||||||
|
name,
|
||||||
|
stored: photo.capture_time,
|
||||||
|
wanted,
|
||||||
|
favorite: photo.favorite,
|
||||||
|
albums: self.db.albums_of_photo(&photo.uid).unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
candidates.truncate(limit);
|
||||||
|
}
|
||||||
|
summary.candidates = candidates.len();
|
||||||
|
// Sampled after the limit, so the examples are of photos the run would
|
||||||
|
// actually touch.
|
||||||
|
summary.samples = candidates
|
||||||
|
.iter()
|
||||||
|
.take(SAMPLES)
|
||||||
|
.map(|c| (c.name.clone(), c.stored, c.wanted))
|
||||||
|
.collect();
|
||||||
|
job.set_total(candidates.len() as u64);
|
||||||
|
info!(
|
||||||
|
examined = summary.examined,
|
||||||
|
candidates = summary.candidates,
|
||||||
|
dry_run,
|
||||||
|
"photo re-date scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
if dry_run || candidates.is_empty() {
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in candidates {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
job.detail(candidate.name.clone());
|
||||||
|
if let Err(e) = self.redate_one(&candidate, &mut summary) {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-date photo");
|
||||||
|
}
|
||||||
|
job.step();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite one photo at the capture time its name implies.
|
||||||
|
///
|
||||||
|
/// Order is load-bearing: upload first, file the new copy, and only then
|
||||||
|
/// trash the original. Every earlier step failing leaves the account exactly
|
||||||
|
/// as it was.
|
||||||
|
fn redate_one(&self, candidate: &Candidate, summary: &mut RedateSummary) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let path = self.open_photo(&candidate.uid)?;
|
||||||
|
let size = std::fs::metadata(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("stat {}: {e}", path.display())))?
|
||||||
|
.len();
|
||||||
|
let media_type = media_type_of(&candidate.name).unwrap_or("application/octet-stream");
|
||||||
|
|
||||||
|
let guard =
|
||||||
|
self.transfers
|
||||||
|
.begin(candidate.name.clone(), "", TransferDirection::Upload, size);
|
||||||
|
let file = std::fs::File::open(&path)
|
||||||
|
.map_err(|e| CoreError::internal(format!("open {}: {e}", path.display())))?;
|
||||||
|
let reader = CountingReader::new(std::io::BufReader::new(file), &guard);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: Some(candidate.wanted),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&candidate.name,
|
||||||
|
media_type,
|
||||||
|
reader,
|
||||||
|
size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
drop(guard);
|
||||||
|
let new_uid = uploaded.map_err(|e| CoreError::from_api(&e, "re-upload photo"))?;
|
||||||
|
|
||||||
|
// Favorite and albums before the trash: a failure here leaves both
|
||||||
|
// copies on the account, which is recoverable by hand. Losing the
|
||||||
|
// original first would not be.
|
||||||
|
if candidate.favorite {
|
||||||
|
let update = PhotoTagsUpdate {
|
||||||
|
node_uid: new_uid.clone(),
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
};
|
||||||
|
match self.rt.block_on(photos.update_photos(&[update])) {
|
||||||
|
// As in `set_photo_favorite`: claim our own echo, so the tag we
|
||||||
|
// just set does not come back as a remote change.
|
||||||
|
Ok(_) => self.note_self_change(&new_uid),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-favorite re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for album in &candidate.albums {
|
||||||
|
let Some(album_uid) = parse_node_uid(album) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, std::slice::from_ref(&new_uid)))
|
||||||
|
{
|
||||||
|
Ok(_) => summary.album_links += 1,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(name = candidate.name, error = %e, "cannot re-file re-dated photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let outcomes = self
|
||||||
|
.rt
|
||||||
|
.block_on(
|
||||||
|
self.client
|
||||||
|
.trash_nodes(std::slice::from_ref(&candidate.uid)),
|
||||||
|
)
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "trash the mis-dated original"))?;
|
||||||
|
if let Some((_, Err(e))) = outcomes.into_iter().next() {
|
||||||
|
return Err(CoreError::from_api(&e, "trash the mis-dated original"));
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.redated += 1;
|
||||||
|
summary.bytes += size;
|
||||||
|
info!(
|
||||||
|
name = candidate.name,
|
||||||
|
from = candidate.stored,
|
||||||
|
to = candidate.wanted,
|
||||||
|
"photo re-dated"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a re-date on its own thread. Errors when one is already running.
|
||||||
|
pub(crate) fn spawn_redate(
|
||||||
|
&self,
|
||||||
|
dry_run: bool,
|
||||||
|
limit: Option<usize>,
|
||||||
|
range: Option<(i64, i64)>,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"a photo re-date is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.redate_photos(dry_run, limit, range, cancel);
|
||||||
|
let mut state = REDATE.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
match &outcome {
|
||||||
|
Ok(summary) => info!(
|
||||||
|
candidates = summary.candidates,
|
||||||
|
redated = summary.redated,
|
||||||
|
failed = summary.failed,
|
||||||
|
"photo re-date finished"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(error = %e, "photo re-date failed"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_uid_without_both_halves_is_not_a_node() {
|
||||||
|
assert!(parse_node_uid("vol~link").is_some());
|
||||||
|
assert!(parse_node_uid("vol~").is_none());
|
||||||
|
assert!(parse_node_uid("~link").is_none());
|
||||||
|
assert!(parse_node_uid("nothing").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_disagreement_larger_than_a_day_counts() {
|
||||||
|
// The name says 2023-02-19; a stored time inside that day is fine.
|
||||||
|
let wanted = capture_time_from_name("IMG-20230219-WA0001.jpg").unwrap();
|
||||||
|
assert!((wanted - (wanted + 3_600)).abs() <= DRIFT);
|
||||||
|
// An import three years later is not.
|
||||||
|
assert!((wanted - (wanted + 3 * 365 * 86_400)).abs() > DRIFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ use pdfs_core::control::{
|
|||||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||||
};
|
};
|
||||||
use pdfs_core::{CoreError, CoreResult};
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::QUOTA_TTL;
|
||||||
|
use proton_drive_rs::proton_sdk::crypto::VerificationStatus;
|
||||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
use proton_drive_rs::{MemberRole, NodeKind};
|
use proton_drive_rs::{MemberRole, NodeKind};
|
||||||
|
|
||||||
@@ -44,6 +48,18 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
|||||||
.map(role_to_str)
|
.map(role_to_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let invitation = n.membership.as_ref();
|
||||||
|
let shared_by = invitation
|
||||||
|
.and_then(|m| m.inviter_email.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let shared_at = invitation.and_then(|m| m.invite_time).unwrap_or(0);
|
||||||
|
// Only a signature that is present and wrong is a warning. An invitation
|
||||||
|
// that could not be checked — unsigned, or no keys for the claimed inviter —
|
||||||
|
// is not evidence of forgery, and flagging every such share would teach the
|
||||||
|
// user to ignore the flag.
|
||||||
|
let shared_by_unverified = invitation
|
||||||
|
.and_then(|m| m.inviter_verification)
|
||||||
|
.is_some_and(|status| status == VerificationStatus::Failed);
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: n.name,
|
name: n.name,
|
||||||
is_dir,
|
is_dir,
|
||||||
@@ -54,6 +70,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,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.
|
||||||
@@ -533,9 +612,33 @@ impl Core {
|
|||||||
.rt
|
.rt
|
||||||
.block_on(self.client.quota())
|
.block_on(self.client.quota())
|
||||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||||
|
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||||
Ok((q.max_space, q.used_space))
|
Ok((q.max_space, q.used_space))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||||
|
/// refetched after that, and `None` when there is no usable answer at all —
|
||||||
|
/// offline with nothing cached.
|
||||||
|
///
|
||||||
|
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||||
|
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||||
|
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||||
|
/// the last known figures keep being served rather than degrading to zero.
|
||||||
|
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||||
|
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||||
|
&& at.elapsed() < QUOTA_TTL
|
||||||
|
{
|
||||||
|
return Some((max_space, used_space));
|
||||||
|
}
|
||||||
|
match self.account_quota() {
|
||||||
|
Ok(q) => Some(q),
|
||||||
|
Err(error) => {
|
||||||
|
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||||
|
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- shared by me -----------------------------------------------------
|
// ---- shared by me -----------------------------------------------------
|
||||||
|
|
||||||
/// List the nodes I have shared with others, each with a summary of its share
|
/// List the nodes I have shared with others, each with a summary of its share
|
||||||
@@ -582,6 +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,
|
||||||
@@ -613,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.
|
||||||
@@ -627,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
//! One shutdown signal shared by every long-lived worker in the daemon.
|
||||||
|
//!
|
||||||
|
//! The mount used to end by unmounting and returning, leaving the drain
|
||||||
|
//! workers, the sync engine, the conflict sweep, the online probe and the local
|
||||||
|
//! indexer running on `Core` clones. In a process that exits immediately that is
|
||||||
|
//! invisible; in an in-process remount — which is how the daemon recovers from a
|
||||||
|
//! mount going away underneath it — it leaks a whole generation of threads that
|
||||||
|
//! keep writing to the database and calling the API on behalf of a mount that no
|
||||||
|
//! longer exists (bugs.md B44).
|
||||||
|
//!
|
||||||
|
//! The primitive is deliberately small: a flag plus a condvar. Every loop that
|
||||||
|
//! would otherwise call `thread::sleep` calls [`Shutdown::sleep`] instead, which
|
||||||
|
//! returns early — and reports `false` — the moment [`Shutdown::stop`] is
|
||||||
|
//! called, so teardown does not have to wait out a five-minute sweep interval to
|
||||||
|
//! join a thread.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Condvar, Mutex};
|
||||||
|
|
||||||
|
/// The daemon's stop signal. Set once, never cleared: a `Core` that has been
|
||||||
|
/// told to stop belongs to a mount that is going away.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Shutdown {
|
||||||
|
stopping: Mutex<bool>,
|
||||||
|
changed: Condvar,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shutdown {
|
||||||
|
/// Whether teardown has begun. Loops check this where they would otherwise
|
||||||
|
/// start another unit of work.
|
||||||
|
pub(crate) fn is_stopping(&self) -> bool {
|
||||||
|
*self.stopping.lock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin teardown and wake everything waiting in [`sleep`](Self::sleep).
|
||||||
|
/// Idempotent, because more than one path can reach teardown (a signal, the
|
||||||
|
/// kernel mount ending, a failed startup).
|
||||||
|
pub(crate) fn stop(&self) {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
*stopping = true;
|
||||||
|
self.changed.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait up to `duration`, or until teardown starts.
|
||||||
|
///
|
||||||
|
/// Returns `false` if the daemon is stopping — including when it was already
|
||||||
|
/// stopping on entry, so `while shutdown.sleep(interval)` is a complete and
|
||||||
|
/// correct loop condition and no separate check is needed at the top.
|
||||||
|
pub(crate) fn sleep(&self, duration: Duration) -> bool {
|
||||||
|
let mut stopping = self.stopping.lock();
|
||||||
|
if !*stopping {
|
||||||
|
self.changed.wait_for(&mut stopping, duration);
|
||||||
|
}
|
||||||
|
!*stopping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_returns_early_once_stopped() {
|
||||||
|
let shutdown = Arc::new(Shutdown::default());
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
|
||||||
|
let waker = shutdown.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
waker.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
// An hour's worth of interval, cut short by the stop.
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(5),
|
||||||
|
"stop must interrupt the wait, not wait it out"
|
||||||
|
);
|
||||||
|
assert!(shutdown.is_stopping());
|
||||||
|
// Already stopping: the next wait does not sleep at all.
|
||||||
|
let started = Instant::now();
|
||||||
|
assert!(!shutdown.sleep(Duration::from_secs(3600)));
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sleeping_runs_to_the_timeout_while_running() {
|
||||||
|
let shutdown = Shutdown::default();
|
||||||
|
assert!(shutdown.sleep(Duration::from_millis(10)));
|
||||||
|
assert!(!shutdown.is_stopping());
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
-85
@@ -141,6 +141,11 @@ pub(crate) struct PendingRevision {
|
|||||||
pub(crate) meta: StagedWrite,
|
pub(crate) meta: StagedWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One directory's entries as `readdir` reports them: `(ino, is_dir, name)` per
|
||||||
|
/// entry, in reply order. The kernel's file-type enum is mapped in at reply
|
||||||
|
/// time so this module stays free of `fuser`.
|
||||||
|
pub(crate) type DirListing = Vec<(u64, bool, String)>;
|
||||||
|
|
||||||
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
/// Mutable inode bookkeeping, guarded by a mutex because fuser drives the
|
||||||
/// `Filesystem` trait through `&self`.
|
/// `Filesystem` trait through `&self`.
|
||||||
pub(crate) struct State {
|
pub(crate) struct State {
|
||||||
@@ -158,6 +163,16 @@ pub(crate) struct State {
|
|||||||
/// have no entry here.
|
/// have no entry here.
|
||||||
pub(crate) handles: HashMap<u64, u64>,
|
pub(crate) handles: HashMap<u64, u64>,
|
||||||
pub(crate) next_fh: u64,
|
pub(crate) next_fh: u64,
|
||||||
|
/// One frozen listing per open directory handle, taken at `opendir`.
|
||||||
|
///
|
||||||
|
/// `readdir` resumes from a byte offset into the listing it is walking, so
|
||||||
|
/// that listing has to stay the same across the calls of one `getdents`
|
||||||
|
/// sequence. Rebuilding it live meant a create or a trash landing between
|
||||||
|
/// two pages shifted every later entry, and the caller silently skipped or
|
||||||
|
/// repeated whatever crossed the page boundary. Each entry is
|
||||||
|
/// `(ino, is_dir, name)` — the kernel type is mapped at reply time so this
|
||||||
|
/// module stays free of `fuser` (bugs.md B43).
|
||||||
|
pub(crate) dir_snapshots: HashMap<u64, Arc<DirListing>>,
|
||||||
/// Resident inode attributes whose effective access changed during an
|
/// Resident inode attributes whose effective access changed during an
|
||||||
/// intern/root refresh. Core drains this set and notifies the matching
|
/// intern/root refresh. Core drains this set and notifies the matching
|
||||||
/// kernel only after releasing the State lock.
|
/// kernel only after releasing the State lock.
|
||||||
@@ -166,13 +181,164 @@ pub(crate) struct State {
|
|||||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||||
/// this map instead of issuing one SQLite query per interned inode.
|
/// this map instead of issuing one SQLite query per interned inode.
|
||||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||||
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
/// Unified SQLite metadata cache. Read from here (`has_children`) but never
|
||||||
/// it inside the `State` lock so the DB stays the authoritative copy across
|
/// written: mutations go to [`State::outbox`] and are applied after the lock
|
||||||
/// restarts (see plan.md P1).
|
/// is released. See [`StateGuard`].
|
||||||
pub(crate) db: Arc<Db>,
|
pub(crate) db: Arc<Db>,
|
||||||
|
/// Write-throughs this mutation owes SQLite, applied by [`StateGuard`] once
|
||||||
|
/// the inode lock is released.
|
||||||
|
///
|
||||||
|
/// The DB stays the authoritative copy across restarts, so every map
|
||||||
|
/// mutation still writes through — it just no longer does so with the
|
||||||
|
/// whole-mount lock held. A single listing's write-through is one
|
||||||
|
/// transaction over up to a few thousand rows, and every other FUSE callback
|
||||||
|
/// used to wait behind it for no reason: the maps were already updated
|
||||||
|
/// before the first row was written.
|
||||||
|
outbox: Vec<DbWrite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A write-through queued by a `State` mutation, applied after the lock drops.
|
||||||
|
///
|
||||||
|
/// Deliberately a small closed set rather than a boxed closure: these run
|
||||||
|
/// outside the lock, and what they can touch should be readable here.
|
||||||
|
pub(crate) enum DbWrite {
|
||||||
|
/// One node or a whole listing. Both go through `upsert_nodes`, which is a
|
||||||
|
/// single transaction either way, so there is nothing to gain by splitting
|
||||||
|
/// them — and a `Node` is large enough that a variant holding one inline
|
||||||
|
/// would set the size of every other.
|
||||||
|
Upsert(Vec<Node>),
|
||||||
|
Delete(NodeUid),
|
||||||
|
SetListed(NodeUid, bool),
|
||||||
|
SetShareAccess(NodeUid, Access),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbWrite {
|
||||||
|
fn apply(self, db: &Db) {
|
||||||
|
let outcome = match &self {
|
||||||
|
Self::Upsert(nodes) => db.upsert_nodes(nodes),
|
||||||
|
Self::Delete(uid) => db.delete_node(uid),
|
||||||
|
Self::SetListed(uid, listed) => db.set_listed(uid, *listed),
|
||||||
|
Self::SetShareAccess(uid, access) => db.set_share_access(uid, *access),
|
||||||
|
};
|
||||||
|
if let Err(error) = outcome {
|
||||||
|
let what = match &self {
|
||||||
|
Self::Upsert(_) => "upsert_nodes",
|
||||||
|
Self::Delete(_) => "delete_node",
|
||||||
|
Self::SetListed(..) => "set_listed",
|
||||||
|
Self::SetShareAccess(..) => "set_share_access",
|
||||||
|
};
|
||||||
|
warn!(%error, write = what, "db write-through failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inode lock, held for the duration of a mutation, plus the write-throughs
|
||||||
|
/// that mutation owed the database.
|
||||||
|
///
|
||||||
|
/// Dropping it releases the lock *first* and only then writes, which is the
|
||||||
|
/// whole point: interning a five-thousand-child listing commits one transaction
|
||||||
|
/// — several fsyncs' worth — and nothing about that needs the maps frozen. The
|
||||||
|
/// write still happens synchronously on this thread before the caller proceeds,
|
||||||
|
/// so a read that follows a mutation still sees it.
|
||||||
|
pub(crate) struct StateGuard<'a> {
|
||||||
|
guard: Option<parking_lot::MutexGuard<'a, State>>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
||||||
|
/// every live mount rather than this `Core`'s own.
|
||||||
|
pub(crate) fn lock_state<'a>(
|
||||||
|
state: &'a parking_lot::Mutex<State>,
|
||||||
|
db: &'a Arc<Db>,
|
||||||
|
) -> StateGuard<'a> {
|
||||||
|
StateGuard::new(state.lock(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StateGuard<'a> {
|
||||||
|
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||||
|
Self {
|
||||||
|
guard: Some(guard),
|
||||||
|
db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for StateGuard<'_> {
|
||||||
|
type Target = State;
|
||||||
|
|
||||||
|
fn deref(&self) -> &State {
|
||||||
|
self.guard.as_ref().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for StateGuard<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut State {
|
||||||
|
self.guard.as_mut().expect("held until drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StateGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let Some(mut guard) = self.guard.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let writes = std::mem::take(&mut guard.outbox);
|
||||||
|
// The lock goes before the writes do. That ordering is the feature.
|
||||||
|
drop(guard);
|
||||||
|
for write in writes {
|
||||||
|
write.apply(self.db);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
|
/// A fresh, empty inode space over `db`.
|
||||||
|
///
|
||||||
|
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
||||||
|
/// to its root and starts allocating above it, while a bare state under test
|
||||||
|
/// has no root and starts at 1.
|
||||||
|
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||||
|
///
|
||||||
|
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||||
|
/// first and then applies them, which is the whole point. A test driving a
|
||||||
|
/// bare `State` has no guard, so it calls this where the daemon would have
|
||||||
|
/// dropped one.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flush_outbox(&mut self) {
|
||||||
|
let db = self.db.clone();
|
||||||
|
for write in std::mem::take(&mut self.outbox) {
|
||||||
|
write.apply(&db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database, with everything this state owes it already written.
|
||||||
|
///
|
||||||
|
/// What a test means by `st.db` is almost always "the database as of now",
|
||||||
|
/// which in the daemon is the state after the guard dropped. Handing back an
|
||||||
|
/// owned `Arc` rather than a borrow keeps it usable inside an `assert_eq!`
|
||||||
|
/// that also touches `st`.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn flushed_db(&mut self) -> Arc<Db> {
|
||||||
|
self.flush_outbox();
|
||||||
|
self.db.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
by_uid: HashMap::new(),
|
||||||
|
children: HashMap::new(),
|
||||||
|
next_ino,
|
||||||
|
active_writes: HashMap::new(),
|
||||||
|
handles: HashMap::new(),
|
||||||
|
next_fh: 1,
|
||||||
|
dir_snapshots: HashMap::new(),
|
||||||
|
access_changes: HashSet::new(),
|
||||||
|
share_access,
|
||||||
|
db,
|
||||||
|
outbox: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
/// Whether `uid` names an owned node currently reachable from this mount's
|
||||||
/// root. Resident entries can outlive their dentries for open-handle and
|
/// root. Resident entries can outlive their dentries for open-handle and
|
||||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
||||||
@@ -278,11 +444,9 @@ impl State {
|
|||||||
|| stored.unwrap_or(Access::Viewer),
|
|| stored.unwrap_or(Access::Viewer),
|
||||||
|membership| access_for(membership.role_exact(), parent_access, true),
|
|membership| access_for(membership.role_exact(), parent_access, true),
|
||||||
);
|
);
|
||||||
if let Err(error) = self.db.set_share_access(&node.uid, access) {
|
self.outbox
|
||||||
warn!(uid = %node.uid, ?access, %error, "db set_share_access failed");
|
.push(DbWrite::SetShareAccess(node.uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(node.uid.clone(), access);
|
||||||
self.share_access.insert(node.uid.clone(), access);
|
|
||||||
}
|
|
||||||
return access;
|
return access;
|
||||||
}
|
}
|
||||||
parent_access
|
parent_access
|
||||||
@@ -313,11 +477,9 @@ impl State {
|
|||||||
None => (None, Access::Unknown),
|
None => (None, Access::Unknown),
|
||||||
};
|
};
|
||||||
let access = access_for(role, parent_access, true);
|
let access = access_for(role, parent_access, true);
|
||||||
if let Err(error) = self.db.set_share_access(uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set observed share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid.clone(), access);
|
||||||
self.share_access.insert(uid.clone(), access);
|
|
||||||
}
|
|
||||||
let Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -602,18 +764,14 @@ impl State {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
for (uid, access) in discovered {
|
for (uid, access) in discovered {
|
||||||
if let Err(error) = self.db.set_share_access(&uid, access) {
|
self.outbox
|
||||||
warn!(%uid, ?access, %error, "db set hydrated share access failed");
|
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||||
} else {
|
self.share_access.insert(uid, access);
|
||||||
self.share_access.insert(uid, access);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
|
||||||
}
|
|
||||||
self.intern_mem(parent, node)
|
self.intern_mem(parent, node)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,9 +843,7 @@ impl State {
|
|||||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||||
/// listing, rather than one autocommit — and one fsync — per child.
|
/// listing, rather than one autocommit — and one fsync — per child.
|
||||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||||
warn!(error = %e, "db upsert_nodes failed");
|
|
||||||
}
|
|
||||||
nodes
|
nodes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|node| self.intern_mem(parent, node))
|
.map(|node| self.intern_mem(parent, node))
|
||||||
@@ -769,9 +925,7 @@ impl State {
|
|||||||
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||||
/// the kernel.
|
/// the kernel.
|
||||||
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
self.outbox.push(DbWrite::Delete(uid.clone()));
|
||||||
warn!(%uid, error = %e, "db delete_node failed");
|
|
||||||
}
|
|
||||||
self.forget_mem(uid)
|
self.forget_mem(uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,9 +988,7 @@ impl State {
|
|||||||
kids.push(ino);
|
kids.push(ino);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = self.db.upsert_node(&node) {
|
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
|
||||||
}
|
|
||||||
self.recompute_descendant_access(ino);
|
self.recompute_descendant_access(ino);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,9 +1003,7 @@ impl State {
|
|||||||
self.children.remove(&ino);
|
self.children.remove(&ino);
|
||||||
if let Some(e) = self.entries.get(&ino) {
|
if let Some(e) = self.entries.get(&ino) {
|
||||||
let uid = e.uid.clone();
|
let uid = e.uid.clone();
|
||||||
if let Err(err) = self.db.set_listed(&uid, false) {
|
self.outbox.push(DbWrite::SetListed(uid, false));
|
||||||
warn!(%uid, error = %err, "db set_listed(false) failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -920,14 +1070,15 @@ impl State {
|
|||||||
/// file is as long as the caller was told it is. Without it a restart serves
|
/// file is as long as the caller was told it is. Without it a restart serves
|
||||||
/// the stale size and the file reads as truncated — or empty — while its
|
/// the stale size and the file reads as truncated — or empty — while its
|
||||||
/// bytes sit safely in staging (offline.md Phase 3).
|
/// bytes sit safely in staging (offline.md Phase 3).
|
||||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||||
|
/// the one mutation here whose write-through the caller must do itself:
|
||||||
|
/// failure has to be reported, not logged. Dropping the row silently means
|
||||||
|
/// the daemon acknowledged a write and then serves the old size for it, so
|
||||||
|
/// the caller has to be able to turn it into a failed `release`.
|
||||||
|
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) -> Option<Node> {
|
||||||
self.set_size(ino, size);
|
self.set_size(ino, size);
|
||||||
self.touch_mtime(ino, mtime);
|
self.touch_mtime(ino, mtime);
|
||||||
if let Some(e) = self.entries.get(&ino)
|
self.entries.get(&ino).map(|e| e.node.clone())
|
||||||
&& let Err(err) = self.db.upsert_node(&e.node)
|
|
||||||
{
|
|
||||||
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1120,8 @@ mod tests {
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,6 +1130,9 @@ mod tests {
|
|||||||
share_id: ShareId::from("share"),
|
share_id: ShareId::from("share"),
|
||||||
membership_id: ShareMembershipId::from("membership"),
|
membership_id: ShareMembershipId::from("membership"),
|
||||||
permissions,
|
permissions,
|
||||||
|
invite_time: None,
|
||||||
|
inviter_email: None,
|
||||||
|
inviter_verification: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,18 +1164,7 @@ mod tests {
|
|||||||
let dir = TempDir::new();
|
let dir = TempDir::new();
|
||||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||||
let share_access = db.all_share_access().unwrap();
|
let share_access = db.all_share_access().unwrap();
|
||||||
let st = State {
|
let st = State::new(Arc::new(db), share_access, 1);
|
||||||
entries: HashMap::new(),
|
|
||||||
by_uid: HashMap::new(),
|
|
||||||
children: HashMap::new(),
|
|
||||||
next_ino: 1,
|
|
||||||
active_writes: HashMap::new(),
|
|
||||||
handles: HashMap::new(),
|
|
||||||
next_fh: 1,
|
|
||||||
access_changes: HashSet::new(),
|
|
||||||
share_access,
|
|
||||||
db: Arc::new(db),
|
|
||||||
};
|
|
||||||
(st, dir)
|
(st, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,8 +1177,8 @@ mod tests {
|
|||||||
let f = st.intern(src, node("f", "src", "f.txt", false));
|
let f = st.intern(src, node("f", "src", "f.txt", false));
|
||||||
st.children.insert(src, vec![f]);
|
st.children.insert(src, vec![f]);
|
||||||
st.children.insert(dst, vec![]);
|
st.children.insert(dst, vec![]);
|
||||||
st.db.set_listed(&uid("src"), true).unwrap();
|
st.flushed_db().set_listed(&uid("src"), true).unwrap();
|
||||||
st.db.set_listed(&uid("dst"), true).unwrap();
|
st.flushed_db().set_listed(&uid("dst"), true).unwrap();
|
||||||
(st, dir, src, dst)
|
(st, dir, src, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,11 +1192,17 @@ mod tests {
|
|||||||
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("dst"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"destination re-enumerates: it gained a child whose row was just deleted"
|
"destination re-enumerates: it gained a child whose row was just deleted"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none(),
|
||||||
"source re-enumerates: its listing predates the move, and a stale \
|
"source re-enumerates: its listing predates the move, and a stale \
|
||||||
`listed` flag would hide every later remote change under it"
|
`listed` flag would hide every later remote change under it"
|
||||||
);
|
);
|
||||||
@@ -1074,12 +1225,41 @@ mod tests {
|
|||||||
let (mut st, _dir, src, _dst) = two_folders();
|
let (mut st, _dir, src, _dst) = two_folders();
|
||||||
let f = st.by_uid[&uid("f")];
|
let f = st.by_uid[&uid("f")];
|
||||||
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
||||||
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
assert!(
|
||||||
|
st.flushed_db()
|
||||||
|
.children_if_listed(&uid("src"))
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
assert!(!st.children.contains_key(&src));
|
assert!(!st.children.contains_key(&src));
|
||||||
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
||||||
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DB1: a mutation's write-through must not happen with the inode lock
|
||||||
|
/// held. Interning a listing commits a transaction over every child, and
|
||||||
|
/// every other FUSE callback used to wait behind that for no reason — the
|
||||||
|
/// maps it needs were updated before the first row was written.
|
||||||
|
#[test]
|
||||||
|
fn a_mutation_writes_through_only_after_the_lock_is_released() {
|
||||||
|
let (st, _dir) = state();
|
||||||
|
let db = st.db.clone();
|
||||||
|
let root = uid("root").to_string();
|
||||||
|
let state = parking_lot::Mutex::new(st);
|
||||||
|
{
|
||||||
|
let mut guard = lock_state(&state, &db);
|
||||||
|
guard.intern(0, node("root", "none", "My Files", true));
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_none(),
|
||||||
|
"the row must not be written while the lock is held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
db.node_by_uid(&root).unwrap().is_some(),
|
||||||
|
"…and must be written by the time the guard is gone"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The failure mode the fix exists for, stated directly: forgetting the node
|
/// The failure mode the fix exists for, stated directly: forgetting the node
|
||||||
/// prunes it from the resident listing but leaves the DB claiming the source
|
/// prunes it from the resident listing but leaves the DB claiming the source
|
||||||
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
||||||
@@ -1087,7 +1267,7 @@ mod tests {
|
|||||||
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
||||||
let (mut st, _dir, _src, _dst) = two_folders();
|
let (mut st, _dir, _src, _dst) = two_folders();
|
||||||
st.forget(&uid("f"));
|
st.forget(&uid("f"));
|
||||||
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
let listed = st.flushed_db().children_if_listed(&uid("src")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
listed.is_some(),
|
listed.is_some(),
|
||||||
"forget does not clear the flag — which is exactly why relocate must"
|
"forget does not clear the flag — which is exactly why relocate must"
|
||||||
@@ -1136,7 +1316,7 @@ mod tests {
|
|||||||
assert!(!st.has_children(folder), "cleared memory children");
|
assert!(!st.has_children(folder), "cleared memory children");
|
||||||
|
|
||||||
// DB children
|
// DB children
|
||||||
st.db
|
st.flushed_db()
|
||||||
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1162,7 +1342,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Editor)
|
Some(Access::Editor)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1174,8 +1354,8 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
||||||
virtual_root.uid = virtual_uid.clone();
|
virtual_root.uid = virtual_uid.clone();
|
||||||
st.db.upsert_node(&virtual_root).unwrap();
|
st.flushed_db().upsert_node(&virtual_root).unwrap();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
||||||
@@ -1183,15 +1363,17 @@ mod tests {
|
|||||||
let mut shared = node("shared", "placeholder", "Retained", true);
|
let mut shared = node("shared", "placeholder", "Retained", true);
|
||||||
shared.parent_uid = Some(virtual_uid.clone());
|
shared.parent_uid = Some(virtual_uid.clone());
|
||||||
shared.membership = Some(membership(6));
|
shared.membership = Some(membership(6));
|
||||||
st.db.upsert_node(&shared).unwrap();
|
st.flushed_db().upsert_node(&shared).unwrap();
|
||||||
st.db.set_share_access(&shared.uid, Access::Editor).unwrap();
|
st.flushed_db()
|
||||||
|
.set_share_access(&shared.uid, Access::Editor)
|
||||||
|
.unwrap();
|
||||||
let shared_ino =
|
let shared_ino =
|
||||||
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
||||||
let child = st.intern(
|
let child = st.intern(
|
||||||
shared_ino,
|
shared_ino,
|
||||||
node("shared-child", "shared", "pending.txt", false),
|
node("shared-child", "shared", "pending.txt", false),
|
||||||
);
|
);
|
||||||
st.db
|
st.flushed_db()
|
||||||
.enqueue_op(&pdfs_core::db::PendingOp {
|
.enqueue_op(&pdfs_core::db::PendingOp {
|
||||||
id: 0,
|
id: 0,
|
||||||
kind: pdfs_core::db::OP_RENAME.to_string(),
|
kind: pdfs_core::db::OP_RENAME.to_string(),
|
||||||
@@ -1209,20 +1391,20 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let snapshot = st.db.visible_children(&virtual_uid).unwrap();
|
let snapshot = st.flushed_db().visible_children(&virtual_uid).unwrap();
|
||||||
assert_eq!(snapshot.len(), 1);
|
assert_eq!(snapshot.len(), 1);
|
||||||
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&shared.uid).unwrap(),
|
st.flushed_db().share_access(&shared.uid).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(st.db.pending_ops().unwrap().len(), 1);
|
assert_eq!(st.flushed_db().pending_ops().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1233,12 +1415,12 @@ mod tests {
|
|||||||
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
||||||
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
||||||
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("offline-share"), Access::Viewer)
|
.set_share_access(&uid("offline-share"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
|
|
||||||
// Reconstruct in deliberately non-topological order, matching the
|
// Reconstruct in deliberately non-topological order, matching the
|
||||||
// database hydration path that first allocates every inode and only
|
// database hydration path that first allocates every inode and only
|
||||||
@@ -1296,7 +1478,7 @@ mod tests {
|
|||||||
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
||||||
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
||||||
for node in nodes {
|
for node in nodes {
|
||||||
st.db.upsert_node(node).unwrap();
|
st.flushed_db().upsert_node(node).unwrap();
|
||||||
}
|
}
|
||||||
let inos: Vec<u64> = nodes
|
let inos: Vec<u64> = nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1394,10 +1576,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("device-root"), Access::Viewer)
|
.set_share_access(&uid("device-root"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let nodes = [
|
let nodes = [
|
||||||
node("root", "none", "My Files", true),
|
node("root", "none", "My Files", true),
|
||||||
node("documents", "device-root", "Documents", true),
|
node("documents", "device-root", "Documents", true),
|
||||||
@@ -1431,7 +1613,7 @@ mod tests {
|
|||||||
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1441,8 +1623,10 @@ mod tests {
|
|||||||
for persisted in [Access::Viewer, Access::Editor] {
|
for persisted in [Access::Viewer, Access::Editor] {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db.set_share_access(&uid("shared"), persisted).unwrap();
|
st.flushed_db()
|
||||||
st.share_access = st.db.all_share_access().unwrap();
|
.set_share_access(&uid("shared"), persisted)
|
||||||
|
.unwrap();
|
||||||
|
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||||
let shared = st.intern(
|
let shared = st.intern(
|
||||||
owned_root,
|
owned_root,
|
||||||
node("shared", "root", "Shared without membership", true),
|
node("shared", "root", "Shared without membership", true),
|
||||||
@@ -1454,7 +1638,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(st.entries[&shared].access, persisted);
|
assert_eq!(st.entries[&shared].access, persisted);
|
||||||
assert_eq!(st.entries[&child].access, persisted);
|
assert_eq!(st.entries[&child].access, persisted);
|
||||||
assert_eq!(st.db.share_access(&uid("shared")).unwrap(), Some(persisted));
|
assert_eq!(
|
||||||
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
|
Some(persisted)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1462,7 +1649,7 @@ mod tests {
|
|||||||
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
||||||
let (mut st, _dir) = state();
|
let (mut st, _dir) = state();
|
||||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Editor)
|
.set_share_access(&uid("shared"), Access::Editor)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
||||||
@@ -1474,7 +1661,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.share_access(&uid("shared")).unwrap(),
|
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1491,7 +1678,7 @@ mod tests {
|
|||||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||||
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
||||||
virtual_node.uid = virtual_uid.clone();
|
virtual_node.uid = virtual_uid.clone();
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&virtual_uid, Access::Viewer)
|
.set_share_access(&virtual_uid, Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
||||||
@@ -1558,7 +1745,7 @@ mod tests {
|
|||||||
let child = st.intern(shared, node("child", "shared", "folder", true));
|
let child = st.intern(shared, node("child", "shared", "folder", true));
|
||||||
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
||||||
|
|
||||||
st.db
|
st.flushed_db()
|
||||||
.set_share_access(&uid("shared"), Access::Viewer)
|
.set_share_access(&uid("shared"), Access::Viewer)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
||||||
@@ -1572,7 +1759,7 @@ mod tests {
|
|||||||
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
||||||
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
st.db.effective_node_access(&uid("leaf")).unwrap(),
|
st.flushed_db().effective_node_access(&uid("leaf")).unwrap(),
|
||||||
Some(Access::Viewer)
|
Some(Access::Viewer)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1628,7 +1815,7 @@ mod tests {
|
|||||||
let shared = st.intern(owned_root, shared_node);
|
let shared = st.intern(owned_root, shared_node);
|
||||||
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
||||||
|
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
assert!(changed.contains(&shared));
|
assert!(changed.contains(&shared));
|
||||||
@@ -1652,7 +1839,7 @@ mod tests {
|
|||||||
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
||||||
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
||||||
}
|
}
|
||||||
st.db.downgrade_all_share_access().unwrap();
|
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||||
|
|
||||||
let changed = st.downgrade_known_shared_access();
|
let changed = st.downgrade_known_shared_access();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! The thread that watches the daemon for the thing the daemon cannot report
|
||||||
|
//! about itself: that it has stopped making progress.
|
||||||
|
//!
|
||||||
|
//! Three jobs, all cheap, all on one timer:
|
||||||
|
//!
|
||||||
|
//! 1. **Say so in the journal.** A worker that has held one job for minutes, a
|
||||||
|
//! queue that is not draining, a control request that never returned — each
|
||||||
|
//! gets a WARN naming it. The 2026-09-18 hang left nothing in the journal at
|
||||||
|
//! all; this is what turns the next one into a one-line diagnosis.
|
||||||
|
//! 2. **Ping the systemd watchdog**, but only after a real round trip over the
|
||||||
|
//! control socket. Pinging unconditionally from a thread that does nothing
|
||||||
|
//! else would keep the unit "healthy" through exactly the hang it is meant to
|
||||||
|
//! catch: the probe is what makes the ping mean "a client can still talk to
|
||||||
|
//! this daemon".
|
||||||
|
//! 3. **Sample memory.** That hang peaked at 5.2 GiB resident, and nobody knew
|
||||||
|
//! until it was over.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write as _};
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use pdfs_core::control::Request as CtlRequest;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use super::systemd;
|
||||||
|
|
||||||
|
/// How often the supervisor looks. Fast enough that a stall is named while the
|
||||||
|
/// user is still watching it, slow enough to cost nothing.
|
||||||
|
const TICK: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// How long one job may hold a worker, or one control request may run, before it
|
||||||
|
/// is called out. Generous: a large upload chunk or a cold folder listing on a
|
||||||
|
/// slow link is minutes of legitimate work, and a false WARN every few minutes
|
||||||
|
/// would train everyone to ignore the real one.
|
||||||
|
const STALL_AFTER: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// How often resident size goes into the log.
|
||||||
|
const RSS_EVERY: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
/// How long the liveness probe waits for the daemon to answer its own control
|
||||||
|
/// socket. Well under the watchdog period, so a probe that times out still
|
||||||
|
/// leaves time for the manager to hear nothing and act.
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Run until shutdown. Spawned once per mount, from [`super::mount::mount`].
|
||||||
|
pub(crate) fn run(core: Core, control_socket: PathBuf) {
|
||||||
|
let watchdog = systemd::watchdog_period();
|
||||||
|
if let Some(period) = watchdog {
|
||||||
|
info!(period_secs = period.as_secs(), "systemd watchdog enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_rss = Instant::now();
|
||||||
|
let mut last_ping = Instant::now();
|
||||||
|
// Per-lane completion counts from the previous tick, to tell a queue that is
|
||||||
|
// being worked through from one that is not moving at all.
|
||||||
|
let mut last_completed = (0_u64, 0_u64);
|
||||||
|
|
||||||
|
while core.shutdown.sleep(TICK) {
|
||||||
|
let report = core.diagnostics();
|
||||||
|
let stalled = report_stalls(&report, last_completed);
|
||||||
|
last_completed = (report.meta_completed, report.transfer_completed);
|
||||||
|
|
||||||
|
if last_rss.elapsed() >= RSS_EVERY {
|
||||||
|
info!(
|
||||||
|
rss_bytes = report.rss_bytes,
|
||||||
|
pending_ops = report.pending_ops,
|
||||||
|
meta_queued = report.meta_queued,
|
||||||
|
transfer_queued = report.transfer_queued,
|
||||||
|
stalled,
|
||||||
|
"daemon resource sample"
|
||||||
|
);
|
||||||
|
last_rss = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stalled lane is deliberately *not* a reason to withhold the ping:
|
||||||
|
// the mount may still be answering everything else, and a restart would
|
||||||
|
// throw away in-flight uploads. The probe decides that; a stall only
|
||||||
|
// gets said out loud.
|
||||||
|
if let Some(period) = watchdog
|
||||||
|
&& last_ping.elapsed() >= period
|
||||||
|
{
|
||||||
|
if probe(&control_socket) {
|
||||||
|
systemd::watchdog_ping();
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
"daemon did not answer its own control socket; withholding the watchdog ping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
last_ping = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WARN about anything that has not moved for [`STALL_AFTER`]. Returns whether
|
||||||
|
/// anything was reported, so the caller can weigh it.
|
||||||
|
fn report_stalls(report: &pdfs_core::control::Diagnostics, last_completed: (u64, u64)) -> bool {
|
||||||
|
let threshold = STALL_AFTER.as_secs();
|
||||||
|
let mut stalled = false;
|
||||||
|
|
||||||
|
for worker in &report.workers {
|
||||||
|
if worker.busy && worker.age_secs >= threshold {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
worker = %worker.name,
|
||||||
|
job = %worker.label,
|
||||||
|
age_secs = worker.age_secs,
|
||||||
|
"a fuse worker has held the same job for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(oldest) = report.inflight.first()
|
||||||
|
&& oldest.age_secs >= threshold
|
||||||
|
{
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
request = %oldest.kind,
|
||||||
|
age_secs = oldest.age_secs,
|
||||||
|
in_flight = report.inflight.len(),
|
||||||
|
"a control request has been running for a long time"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// A backed-up lane is normal; a backed-up lane that finished nothing since
|
||||||
|
// the last tick is not.
|
||||||
|
if report.meta_queued > 0 && report.meta_completed == last_completed.0 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.meta_queued,
|
||||||
|
"the metadata lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if report.transfer_queued > 0 && report.transfer_completed == last_completed.1 {
|
||||||
|
stalled = true;
|
||||||
|
warn!(
|
||||||
|
queued = report.transfer_queued,
|
||||||
|
"the transfer lane has not completed a job since the last check"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stalled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round trip over the control socket: connect, ask for the cheapest
|
||||||
|
/// request there is, read the answer.
|
||||||
|
///
|
||||||
|
/// This is the liveness the watchdog ping stands for. It covers the accept loop,
|
||||||
|
/// the handler pool and the reply path — everything a `pdfs` command or the GUI
|
||||||
|
/// depends on — without touching the network or the database.
|
||||||
|
fn probe(control_socket: &Path) -> bool {
|
||||||
|
let Ok(stream) = UnixStream::connect(control_socket) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
|| stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(request) = serde_json::to_string(&CtlRequest::Diagnostics) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let mut stream = stream;
|
||||||
|
if stream.write_all(format!("{request}\n").as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut line = String::new();
|
||||||
|
BufReader::new(stream).read_line(&mut line).is_ok() && !line.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::control::{Diagnostics, InflightRequest, WorkerState};
|
||||||
|
|
||||||
|
fn busy_worker(age_secs: u64) -> WorkerState {
|
||||||
|
WorkerState {
|
||||||
|
name: "pdfs-fuse-3".into(),
|
||||||
|
busy: true,
|
||||||
|
label: "read".into(),
|
||||||
|
age_secs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker doing normal work must not be reported: a WARN every tick is a
|
||||||
|
/// WARN nobody reads.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_work_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(5)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worker on the same job past the threshold is the shape of the hang.
|
||||||
|
#[test]
|
||||||
|
fn a_long_held_job_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
workers: vec![busy_worker(STALL_AFTER.as_secs() + 1)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// So is a control request that never returned.
|
||||||
|
#[test]
|
||||||
|
fn a_long_running_request_is_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
inflight: vec![InflightRequest {
|
||||||
|
kind: "OpenFile".into(),
|
||||||
|
age_secs: STALL_AFTER.as_secs() + 1,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(report_stalls(&report, (0, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queue that is draining is busy, not stalled — that is what comparing
|
||||||
|
/// the completion counts is for.
|
||||||
|
#[test]
|
||||||
|
fn a_draining_queue_is_not_a_stall() {
|
||||||
|
let report = Diagnostics {
|
||||||
|
transfer_queued: 12,
|
||||||
|
transfer_completed: 40,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!report_stalls(&report, (0, 30)));
|
||||||
|
assert!(report_stalls(&report, (0, 40)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,10 +67,16 @@ impl Core {
|
|||||||
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
/// Sweep for stale conflict copies forever, on its own thread. See the module
|
||||||
/// docs for the identical/divergent policy.
|
/// docs for the identical/divergent policy.
|
||||||
pub(crate) fn run_conflict_sweep_loop(&self) {
|
pub(crate) fn run_conflict_sweep_loop(&self) {
|
||||||
std::thread::sleep(CONFLICT_SWEEP_WARMUP);
|
// Both waits are interruptible, so teardown does not have to sit out a
|
||||||
|
// five-minute interval to join this thread (bugs.md B44).
|
||||||
|
if !self.shutdown.sleep(CONFLICT_SWEEP_WARMUP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
self.sweep_conflicts_once();
|
self.sweep_conflicts_once();
|
||||||
std::thread::sleep(CONFLICT_SWEEP_INTERVAL);
|
if !self.shutdown.sleep(CONFLICT_SWEEP_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +144,9 @@ impl Core {
|
|||||||
/// enumerated every node up front and has been doing network I/O since. Before
|
/// enumerated every node up front and has been doing network I/O since. Before
|
||||||
/// touching anything remote this re-reads the node and refuses to act unless
|
/// touching anything remote this re-reads the node and refuses to act unless
|
||||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||||
/// unopened. Without that, a user edit landing inside the window is destroyed
|
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||||
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
/// or its staged blob: a write that arrives after that check is left for the
|
||||||
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
/// drain to resolve against the now-trashed node.
|
||||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
@@ -166,10 +172,13 @@ impl Core {
|
|||||||
self.for_each_state(|st| {
|
self.for_each_state(|st| {
|
||||||
st.forget_or_unlink(uid);
|
st.forget_or_unlink(uid);
|
||||||
});
|
});
|
||||||
if let Err(error) = self.discard_queued_ops(uid) {
|
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||||
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
// proved the queue empty before the trash above, but that proof is stale
|
||||||
return;
|
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||||
}
|
// that window stages bytes, and discarding here would unlink the staged
|
||||||
|
// blob holding the only copy of them. Leaving the op queued is the
|
||||||
|
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||||
|
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||||
self.cache.evict(uid);
|
self.cache.evict(uid);
|
||||||
self.evict_reader(uid);
|
self.evict_reader(uid);
|
||||||
if let Err(e) = self.db.delete_node(uid) {
|
if let Err(e) = self.db.delete_node(uid) {
|
||||||
@@ -194,8 +203,8 @@ impl Core {
|
|||||||
let uid = &snapshot.uid;
|
let uid = &snapshot.uid;
|
||||||
let name = snapshot.name.as_str();
|
let name = snapshot.name.as_str();
|
||||||
|
|
||||||
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
// A queued op means bytes are still owed an upload; trashing the node
|
||||||
// would throw those away along with the staged blob holding them.
|
// under them turns an ordinary upload into a conflict fork.
|
||||||
match self.db.has_any_op(&uid.to_string()) {
|
match self.db.has_any_op(&uid.to_string()) {
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
@@ -252,7 +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 {
|
||||||
@@ -359,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+591
-77
@@ -54,7 +54,7 @@ use engine::{classify, settle_with};
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use local_scan::is_write_mode;
|
use local_scan::is_write_mode;
|
||||||
use local_scan::open_for_write_set;
|
use local_scan::open_for_write_set;
|
||||||
use model::{LocalItem, Outcome, PassAbort, Pending, RemoteItem};
|
use model::{LocalItem, LocalSig, Outcome, PassAbort, Pending, RemoteItem};
|
||||||
pub(crate) use planner::base_name;
|
pub(crate) use planner::base_name;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use planner::conflict_path;
|
use planner::conflict_path;
|
||||||
@@ -82,6 +82,12 @@ impl Core {
|
|||||||
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
/// is guaranteed — a switch requested over the control socket cannot wait for a
|
||||||
/// pass that may run for minutes.
|
/// pass that may run for minutes.
|
||||||
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) {
|
||||||
|
// A paused folder skips the settle too: a queued switch to on-demand
|
||||||
|
// evicts the local tree, which is only safe after a pass uploaded it.
|
||||||
|
if self.sync_folder_paused(folder.id) {
|
||||||
|
debug!(id = folder.id, "sync: folder paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
// `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing
|
||||||
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
// to reconcile — but one may still carry a queued switch back to `mirror`,
|
||||||
// so the settle below is not skipped with it.
|
// so the settle below is not skipped with it.
|
||||||
@@ -525,6 +531,9 @@ impl Core {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trashed.push(rel.clone());
|
trashed.push(rel.clone());
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -687,6 +696,7 @@ impl Core {
|
|||||||
rel_path: rel.clone(),
|
rel_path: rel.clone(),
|
||||||
remote_uid: Some(remote.uid.to_string()),
|
remote_uid: Some(remote.uid.to_string()),
|
||||||
local_mtime: local.mtime,
|
local_mtime: local.mtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: local.size,
|
local_size: local.size,
|
||||||
remote_rev: Some(remote.mtime.to_string()),
|
remote_rev: Some(remote.mtime.to_string()),
|
||||||
remote_hash: Some(remote.size.to_string()),
|
remote_hash: Some(remote.size.to_string()),
|
||||||
@@ -717,6 +727,9 @@ impl Core {
|
|||||||
uid: remote.uid.clone(),
|
uid: remote.uid.clone(),
|
||||||
mtime: remote.mtime,
|
mtime: remote.mtime,
|
||||||
size: remote.size,
|
size: remote.size,
|
||||||
|
// What the walk saw here, so the apply step can tell a
|
||||||
|
// stale plan from a live one before it overwrites.
|
||||||
|
local: l.map(LocalSig::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
FilePlan::Conflict => {
|
FilePlan::Conflict => {
|
||||||
@@ -739,7 +752,9 @@ impl Core {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
FilePlan::DeleteLocal => {
|
FilePlan::DeleteLocal => {
|
||||||
if let Err(e) = std::fs::remove_file(local_root.join(rel_to_path(rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_file(local_root.join(rel_to_path(rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local file failed");
|
warn!(rel, error = %e, "sync: remove local file failed");
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -771,6 +786,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, rel);
|
let _ = self.db.sync_entry_remove(folder_id, rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -797,7 +815,9 @@ impl Core {
|
|||||||
// Deferred folder deletions, deepest first.
|
// Deferred folder deletions, deepest first.
|
||||||
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
delete_local_dirs.sort_by_key(|p| std::cmp::Reverse(p.matches('/').count()));
|
||||||
for rel in delete_local_dirs {
|
for rel in delete_local_dirs {
|
||||||
if let Err(e) = std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))) {
|
if let Err(e) =
|
||||||
|
removed_locally(std::fs::remove_dir_all(local_root.join(rel_to_path(&rel))))
|
||||||
|
{
|
||||||
warn!(rel, error = %e, "sync: remove local folder failed");
|
warn!(rel, error = %e, "sync: remove local folder failed");
|
||||||
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
self.log_activity(ActivityKind::Trash, base_name(&rel), e.to_string(), false);
|
||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
@@ -824,6 +844,9 @@ impl Core {
|
|||||||
outcome.errors += 1;
|
outcome.errors += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(parent) = parent_uid_of(&rel, &remote_dirs, &baseline) {
|
||||||
|
self.invalidate_children_of(&parent);
|
||||||
|
}
|
||||||
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
let _ = self.db.sync_entry_remove(folder_id, &rel);
|
||||||
self.log_activity(
|
self.log_activity(
|
||||||
ActivityKind::Trash,
|
ActivityKind::Trash,
|
||||||
@@ -849,68 +872,9 @@ impl Core {
|
|||||||
writing: &HashSet<PathBuf>,
|
writing: &HashSet<PathBuf>,
|
||||||
out: &mut HashMap<String, LocalItem>,
|
out: &mut HashMap<String, LocalItem>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
walk_local_tree(root, dir, rules, writing, out, &mut |rel| {
|
||||||
for entry in entries {
|
self.progress_scanned(folder_id, base_name(rel))
|
||||||
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
})
|
||||||
let path = entry.path();
|
|
||||||
let meta = std::fs::symlink_metadata(&path)
|
|
||||||
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
||||||
let stripped = path.strip_prefix(root).map_err(|e| {
|
|
||||||
format!(
|
|
||||||
"local path {} escaped sync root {}: {e}",
|
|
||||||
path.display(),
|
|
||||||
root.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let rel = stripped.to_str().ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
// Ignore our own in-flight download temp files.
|
|
||||||
if rel.contains(".pdfs-tmp-") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Ignored paths are dropped here as well as from the classification
|
|
||||||
// union — not for correctness (the union filter is what makes this
|
|
||||||
// safe) but so an ignored `node_modules/` is never descended into or
|
|
||||||
// counted against scan progress.
|
|
||||||
if rules.is_ignored(rel, meta.is_dir()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if meta.is_dir() {
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: true,
|
|
||||||
mtime: 0,
|
|
||||||
size: 0,
|
|
||||||
open_for_write: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
self.walk_local(folder_id, root, &path, rules, writing, out)?;
|
|
||||||
} else if meta.is_file() {
|
|
||||||
// Check whether any process holds this file open for writing.
|
|
||||||
// The canonical path is used because /proc/*/fd links resolve
|
|
||||||
// to canonical targets.
|
|
||||||
let ofw = std::fs::canonicalize(&path)
|
|
||||||
.map(|canon| writing.contains(&canon))
|
|
||||||
.unwrap_or(false);
|
|
||||||
out.insert(
|
|
||||||
rel.to_string(),
|
|
||||||
LocalItem {
|
|
||||||
is_dir: false,
|
|
||||||
mtime: system_mtime(&meta),
|
|
||||||
size: meta.len() as i64,
|
|
||||||
open_for_write: ofw,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.progress_scanned(folder_id, base_name(rel));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
/// Map every remote folder under `folder` to its uid by rel path, ignoring
|
||||||
@@ -1069,6 +1033,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
@@ -1175,6 +1140,9 @@ impl Core {
|
|||||||
.create_folder(parent, base_name(rel), Some(now_secs()))
|
.create_folder(parent, base_name(rel), Some(now_secs()))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
.map_err(|e| format!("create remote folder {rel}: {e}"))?;
|
||||||
|
// The parent has gained a child that no cached listing of it
|
||||||
|
// knows about (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
self.baseline_dir(folder_id, rel, &uid)?;
|
self.baseline_dir(folder_id, rel, &uid)?;
|
||||||
Ok(Applied::Dir(rel.clone(), uid))
|
Ok(Applied::Dir(rel.clone(), uid))
|
||||||
}
|
}
|
||||||
@@ -1192,8 +1160,9 @@ impl Core {
|
|||||||
uid,
|
uid,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
|
local,
|
||||||
} => {
|
} => {
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, *local)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Downloaded)
|
Ok(Applied::Downloaded)
|
||||||
}
|
}
|
||||||
@@ -1211,7 +1180,11 @@ impl Core {
|
|||||||
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
.map_err(|e| format!("set aside conflict copy for {rel}: {e}"))?;
|
||||||
info!(rel, "sync: kept local changes as a conflict copy");
|
info!(rel, "sync: kept local changes as a conflict copy");
|
||||||
}
|
}
|
||||||
self.download_file(folder_id, local_root, rel, uid, *mtime, *size)
|
// The local copy has just been moved aside, so the state this
|
||||||
|
// download expects to find at the path is "nothing" — anything
|
||||||
|
// that appears while it runs is a *new* racing write, and gets
|
||||||
|
// its own conflict copy rather than being overwritten (B39).
|
||||||
|
self.download_file(folder_id, local_root, rel, uid, *mtime, *size, None)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Applied::Conflict)
|
Ok(Applied::Conflict)
|
||||||
}
|
}
|
||||||
@@ -1250,6 +1223,9 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
// Taken from the descriptor being streamed, so it names the content that
|
||||||
|
// actually goes up rather than whatever is at the path when we finish.
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
// Count the bytes as they are read, so this upload shows up in
|
// Count the bytes as they are read, so this upload shows up in
|
||||||
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
// `GetQueueStatus` next to a manual one. The uid isn't known until the
|
||||||
// draft is sealed, so the transfer registers without one.
|
// draft is sealed, so the transfer registers without one.
|
||||||
@@ -1276,7 +1252,13 @@ impl Core {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload {rel}: {e}"))?;
|
.map_err(|e| format!("upload {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, &uid).await
|
// The parent has gained a child that no cached listing of it knows
|
||||||
|
// about. Without this, a folder later switched to on-demand serves a
|
||||||
|
// snapshot that predates this upload (B86).
|
||||||
|
self.invalidate_children_of(parent);
|
||||||
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), &uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a changed local file as a new revision of an existing remote node.
|
/// Upload a changed local file as a new revision of an existing remote node.
|
||||||
@@ -1291,6 +1273,7 @@ impl Core {
|
|||||||
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
let file = std::fs::File::open(&path).map_err(|e| format!("open {rel}: {e}"))?;
|
||||||
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
let meta = file.metadata().map_err(|e| format!("stat {rel}: {e}"))?;
|
||||||
let mtime = system_mtime(&meta);
|
let mtime = system_mtime(&meta);
|
||||||
|
let streamed = LocalSig::from(&meta);
|
||||||
let reader = OwnedCountingReader::new(
|
let reader = OwnedCountingReader::new(
|
||||||
file,
|
file,
|
||||||
self.transfers.begin(
|
self.transfers.begin(
|
||||||
@@ -1304,11 +1287,20 @@ impl Core {
|
|||||||
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
.upload_new_revision_from(uid, reader, meta.len() as i64, Vec::new(), Some(mtime))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
.map_err(|e| format!("upload revision {rel}: {e}"))?;
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
warn_if_torn(rel, &path, streamed);
|
||||||
|
self.record_file_baseline(folder_id, rel, Some(streamed), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a remote file to its local path (atomically via a temp file),
|
/// Download a remote file to its local path (atomically via a temp file),
|
||||||
/// stamp the local mtime to match the remote, then record baseline.
|
/// stamp the local mtime to match the remote, then record baseline.
|
||||||
|
///
|
||||||
|
/// `expected_local` is the identity planning saw at the destination. The
|
||||||
|
/// rename below is the destructive step, and the download in between can run
|
||||||
|
/// for minutes, so the destination is re-examined right before it: a local
|
||||||
|
/// file that no longer matches the plan is set aside as a conflict copy
|
||||||
|
/// instead of being replaced (bugs.md B39).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn download_file(
|
async fn download_file(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
@@ -1317,6 +1309,7 @@ impl Core {
|
|||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
expected_local: Option<LocalSig>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = local_root.join(rel_to_path(rel));
|
let path = local_root.join(rel_to_path(rel));
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
@@ -1351,6 +1344,13 @@ impl Core {
|
|||||||
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
out.flush().map_err(|e| format!("flush tmp {rel}: {e}"))?;
|
||||||
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
out.sync_all().map_err(|e| format!("sync tmp {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
|
// The last moment at which the destination can still be saved. Anything
|
||||||
|
// other than the state planning classified means a writer got there
|
||||||
|
// first, and its bytes are the ones nothing else has a copy of.
|
||||||
|
if let Err(e) = keep_racing_local_edit(&path, expected_local) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
return Err(format!("set aside racing local edit for {rel}: {e}"));
|
||||||
|
}
|
||||||
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
std::fs::rename(&tmp, &path).map_err(|e| format!("place {rel}: {e}"))?;
|
||||||
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
// Match local mtime to the remote's so neither side looks "changed" next pass.
|
||||||
let f = std::fs::File::options()
|
let f = std::fs::File::options()
|
||||||
@@ -1366,23 +1366,36 @@ impl Core {
|
|||||||
.and_then(|dir| dir.sync_all())
|
.and_then(|dir| dir.sync_all())
|
||||||
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
.map_err(|e| format!("sync directory for {rel}: {e}"))?;
|
||||||
}
|
}
|
||||||
self.record_file_baseline(folder_id, rel, &path, uid).await
|
// Here the file on disk *is* the transferred content: it was published
|
||||||
|
// by a rename and stamped just above, so its stat is the identity.
|
||||||
|
self.record_file_baseline(folder_id, rel, local_sig(&path), uid)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh a file's baseline from ground truth: the local stat and the
|
/// Refresh a file's baseline from ground truth: the local identity these
|
||||||
/// remote node's reported signature. Called after every upload/download so
|
/// bytes came from and the remote node's reported signature. Called after
|
||||||
/// the very next reconcile sees the path as unchanged.
|
/// every upload/download so the very next reconcile sees the path as
|
||||||
|
/// unchanged.
|
||||||
|
///
|
||||||
|
/// `local` is the identity the *transferred content* corresponds to, which
|
||||||
|
/// is not always the file's current stat. An upload streams the live file,
|
||||||
|
/// so it passes the identity it read the first byte at: if a writer changed
|
||||||
|
/// the file mid-stream, the remote now holds a torn revision, and recording
|
||||||
|
/// the pre-upload identity is what makes the next pass see the path as
|
||||||
|
/// locally changed and upload a whole, clean revision over it. Recording the
|
||||||
|
/// post-upload stat instead declared the torn revision synchronized and left
|
||||||
|
/// it as the file's content on Drive (bugs.md B40).
|
||||||
async fn record_file_baseline(
|
async fn record_file_baseline(
|
||||||
&self,
|
&self,
|
||||||
folder_id: i64,
|
folder_id: i64,
|
||||||
rel: &str,
|
rel: &str,
|
||||||
local_path: &Path,
|
local: Option<LocalSig>,
|
||||||
uid: &NodeUid,
|
uid: &NodeUid,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let (lmtime, lsize) = match std::fs::metadata(local_path) {
|
let Some(local) = local else {
|
||||||
Ok(m) => (system_mtime(&m), m.len() as i64),
|
return Err(format!("stat {rel}: no local identity to baseline"));
|
||||||
Err(e) => return Err(format!("stat {rel}: {e}")),
|
|
||||||
};
|
};
|
||||||
|
let (lmtime, lsize) = (local.mtime, local.size);
|
||||||
// Re-fetch the node so the stored remote signature is exactly what a walk
|
// Re-fetch the node so the stored remote signature is exactly what a walk
|
||||||
// will report next time.
|
// will report next time.
|
||||||
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
let (rmtime, rsize) = match self.client.enumerate_nodes(std::slice::from_ref(uid)).await {
|
||||||
@@ -1402,6 +1415,7 @@ impl Core {
|
|||||||
rel_path: rel.to_string(),
|
rel_path: rel.to_string(),
|
||||||
remote_uid: Some(uid.to_string()),
|
remote_uid: Some(uid.to_string()),
|
||||||
local_mtime: lmtime,
|
local_mtime: lmtime,
|
||||||
|
local_mtime_ns: local.mtime_ns,
|
||||||
local_size: lsize,
|
local_size: lsize,
|
||||||
remote_rev: Some(rmtime.to_string()),
|
remote_rev: Some(rmtime.to_string()),
|
||||||
remote_hash: Some(rsize.to_string()),
|
remote_hash: Some(rsize.to_string()),
|
||||||
@@ -1411,6 +1425,177 @@ impl Core {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Say so when a file changed under an upload that was streaming it.
|
||||||
|
///
|
||||||
|
/// Purely diagnostic — the correction is already in place, because the baseline
|
||||||
|
/// records the identity the upload *read* rather than the one on disk now, so
|
||||||
|
/// the next pass classifies the path as locally changed and replaces the torn
|
||||||
|
/// revision. Without the log there is nothing in the journal to explain the
|
||||||
|
/// extra revision (bugs.md B40).
|
||||||
|
fn warn_if_torn(rel: &str, path: &Path, streamed: LocalSig) {
|
||||||
|
let now = local_sig(path);
|
||||||
|
if !now.is_some_and(|now| now.same_content(&streamed)) {
|
||||||
|
warn!(
|
||||||
|
rel,
|
||||||
|
?streamed,
|
||||||
|
?now,
|
||||||
|
"sync: file changed while uploading; the remote revision may be torn \
|
||||||
|
and will be replaced on the next pass"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity a reconcile pass uses to recognise a local file it has already
|
||||||
|
/// looked at: `None` when nothing is at the path.
|
||||||
|
fn local_sig(path: &Path) -> Option<LocalSig> {
|
||||||
|
std::fs::metadata(path).ok().map(|m| LocalSig {
|
||||||
|
mtime: system_mtime(&m),
|
||||||
|
mtime_ns: system_mtime_ns(&m),
|
||||||
|
size: m.len() as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guard the destination of a download: if what is at `path` is no longer what
|
||||||
|
/// planning classified, move it aside as a conflict copy before the caller
|
||||||
|
/// renames over it.
|
||||||
|
///
|
||||||
|
/// A reconcile pass decides what to do from a walk that finished before the
|
||||||
|
/// transfer started, and a download of a large file can run for minutes. Without
|
||||||
|
/// this, a local edit completed inside that window was replaced by the remote
|
||||||
|
/// version with no copy of it kept anywhere — the one shape of loss a sync
|
||||||
|
/// engine must not have (bugs.md B39). Keeping the copy makes it re-upload as a
|
||||||
|
/// new file on the next pass, which is the same resolution a both-sides-changed
|
||||||
|
/// conflict already gets.
|
||||||
|
///
|
||||||
|
/// A path that is empty when it was expected to be is not a conflict: the
|
||||||
|
/// download is about to recreate it, and there is nothing to preserve.
|
||||||
|
fn keep_racing_local_edit(path: &Path, expected: Option<LocalSig>) -> std::io::Result<()> {
|
||||||
|
let actual = local_sig(path);
|
||||||
|
let unchanged = match (actual, expected) {
|
||||||
|
(Some(a), Some(e)) => a.same_content(&e),
|
||||||
|
// Nothing there: the download is about to create it, and there is
|
||||||
|
// nothing to preserve either way.
|
||||||
|
(None, _) => true,
|
||||||
|
(Some(_), None) => false,
|
||||||
|
};
|
||||||
|
if unchanged {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
warn!(
|
||||||
|
path = %path.display(),
|
||||||
|
?expected,
|
||||||
|
?actual,
|
||||||
|
"sync: local file changed while downloading; keeping it as a conflict copy"
|
||||||
|
);
|
||||||
|
preserve_conflict_copy(path, now_secs()).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a local removal as the sync engine has to read it: the *state* it was
|
||||||
|
/// asking for is "this path is gone", so a path that was already gone is a
|
||||||
|
/// success, not a failure.
|
||||||
|
///
|
||||||
|
/// It matters because the baseline is only dropped on success. Treating
|
||||||
|
/// `NotFound` as an error left the baseline in place for a file that no longer
|
||||||
|
/// exists, so the same removal was re-attempted and re-reported as an error on
|
||||||
|
/// every pass, forever. Every other error is still an error, and keeping the
|
||||||
|
/// baseline is exactly right there: the surviving file must not be mistaken for
|
||||||
|
/// a new local creation on the next pass and uploaded back (bugs.md B42).
|
||||||
|
fn removed_locally(result: std::io::Result<()>) -> std::io::Result<()> {
|
||||||
|
match result {
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body of [`Core::walk_local`], without a `Core`: recursively collect the
|
||||||
|
/// local tree under `dir` into `out`, reporting each entry to `scanned`.
|
||||||
|
///
|
||||||
|
/// **Every failure here is fatal to the pass, deliberately.** A `read_dir` that
|
||||||
|
/// fails, an entry that cannot be stat-ed, a name that is not UTF-8 — each of
|
||||||
|
/// them would, if skipped, leave `out` describing a *smaller* tree than the one
|
||||||
|
/// on disk. Reconciliation reads a path present in the baseline and absent from
|
||||||
|
/// the scan as "the user deleted this", and propagates that to Drive. So an
|
||||||
|
/// unreadable subdirectory would trash its remote counterpart, and the deeper
|
||||||
|
/// the failure, the more it takes with it (`docs/BUGS.md` B55). Refusing the
|
||||||
|
/// whole pass keeps the previous baseline and leaves the remote alone; the next
|
||||||
|
/// pass retries once the transient condition clears.
|
||||||
|
///
|
||||||
|
/// Split out from the method so exactly that can be tested against a real tree
|
||||||
|
/// with a real unreadable directory in it, which needs no `Core` at all.
|
||||||
|
fn walk_local_tree(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
rules: &IgnoreRules,
|
||||||
|
writing: &HashSet<PathBuf>,
|
||||||
|
out: &mut HashMap<String, LocalItem>,
|
||||||
|
scanned: &mut dyn FnMut(&str),
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|e| format!("read entry in {}: {e}", dir.display()))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let meta = std::fs::symlink_metadata(&path)
|
||||||
|
.map_err(|e| format!("stat {}: {e}", path.display()))?;
|
||||||
|
let stripped = path.strip_prefix(root).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"local path {} escaped sync root {}: {e}",
|
||||||
|
path.display(),
|
||||||
|
root.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let rel = stripped.to_str().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"local path {} is not valid UTF-8; refusing a destructive sync pass",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Ignore our own in-flight download temp files.
|
||||||
|
if rel.contains(".pdfs-tmp-") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ignored paths are dropped here as well as from the classification
|
||||||
|
// union — not for correctness (the union filter is what makes this
|
||||||
|
// safe) but so an ignored `node_modules/` is never descended into or
|
||||||
|
// counted against scan progress.
|
||||||
|
if rules.is_ignored(rel, meta.is_dir()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if meta.is_dir() {
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: true,
|
||||||
|
mtime: 0,
|
||||||
|
mtime_ns: None,
|
||||||
|
size: 0,
|
||||||
|
open_for_write: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
walk_local_tree(root, &path, rules, writing, out, scanned)?;
|
||||||
|
} else if meta.is_file() {
|
||||||
|
// Check whether any process holds this file open for writing.
|
||||||
|
// The canonical path is used because /proc/*/fd links resolve
|
||||||
|
// to canonical targets.
|
||||||
|
let ofw = std::fs::canonicalize(&path)
|
||||||
|
.map(|canon| writing.contains(&canon))
|
||||||
|
.unwrap_or(false);
|
||||||
|
out.insert(
|
||||||
|
rel.to_string(),
|
||||||
|
LocalItem {
|
||||||
|
is_dir: false,
|
||||||
|
mtime: system_mtime(&meta),
|
||||||
|
mtime_ns: system_mtime_ns(&meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
open_for_write: ofw,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
scanned(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
/// Publish a conflict copy without overwriting an earlier conflict bearing the
|
||||||
/// same timestamp. The original is removed only after the new copy is durable.
|
/// same timestamp. The original is removed only after the new copy is durable.
|
||||||
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
||||||
@@ -1444,6 +1629,26 @@ fn preserve_conflict_copy(path: &Path, stamp: i64) -> std::io::Result<PathBuf> {
|
|||||||
unreachable!("u32 conflict suffix space exhausted")
|
unreachable!("u32 conflict suffix space exhausted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The remote uid of `rel`'s parent folder: from the pass's folder-uid map
|
||||||
|
/// first, and from the baseline second for a parent the map never covered.
|
||||||
|
///
|
||||||
|
/// Used to name the folder whose cached listing a remote mutation has just
|
||||||
|
/// invalidated (B86). `None` is not an error — it only means this pass cannot
|
||||||
|
/// say which folder to invalidate, and the caller skips it.
|
||||||
|
fn parent_uid_of(
|
||||||
|
rel: &str,
|
||||||
|
remote_dirs: &HashMap<String, NodeUid>,
|
||||||
|
baseline: &HashMap<String, StoredSyncEntry>,
|
||||||
|
) -> Option<NodeUid> {
|
||||||
|
let parent = parent_rel(rel);
|
||||||
|
remote_dirs.get(parent).cloned().or_else(|| {
|
||||||
|
baseline
|
||||||
|
.get(parent)
|
||||||
|
.and_then(|e| e.remote_uid.as_deref())
|
||||||
|
.and_then(parse_uid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
|
||||||
match path.parent() {
|
match path.parent() {
|
||||||
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
Some(parent) => std::fs::File::open(parent)?.sync_all(),
|
||||||
@@ -1476,10 +1681,77 @@ fn system_mtime(meta: &std::fs::Metadata) -> i64 {
|
|||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same modification time in nanoseconds, when the filesystem reports one.
|
||||||
|
///
|
||||||
|
/// Whole seconds cannot distinguish an edit made in the same second as the last
|
||||||
|
/// sync from no edit at all, and if the size did not change either, that file
|
||||||
|
/// was never uploaded (bugs.md B25). `None` for a clock or filesystem that
|
||||||
|
/// cannot answer, which falls the comparison back to seconds rather than
|
||||||
|
/// asserting an mtime of zero.
|
||||||
|
fn system_mtime_ns(meta: &std::fs::Metadata) -> Option<i64> {
|
||||||
|
meta.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||||
|
.and_then(|d| i64::try_from(d.as_nanos()).ok())
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A removal of something already gone has produced the state the sync
|
||||||
|
/// engine wanted, so its baseline is dropped rather than retried forever.
|
||||||
|
/// Anything else stays an error and keeps the baseline (bugs.md B42).
|
||||||
|
#[test]
|
||||||
|
fn an_already_absent_path_counts_as_removed() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b42-{}", std::process::id()));
|
||||||
|
let missing = root.join("never-existed");
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&missing)).is_ok());
|
||||||
|
assert!(removed_locally(std::fs::remove_dir_all(&missing)).is_ok());
|
||||||
|
|
||||||
|
// A directory in the way of `remove_file` is a real failure, and must
|
||||||
|
// stay one: the file survives, and its baseline has to survive with it.
|
||||||
|
let occupied = root.join("a-directory");
|
||||||
|
std::fs::create_dir_all(&occupied).unwrap();
|
||||||
|
assert!(removed_locally(std::fs::remove_file(&occupied)).is_err());
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The download guard: a destination that still matches the plan is
|
||||||
|
/// replaced silently, one that has moved on since is kept (bugs.md B39).
|
||||||
|
#[test]
|
||||||
|
fn a_download_keeps_a_local_edit_it_did_not_plan_for() {
|
||||||
|
let root = std::env::temp_dir().join(format!("pdfs-b39-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let path = root.join("report.txt");
|
||||||
|
|
||||||
|
// Nothing there, nothing expected: the download simply creates it.
|
||||||
|
assert!(keep_racing_local_edit(&path, None).is_ok());
|
||||||
|
assert!(!path.exists());
|
||||||
|
|
||||||
|
// Unchanged since planning: no copy, the caller overwrites it.
|
||||||
|
std::fs::write(&path, b"planned").unwrap();
|
||||||
|
let planned = local_sig(&path).unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"planned");
|
||||||
|
|
||||||
|
// Changed since planning: the racing bytes are the only copy of
|
||||||
|
// themselves, so they must survive the overwrite as a conflict copy.
|
||||||
|
std::fs::write(&path, b"a much longer local edit").unwrap();
|
||||||
|
keep_racing_local_edit(&path, Some(planned)).unwrap();
|
||||||
|
let kept: Vec<_> = std::fs::read_dir(&root)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| std::fs::read(e.path()).unwrap_or_default())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
kept.iter()
|
||||||
|
.any(|bytes| bytes == b"a much longer local edit"),
|
||||||
|
"the racing edit must still exist somewhere: {kept:?}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
/// Feed `n` `Reconcile` events `gap` apart on another thread, starting
|
||||||
/// immediately. Returns the sender's join handle so the test can keep it
|
/// immediately. Returns the sender's join handle so the test can keep it
|
||||||
/// alive (dropping the sender early would look like a disconnect).
|
/// alive (dropping the sender early would look like a disconnect).
|
||||||
@@ -1565,6 +1837,7 @@ mod tests {
|
|||||||
|
|
||||||
fn local_item(is_dir: bool) -> LocalItem {
|
fn local_item(is_dir: bool) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir,
|
is_dir,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
size: 1,
|
size: 1,
|
||||||
@@ -1722,6 +1995,44 @@ mod tests {
|
|||||||
assert!(guard_local_wipe(&base, &fresh).is_err());
|
assert!(guard_local_wipe(&base, &fresh).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B54's remaining case, end to end: a folder whose local root is still
|
||||||
|
/// *readable* but is no longer the tree it was — an empty directory left
|
||||||
|
/// where a mount used to be, or a different device mounted over it.
|
||||||
|
///
|
||||||
|
/// Nothing fails here. The scan succeeds and honestly reports an empty
|
||||||
|
/// directory, so the only thing standing between the user and a trashed
|
||||||
|
/// remote folder is the guard, and it has to fire for a one-file baseline
|
||||||
|
/// exactly as it does for a large one.
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_mountpoint_trips_the_guard_at_every_baseline_size() {
|
||||||
|
let root = sync_test_dir("wipe-replaced-mount");
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
assert!(local.is_empty(), "the replacement root is genuinely empty");
|
||||||
|
|
||||||
|
for size in 1..=3 {
|
||||||
|
let baseline: HashMap<String, ()> =
|
||||||
|
(0..size).map(|i| (format!("file{i}.txt"), ())).collect();
|
||||||
|
assert!(
|
||||||
|
guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_err(),
|
||||||
|
"a {size}-path baseline against an empty root is not a deletion"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the guard steps aside the moment the real tree is back, so a
|
||||||
|
// remounted folder resumes rather than staying wedged.
|
||||||
|
std::fs::write(root.join("file0.txt"), b"back").unwrap();
|
||||||
|
let mut local: HashMap<String, LocalItem> = HashMap::new();
|
||||||
|
walk_local_tree(&root, &root, &rules, &writing, &mut local, &mut progress).unwrap();
|
||||||
|
let baseline: HashMap<String, ()> = [("file0.txt".to_string(), ())].into_iter().collect();
|
||||||
|
assert!(guard_local_wipe(&filter_baseline(&baseline, &rules), &local).is_ok());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
/// A restored folder (features.md 5.2) starts with an empty baseline against
|
||||||
/// an empty local directory and a full remote. That must reconcile as
|
/// an empty local directory and a full remote. That must reconcile as
|
||||||
/// "download everything" rather than tripping the wipe guard — otherwise
|
/// "download everything" rather than tripping the wipe guard — otherwise
|
||||||
@@ -1764,6 +2075,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some(mtime.to_string()),
|
remote_rev: Some(mtime.to_string()),
|
||||||
remote_hash: Some(size.to_string()),
|
remote_hash: Some(size.to_string()),
|
||||||
},
|
},
|
||||||
@@ -1801,6 +2113,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
},
|
},
|
||||||
@@ -1815,6 +2128,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 10,
|
local_mtime: 10,
|
||||||
local_size: 20,
|
local_size: 20,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: Some("1700".into()),
|
remote_rev: Some("1700".into()),
|
||||||
remote_hash: Some("4096".into()),
|
remote_hash: Some("4096".into()),
|
||||||
};
|
};
|
||||||
@@ -1828,6 +2142,7 @@ mod tests {
|
|||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime: 0,
|
local_mtime: 0,
|
||||||
local_size: 0,
|
local_size: 0,
|
||||||
|
local_mtime_ns: None,
|
||||||
remote_rev: None,
|
remote_rev: None,
|
||||||
remote_hash: None,
|
remote_hash: None,
|
||||||
};
|
};
|
||||||
@@ -1863,6 +2178,7 @@ mod tests {
|
|||||||
uid: uid(),
|
uid: uid(),
|
||||||
mtime: 0,
|
mtime: 0,
|
||||||
size: 10,
|
size: 10,
|
||||||
|
local: None,
|
||||||
};
|
};
|
||||||
assert_eq!(op.kind(), ActivityKind::Download);
|
assert_eq!(op.kind(), ActivityKind::Download);
|
||||||
}
|
}
|
||||||
@@ -1918,6 +2234,204 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(dir).unwrap();
|
std::fs::remove_dir_all(dir).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B49's remaining cases. Preservation is the step that makes a download
|
||||||
|
/// safe to publish, so every way it can fail has to leave the local file
|
||||||
|
/// where it was — the download is aborted, not the file replaced.
|
||||||
|
#[test]
|
||||||
|
fn conflict_preservation_failures_all_keep_the_local_file() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
// An unwritable directory: the conflict copy cannot be created at all.
|
||||||
|
let dir = sync_test_dir("conflict-eacces");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"the only copy").unwrap();
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||||
|
let denied = preserve_conflict_copy(&source, 1700);
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
assert!(denied.is_err(), "a read-only directory must fail the copy");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&source).unwrap(),
|
||||||
|
b"the only copy",
|
||||||
|
"the local file survives byte-identical"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A source that vanished between planning and preservation. The pass
|
||||||
|
// must report it rather than publish the download over the gap.
|
||||||
|
let dir = sync_test_dir("conflict-enoent");
|
||||||
|
assert!(preserve_conflict_copy(&dir.join("gone.txt"), 1700).is_err());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
// A write error partway through the copy — induced by making the
|
||||||
|
// destination name a directory, so `create_new` fails as something
|
||||||
|
// other than `AlreadyExists`.
|
||||||
|
let dir = sync_test_dir("conflict-eisdir");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"still the only copy").unwrap();
|
||||||
|
std::fs::create_dir(conflict_path(&source, 1700)).unwrap();
|
||||||
|
// Suffix 0 is taken by a directory; `create_new` on it reports
|
||||||
|
// `AlreadyExists`, so preservation moves on to suffix 1 and succeeds.
|
||||||
|
// What must not happen is the source being removed without a copy.
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
assert!(preserved.is_file());
|
||||||
|
assert_eq!(std::fs::read(&preserved).unwrap(), b"still the only copy");
|
||||||
|
assert!(!source.exists());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A conflict copy keeps the bytes *and* the metadata that identifies them,
|
||||||
|
/// so the next pass uploads the user's version rather than a file that looks
|
||||||
|
/// freshly written.
|
||||||
|
#[test]
|
||||||
|
fn a_conflict_copy_is_byte_and_mtime_identical() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let dir = sync_test_dir("conflict-identity");
|
||||||
|
let source = dir.join("notes.txt");
|
||||||
|
std::fs::write(&source, b"local edit that must survive").unwrap();
|
||||||
|
let before = std::fs::metadata(&source).unwrap();
|
||||||
|
|
||||||
|
let preserved = preserve_conflict_copy(&source, 1700).unwrap();
|
||||||
|
let after = std::fs::metadata(&preserved).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&preserved).unwrap(),
|
||||||
|
b"local edit that must survive"
|
||||||
|
);
|
||||||
|
assert_eq!(after.len(), before.len());
|
||||||
|
assert_eq!(after.modified().unwrap(), before.modified().unwrap());
|
||||||
|
assert_eq!(after.permissions().mode(), before.permissions().mode());
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- B55: an incomplete local scan is never a set of deletions ---------
|
||||||
|
|
||||||
|
fn no_progress() -> impl FnMut(&str) {
|
||||||
|
|_: &str| {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory the pass cannot read must fail the pass. Omitting it would
|
||||||
|
/// hand reconciliation a baseline-minus-subtree, which it reads as the user
|
||||||
|
/// having deleted that subtree and propagates to Drive (B55).
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_subdirectory_fails_the_scan_instead_of_shrinking_it() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
let locked = root.join("locked");
|
||||||
|
std::fs::create_dir(&locked).unwrap();
|
||||||
|
std::fs::write(locked.join("inner.txt"), b"b").unwrap();
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("an unreadable directory must abort the pass");
|
||||||
|
assert!(
|
||||||
|
error.contains("locked"),
|
||||||
|
"the error should name the subtree it could not read: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same rule one level up: if the sync root itself cannot be read, the
|
||||||
|
/// pass sees nothing, and "nothing" must never mean "delete everything".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_root_fails_the_scan() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-root-eacces");
|
||||||
|
std::fs::write(root.join("keep.txt"), b"a").unwrap();
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
|
||||||
|
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root that is gone entirely — an unmounted device folder, a removed
|
||||||
|
/// directory — is the sharpest version of the same case: every baseline path
|
||||||
|
/// is "missing", and the scan must refuse rather than report an empty tree.
|
||||||
|
#[test]
|
||||||
|
fn an_absent_root_fails_the_scan() {
|
||||||
|
let root = sync_test_dir("scan-absent");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
assert!(walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress).is_err());
|
||||||
|
assert!(out.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A name that is not valid UTF-8 cannot be keyed into the scan, so it
|
||||||
|
/// cannot be compared against the baseline either. Refusing the pass is the
|
||||||
|
/// only safe reading: skipping it would classify the file as deleted.
|
||||||
|
#[test]
|
||||||
|
fn a_non_utf8_name_fails_the_scan() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
|
||||||
|
let root = sync_test_dir("scan-non-utf8");
|
||||||
|
std::fs::write(root.join(OsStr::from_bytes(b"bad\xffname")), b"x").unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut progress = no_progress();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut progress);
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
let error = result.expect_err("a name the scan cannot key must abort the pass");
|
||||||
|
assert!(error.contains("UTF-8"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The complement: a scan that *can* read everything reports everything, so
|
||||||
|
/// the strictness above does not come at the cost of a working pass. A
|
||||||
|
/// symlink is skipped by design rather than by failure, and a dangling one
|
||||||
|
/// stats fine through `symlink_metadata`.
|
||||||
|
#[test]
|
||||||
|
fn a_readable_tree_scans_completely() {
|
||||||
|
let root = sync_test_dir("scan-complete");
|
||||||
|
std::fs::write(root.join("a.txt"), b"aa").unwrap();
|
||||||
|
std::fs::create_dir(root.join("sub")).unwrap();
|
||||||
|
std::fs::write(root.join("sub/b.txt"), b"bbb").unwrap();
|
||||||
|
std::os::unix::fs::symlink("nowhere", root.join("dangling")).unwrap();
|
||||||
|
|
||||||
|
let rules = rules_for(&[]);
|
||||||
|
let writing = HashSet::new();
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let mut seen: Vec<String> = Vec::new();
|
||||||
|
let result = walk_local_tree(&root, &root, &rules, &writing, &mut out, &mut |rel| {
|
||||||
|
seen.push(rel.to_string())
|
||||||
|
});
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
|
||||||
|
result.unwrap();
|
||||||
|
let mut keys: Vec<&str> = out.keys().map(String::as_str).collect();
|
||||||
|
keys.sort_unstable();
|
||||||
|
assert_eq!(keys, vec!["a.txt", "sub", "sub/b.txt"]);
|
||||||
|
assert_eq!(out["a.txt"].size, 2);
|
||||||
|
assert_eq!(out["sub/b.txt"].size, 3);
|
||||||
|
assert!(out["sub"].is_dir);
|
||||||
|
assert_eq!(seen.len(), 3, "every reported entry ticks scan progress");
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_test_dir(label: &str) -> PathBuf {
|
fn sync_test_dir(label: &str) -> PathBuf {
|
||||||
let nonce = SystemTime::now()
|
let nonce = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -32,17 +32,24 @@ pub(crate) enum SyncMsg {
|
|||||||
ReconcileAll,
|
ReconcileAll,
|
||||||
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
/// Re-read the folder set and adjust the filesystem watches (after add/remove).
|
||||||
Rewatch,
|
Rewatch,
|
||||||
|
/// The daemon is tearing down: finish the current pass and end the engine
|
||||||
|
/// thread, so the mount can join it (bugs.md B44).
|
||||||
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
/// Start the sync engine: a control thread that owns the filesystem watcher and
|
||||||
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
/// serialises reconcile passes, plus a periodic poll thread. The engine receives
|
||||||
/// on `rx`; senders live in [`Core::sync_tx`].
|
/// on `rx`; senders live in [`Core::sync_tx`].
|
||||||
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) {
|
pub(crate) fn spawn(core: Core, rx: Receiver<SyncMsg>) -> Option<std::thread::JoinHandle<()>> {
|
||||||
if let Err(e) = std::thread::Builder::new()
|
match std::thread::Builder::new()
|
||||||
.name("pdfs-sync".into())
|
.name("pdfs-sync".into())
|
||||||
.spawn(move || engine_loop(core, rx))
|
.spawn(move || engine_loop(core, rx))
|
||||||
{
|
{
|
||||||
warn!(error = %e, "failed to start sync engine");
|
Ok(handle) => Some(handle),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "failed to start sync engine");
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +81,11 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
// Periodic remote poll.
|
// Periodic remote poll.
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pdfs-sync-poll".into())
|
.name("pdfs-sync-poll".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -94,8 +101,12 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if do_rewatch {
|
if do_rewatch {
|
||||||
rewatch(&core, &mut watcher, &watched);
|
rewatch(&core, &mut watcher, &watched);
|
||||||
@@ -104,6 +115,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
reconcile_all(&core);
|
reconcile_all(&core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -116,9 +130,9 @@ fn engine_loop(core: Core, rx: Receiver<SyncMsg>) {
|
|||||||
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
||||||
{
|
{
|
||||||
let tx = core.sync_tx.clone();
|
let tx = core.sync_tx.clone();
|
||||||
|
let shutdown = core.shutdown.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
loop {
|
while shutdown.sleep(POLL_INTERVAL) {
|
||||||
std::thread::sleep(POLL_INTERVAL);
|
|
||||||
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
if tx.send(SyncMsg::ReconcileAll).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -130,12 +144,19 @@ fn poll_only_loop(core: &Core, rx: Receiver<SyncMsg>) {
|
|||||||
let mut ids: HashSet<i64> = HashSet::new();
|
let mut ids: HashSet<i64> = HashSet::new();
|
||||||
let mut all = false;
|
let mut all = false;
|
||||||
let mut do_rewatch = false;
|
let mut do_rewatch = false;
|
||||||
classify(msg, &mut ids, &mut all, &mut do_rewatch);
|
if !classify(msg, &mut ids, &mut all, &mut do_rewatch)
|
||||||
settle(&rx, &mut ids, &mut all, &mut do_rewatch);
|
|| !settle(&rx, &mut ids, &mut all, &mut do_rewatch)
|
||||||
|
{
|
||||||
|
info!("sync engine stopping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if all {
|
if all {
|
||||||
reconcile_all(core);
|
reconcile_all(core);
|
||||||
} else {
|
} else {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if core.sync_paused() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
if let Ok(Some(folder)) = core.db.sync_folder_get(id) {
|
||||||
core.reconcile_folder(&folder);
|
core.reconcile_folder(&folder);
|
||||||
}
|
}
|
||||||
@@ -158,7 +179,7 @@ pub(super) fn settle_with(
|
|||||||
do_rewatch: &mut bool,
|
do_rewatch: &mut bool,
|
||||||
quiet: Duration,
|
quiet: Duration,
|
||||||
cap: Duration,
|
cap: Duration,
|
||||||
) {
|
) -> bool {
|
||||||
let deadline = Instant::now() + cap;
|
let deadline = Instant::now() + cap;
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -170,32 +191,59 @@ pub(super) fn settle_with(
|
|||||||
match rx.recv_timeout(wait) {
|
match rx.recv_timeout(wait) {
|
||||||
// Another event: the burst is still going, so the quiet window
|
// Another event: the burst is still going, so the quiet window
|
||||||
// starts again from here.
|
// starts again from here.
|
||||||
Ok(m) => classify(m, ids, all, do_rewatch),
|
// A stop lands mid-burst: give up the rest of the burst rather
|
||||||
|
// than settling for it, and let the caller end the loop.
|
||||||
|
Ok(m) => {
|
||||||
|
if !classify(m, ids, all, do_rewatch) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Quiet for a full window: whatever was being written has stopped.
|
// Quiet for a full window: whatever was being written has stopped.
|
||||||
Err(RecvTimeoutError::Timeout) => break,
|
Err(RecvTimeoutError::Timeout) => break,
|
||||||
// Every sender is gone; the caller's loop is about to end anyway.
|
// Every sender is gone; the caller's loop is about to end anyway.
|
||||||
Err(RecvTimeoutError::Disconnected) => break,
|
Err(RecvTimeoutError::Disconnected) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`settle_with`] at the real timings.
|
/// [`settle_with`] at the real timings.
|
||||||
fn settle(rx: &Receiver<SyncMsg>, ids: &mut HashSet<i64>, all: &mut bool, do_rewatch: &mut bool) {
|
fn settle(
|
||||||
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE);
|
rx: &Receiver<SyncMsg>,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
do_rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
|
settle_with(rx, ids, all, do_rewatch, DEBOUNCE, MAX_SETTLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn classify(msg: SyncMsg, ids: &mut HashSet<i64>, all: &mut bool, rewatch: &mut bool) {
|
/// Fold one message into the pending work of the current burst. Returns `false`
|
||||||
|
/// for [`SyncMsg::Stop`], which is not work but an instruction to end the loop.
|
||||||
|
pub(super) fn classify(
|
||||||
|
msg: SyncMsg,
|
||||||
|
ids: &mut HashSet<i64>,
|
||||||
|
all: &mut bool,
|
||||||
|
rewatch: &mut bool,
|
||||||
|
) -> bool {
|
||||||
match msg {
|
match msg {
|
||||||
SyncMsg::Reconcile(id) => {
|
SyncMsg::Reconcile(id) => {
|
||||||
ids.insert(id);
|
ids.insert(id);
|
||||||
}
|
}
|
||||||
SyncMsg::ReconcileAll => *all = true,
|
SyncMsg::ReconcileAll => *all = true,
|
||||||
SyncMsg::Rewatch => *rewatch = true,
|
SyncMsg::Rewatch => *rewatch = true,
|
||||||
|
SyncMsg::Stop => return false,
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconcile every mirror folder in turn.
|
/// Reconcile every mirror folder in turn. Skipped while sync is paused: the
|
||||||
|
/// resume sends a fresh [`SyncMsg::ReconcileAll`], which walks everything the
|
||||||
|
/// watcher reported meanwhile.
|
||||||
fn reconcile_all(core: &Core) {
|
fn reconcile_all(core: &Core) {
|
||||||
|
if core.sync_paused() {
|
||||||
|
debug!("sync: paused, skipping reconcile");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let folders = match core.db.sync_folder_list() {
|
let folders = match core.db.sync_folder_list() {
|
||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -2,9 +2,73 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// What a reconcile pass remembers about the local side of one path: enough to
|
||||||
|
/// recognise the same content again, and no more.
|
||||||
|
///
|
||||||
|
/// `mtime_ns` is the whole reason this is a struct rather than a pair — see
|
||||||
|
/// [`StoredSyncEntry::local_mtime_ns`](pdfs_core::db::StoredSyncEntry).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct LocalSig {
|
||||||
|
pub(super) mtime: i64,
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
|
pub(super) size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalSig {
|
||||||
|
/// Whether these two observations describe the same local content.
|
||||||
|
///
|
||||||
|
/// Nanoseconds decide it when both sides have them; otherwise the comparison
|
||||||
|
/// falls back to whole seconds, which is what a baseline row written before
|
||||||
|
/// schema 28 — or a filesystem that reports no sub-second time — can support
|
||||||
|
/// (bugs.md B25).
|
||||||
|
pub(super) fn same_content(&self, other: &Self) -> bool {
|
||||||
|
if self.size != other.size {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (self.mtime_ns, other.mtime_ns) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => self.mtime == other.mtime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&LocalItem> for LocalSig {
|
||||||
|
fn from(item: &LocalItem) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: item.mtime,
|
||||||
|
mtime_ns: item.mtime_ns,
|
||||||
|
size: item.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&std::fs::Metadata> for LocalSig {
|
||||||
|
fn from(meta: &std::fs::Metadata) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: system_mtime(meta),
|
||||||
|
mtime_ns: system_mtime_ns(meta),
|
||||||
|
size: meta.len() as i64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline's record of the local side, as [`LocalSig`] compares it.
|
||||||
|
impl From<&StoredSyncEntry> for LocalSig {
|
||||||
|
fn from(entry: &StoredSyncEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
mtime: entry.local_mtime,
|
||||||
|
mtime_ns: entry.local_mtime_ns,
|
||||||
|
size: entry.local_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) struct LocalItem {
|
pub(super) struct LocalItem {
|
||||||
pub(super) is_dir: bool,
|
pub(super) is_dir: bool,
|
||||||
pub(super) mtime: i64,
|
pub(super) mtime: i64,
|
||||||
|
/// [`LocalItem::mtime`] to nanosecond precision, when the filesystem
|
||||||
|
/// reports one.
|
||||||
|
pub(super) mtime_ns: Option<i64>,
|
||||||
pub(super) size: i64,
|
pub(super) size: i64,
|
||||||
/// True when another process holds this file open for writing (detected via
|
/// True when another process holds this file open for writing (detected via
|
||||||
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
/// `/proc/*/fd`). An open-for-write file is kept in the map — so it is not
|
||||||
@@ -91,11 +155,18 @@ pub(super) enum Pending {
|
|||||||
UploadRevision { rel: String, uid: NodeUid },
|
UploadRevision { rel: String, uid: NodeUid },
|
||||||
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
/// Download remote `uid` to the local path, stamping `mtime`. `size` is the
|
||||||
/// remote's reported size, used as the transfer's expected total.
|
/// remote's reported size, used as the transfer's expected total.
|
||||||
|
///
|
||||||
|
/// `local` is what the planning walk saw at this path, or `None` if nothing
|
||||||
|
/// was there. A download can run for minutes, so the apply step revalidates
|
||||||
|
/// against it immediately before it renames over the destination: a writer
|
||||||
|
/// that finished an edit in the meantime must not be overwritten without a
|
||||||
|
/// conflict copy (bugs.md B39).
|
||||||
Download {
|
Download {
|
||||||
rel: String,
|
rel: String,
|
||||||
uid: NodeUid,
|
uid: NodeUid,
|
||||||
mtime: i64,
|
mtime: i64,
|
||||||
size: i64,
|
size: i64,
|
||||||
|
local: Option<LocalSig>,
|
||||||
},
|
},
|
||||||
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
/// Both sides changed: set the local copy aside, then download remote `uid`.
|
||||||
Conflict {
|
Conflict {
|
||||||
|
|||||||
@@ -218,7 +218,10 @@ pub(super) fn plan_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let local_changed = local.is_some_and(|item| {
|
let local_changed = local.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| base.local_mtime != item.mtime || base.local_size != item.size)
|
// Nanoseconds where both sides have them (bugs.md B25): whole seconds
|
||||||
|
// could not see an edit made in the same second as the last sync that
|
||||||
|
// left the file the same length, so it was never uploaded.
|
||||||
|
baseline.is_none_or(|base| !LocalSig::from(item).same_content(&LocalSig::from(base)))
|
||||||
});
|
});
|
||||||
let remote_changed = remote.is_some_and(|item| {
|
let remote_changed = remote.is_some_and(|item| {
|
||||||
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
baseline.is_none_or(|base| remote_sig(base) != Some((item.mtime, item.size)))
|
||||||
@@ -252,6 +255,7 @@ mod file_plan_tests {
|
|||||||
|
|
||||||
fn local(mtime: i64, size: i64) -> LocalItem {
|
fn local(mtime: i64, size: i64) -> LocalItem {
|
||||||
LocalItem {
|
LocalItem {
|
||||||
|
mtime_ns: None,
|
||||||
is_dir: false,
|
is_dir: false,
|
||||||
mtime,
|
mtime,
|
||||||
size,
|
size,
|
||||||
@@ -278,12 +282,49 @@ mod file_plan_tests {
|
|||||||
rel_path: "file".into(),
|
rel_path: "file".into(),
|
||||||
remote_uid: Some("v~l".into()),
|
remote_uid: Some("v~l".into()),
|
||||||
local_mtime,
|
local_mtime,
|
||||||
|
local_mtime_ns: None,
|
||||||
local_size,
|
local_size,
|
||||||
remote_rev: Some(remote_mtime.to_string()),
|
remote_rev: Some(remote_mtime.to_string()),
|
||||||
remote_hash: Some(remote_size.to_string()),
|
remote_hash: Some(remote_size.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole seconds cannot see an edit made inside the same second that leaves the
|
||||||
|
/// file the same length, so a mirror silently stopped uploading it. With a
|
||||||
|
/// nanosecond time on both sides it is an ordinary local change (bugs.md B25).
|
||||||
|
#[test]
|
||||||
|
fn a_same_second_same_size_edit_is_still_a_local_change() {
|
||||||
|
let mut base = baseline(10, 20, 30, 40);
|
||||||
|
base.local_mtime_ns = Some(10_000_000_000);
|
||||||
|
let edited = LocalItem {
|
||||||
|
mtime_ns: Some(10_400_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
let remote = remote(30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::UploadRevision
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same file untouched still reads as unchanged.
|
||||||
|
let same = LocalItem {
|
||||||
|
mtime_ns: Some(10_000_000_000),
|
||||||
|
..local(10, 20)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&same), Some(&remote), Some(&base)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a row from before the column exists compares on seconds, exactly
|
||||||
|
// as it did when it was written — no mass re-upload on upgrade.
|
||||||
|
let old = baseline(10, 20, 30, 40);
|
||||||
|
assert_eq!(
|
||||||
|
plan_file(Some(&edited), Some(&remote), Some(&old)),
|
||||||
|
FilePlan::Unchanged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn file_plan_covers_every_presence_and_change_shape() {
|
fn file_plan_covers_every_presence_and_change_shape() {
|
||||||
let same_local = local(10, 20);
|
let same_local = local(10, 20);
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! The `sd_notify` half of the service contract, without linking libsystemd.
|
||||||
|
//!
|
||||||
|
//! The protocol is one datagram of `KEY=VALUE` lines to the socket in
|
||||||
|
//! `$NOTIFY_SOCKET`. That is little enough to write directly, and writing it
|
||||||
|
//! directly keeps the daemon buildable on a machine without systemd headers —
|
||||||
|
//! everything here degrades to "no socket, do nothing" off systemd.
|
||||||
|
|
||||||
|
use std::os::unix::net::UnixDatagram;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Send one notification. `false` when there is no manager to send it to, or the
|
||||||
|
/// send failed — a caller has nothing useful to do about either.
|
||||||
|
pub(crate) fn notify(message: &str) -> bool {
|
||||||
|
let Ok(socket_path) = std::env::var("NOTIFY_SOCKET") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if socket_path.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(socket) = UnixDatagram::unbound() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if let Some(name) = socket_path.strip_prefix('@') {
|
||||||
|
// Abstract namespace: the address is a NUL byte followed by the name,
|
||||||
|
// which `std`'s path-based API cannot express.
|
||||||
|
send_abstract(&socket, name, message)
|
||||||
|
} else {
|
||||||
|
socket.send_to(message.as_bytes(), &socket_path).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `sendto` on an abstract-namespace address, which needs a hand-built
|
||||||
|
/// `sockaddr_un` because the first byte of the name is NUL.
|
||||||
|
fn send_abstract(socket: &UnixDatagram, name: &str, message: &str) -> bool {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
|
||||||
|
let mut addr: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
||||||
|
addr.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
// One byte for the leading NUL, and the path must still be addressable.
|
||||||
|
if bytes.len() + 1 > addr.sun_path.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (slot, byte) in addr.sun_path.iter_mut().skip(1).zip(bytes) {
|
||||||
|
*slot = *byte as libc::c_char;
|
||||||
|
}
|
||||||
|
let len = std::mem::size_of::<libc::sa_family_t>() + 1 + bytes.len();
|
||||||
|
let sent = unsafe {
|
||||||
|
libc::sendto(
|
||||||
|
socket.as_raw_fd(),
|
||||||
|
message.as_ptr().cast(),
|
||||||
|
message.len(),
|
||||||
|
0,
|
||||||
|
std::ptr::from_ref(&addr).cast(),
|
||||||
|
len as libc::socklen_t,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
sent >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is up and serving. Called once the mount is live,
|
||||||
|
/// so `systemctl --user start` returns when the mount actually works rather than
|
||||||
|
/// when the process exists.
|
||||||
|
pub(crate) fn ready() {
|
||||||
|
if notify("READY=1\n") {
|
||||||
|
debug!("notified systemd: ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is still healthy.
|
||||||
|
pub(crate) fn watchdog_ping() {
|
||||||
|
let _ = notify("WATCHDOG=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the manager the daemon is stopping, so a slow teardown is not mistaken
|
||||||
|
/// for a hang by the watchdog it is racing.
|
||||||
|
pub(crate) fn stopping() {
|
||||||
|
let _ = notify("STOPPING=1\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often the manager expects a ping, from `$WATCHDOG_USEC`, or `None` when
|
||||||
|
/// the watchdog is off or belongs to another process.
|
||||||
|
///
|
||||||
|
/// Reports *half* the configured interval, the interval systemd documents for
|
||||||
|
/// pinging: a ping period equal to the deadline would restart the daemon on the
|
||||||
|
/// first late scheduling.
|
||||||
|
pub(crate) fn watchdog_period() -> Option<Duration> {
|
||||||
|
// `WATCHDOG_PID` is set when the manager wants a specific process to answer;
|
||||||
|
// in that case a ping from anyone else is not the one it is waiting for.
|
||||||
|
if let Ok(pid) = std::env::var("WATCHDOG_PID")
|
||||||
|
&& pid.parse::<u32>().ok() != Some(std::process::id())
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let usec: u64 = std::env::var("WATCHDOG_USEC").ok()?.parse().ok()?;
|
||||||
|
(usec > 0).then(|| Duration::from_micros(usec / 2))
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
//! Importing a Google Photos Takeout export into Proton Photos.
|
||||||
|
//!
|
||||||
|
//! [`pdfs_core::takeout`] says what is in the archives; this is the part that
|
||||||
|
//! talks to Proton. One import runs on its own thread and moves through four
|
||||||
|
//! phases, reporting into the transfer registry as a job so the GUI's transfer
|
||||||
|
//! list shows it without a bespoke widget:
|
||||||
|
//!
|
||||||
|
//! 1. **Scan** — read the archives' central directories and sidecars.
|
||||||
|
//! 2. **Deduplicate against the account** — ask the photos volume which of the
|
||||||
|
//! names already exist ([`ProtonPhotosClient::name_collisions`]), hash only
|
||||||
|
//! those files, then confirm with the content digest. A library that is
|
||||||
|
//! already half-synced by Proton Photos on a phone therefore costs one cheap
|
||||||
|
//! request per 150 names and reads no media at all for the misses.
|
||||||
|
//! 3. **Upload** the survivors, streaming each entry straight out of its zip —
|
||||||
|
//! no extraction to disk, so the import needs no scratch space.
|
||||||
|
//! 4. **Albums** — create the albums the export names that the account lacks,
|
||||||
|
//! then add every photo of each album, the ones that were already on the
|
||||||
|
//! account included.
|
||||||
|
//!
|
||||||
|
//! Re-running an interrupted import is the resume story: phase 2 finds
|
||||||
|
//! everything the previous run uploaded and skips it. Nothing is persisted
|
||||||
|
//! between runs.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use pdfs_core::control::{ActivityKind, ImportSummary, TransferDirection};
|
||||||
|
use pdfs_core::takeout::{Bucket, TakeoutPhoto, TakeoutScan, scan_archives};
|
||||||
|
use pdfs_core::{CoreError, CoreResult};
|
||||||
|
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||||
|
use proton_drive_rs::{PhotoTag, PhotoTagsUpdate, PhotoUploadMetadata};
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use super::Core;
|
||||||
|
use crate::transfers::CountingReader;
|
||||||
|
|
||||||
|
/// The one import this daemon may be running, and how the last one ended.
|
||||||
|
///
|
||||||
|
/// Process-global rather than a [`Core`] field because it must be *one* import
|
||||||
|
/// per daemon however many `Core` clones exist (the forked on-demand mounts each
|
||||||
|
/// hold one), and because a front-end asking for the result after the import
|
||||||
|
/// finished must still find it.
|
||||||
|
static IMPORT: std::sync::LazyLock<parking_lot::Mutex<ImportState>> =
|
||||||
|
std::sync::LazyLock::new(|| parking_lot::Mutex::new(ImportState::default()));
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ImportState {
|
||||||
|
running: bool,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
|
last: Option<ImportSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an import is running, and the last one's report.
|
||||||
|
pub(crate) fn import_status() -> (bool, Option<ImportSummary>) {
|
||||||
|
let state = IMPORT.lock();
|
||||||
|
(state.running, state.last.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the running import to stop. False when none is running.
|
||||||
|
pub(crate) fn cancel_import() -> bool {
|
||||||
|
match &IMPORT.lock().cancel {
|
||||||
|
Some(flag) => {
|
||||||
|
flag.store(true, Ordering::Relaxed);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One photo to import, after the copies Google keeps per album are folded into
|
||||||
|
/// a single upload that joins several albums.
|
||||||
|
struct Planned {
|
||||||
|
photo: TakeoutPhoto,
|
||||||
|
/// Every album this photo belongs to, by export title.
|
||||||
|
albums: Vec<String>,
|
||||||
|
/// Filled in phase 2/3: where the photo ended up on the account.
|
||||||
|
uid: Option<NodeUid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold the scan into one entry per distinct photo.
|
||||||
|
///
|
||||||
|
/// Google stores a full copy of a photo in every album folder it appears in, so
|
||||||
|
/// the same file is in the export two or three times over. They are folded on
|
||||||
|
/// `(name, size)`: a byte-identical copy under the same name is the same photo,
|
||||||
|
/// and the album memberships of the copies are unioned onto the survivor. Two
|
||||||
|
/// genuinely different photos sharing both a name and an exact size would be
|
||||||
|
/// merged — vanishingly unlikely, and the cost of being wrong is one photo
|
||||||
|
/// filed under an extra album rather than lost data.
|
||||||
|
fn plan(scan: &TakeoutScan) -> Vec<Planned> {
|
||||||
|
let mut order: Vec<(String, u64)> = Vec::new();
|
||||||
|
let mut by_key: HashMap<(String, u64), Planned> = HashMap::new();
|
||||||
|
|
||||||
|
for photo in &scan.photos {
|
||||||
|
let key = (photo.name.clone(), photo.size);
|
||||||
|
let entry = by_key.entry(key.clone()).or_insert_with(|| {
|
||||||
|
order.push(key.clone());
|
||||||
|
Planned {
|
||||||
|
photo: photo.clone(),
|
||||||
|
albums: Vec::new(),
|
||||||
|
uid: None,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Bucket::Album(title) = &photo.bucket
|
||||||
|
&& !entry.albums.iter().any(|existing| existing == title)
|
||||||
|
{
|
||||||
|
entry.albums.push(title.clone());
|
||||||
|
}
|
||||||
|
// A copy that carries a capture time wins over one that does not: the
|
||||||
|
// album copy and the year-folder copy do not always both have a sidecar.
|
||||||
|
if entry.photo.capture_time.is_none() && photo.capture_time.is_some() {
|
||||||
|
entry.photo.capture_time = photo.capture_time;
|
||||||
|
}
|
||||||
|
entry.photo.favorite |= photo.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
order
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| by_key.remove(&key))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one archive and stream `entry` through `sink`.
|
||||||
|
fn read_entry<R>(
|
||||||
|
archive: &mut zip::ZipArchive<std::io::BufReader<std::fs::File>>,
|
||||||
|
entry: &str,
|
||||||
|
sink: impl FnOnce(&mut (dyn Read + Send)) -> R,
|
||||||
|
) -> CoreResult<R> {
|
||||||
|
let mut file = archive
|
||||||
|
.by_name(entry)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{entry}: {e}")))?;
|
||||||
|
Ok(sink(&mut file))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Core {
|
||||||
|
/// Import Google Photos Takeout archives. Blocking; run on its own thread.
|
||||||
|
///
|
||||||
|
/// `cancel` stops the run at the next photo boundary — an upload already on
|
||||||
|
/// the wire is allowed to finish, so a cancelled import never leaves a
|
||||||
|
/// half-written revision behind.
|
||||||
|
pub(crate) fn import_takeout(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
) -> CoreResult<ImportSummary> {
|
||||||
|
let job = self.transfers.begin_job("Importing Google Photos");
|
||||||
|
job.detail("reading archives");
|
||||||
|
|
||||||
|
let scan = scan_archives(&archives)?;
|
||||||
|
let mut planned = plan(&scan);
|
||||||
|
let mut summary = ImportSummary {
|
||||||
|
found: planned.len(),
|
||||||
|
skipped_trashed: scan.skipped_trashed,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
job.set_total(planned.len() as u64);
|
||||||
|
info!(
|
||||||
|
archives = archives.len(),
|
||||||
|
photos = summary.found,
|
||||||
|
albums = scan.albums.len(),
|
||||||
|
trashed = summary.skipped_trashed,
|
||||||
|
"takeout scan complete"
|
||||||
|
);
|
||||||
|
|
||||||
|
let photos = self.photos();
|
||||||
|
if self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.get_photos_root())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "resolve photos library"))?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(CoreError::invalid(
|
||||||
|
"this account has no Proton Photos library yet; open Proton Photos once to create it, then import again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: which of these are already on the account.
|
||||||
|
job.detail("checking for photos you already have");
|
||||||
|
self.mark_existing(&mut planned, &archives, &mut summary, &cancel)?;
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
summary.duplicates = planned.iter().filter(|p| p.uid.is_some()).count();
|
||||||
|
return Ok(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: upload what is left, one archive at a time so each zip's
|
||||||
|
// central directory is parsed once.
|
||||||
|
self.upload_planned(&mut planned, &archives, &mut summary, &cancel, &job)?;
|
||||||
|
|
||||||
|
// Phase 4: albums. Attempted even after a cancel — the photos that did
|
||||||
|
// upload should still land in their albums.
|
||||||
|
if !scan.albums.is_empty() {
|
||||||
|
job.detail("sorting photos into albums");
|
||||||
|
if let Err(e) = self.link_albums(&planned, &scan.albums, &mut summary) {
|
||||||
|
warn!(error = %e, "takeout album linking failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_timeline_refresh();
|
||||||
|
summary.cancelled = cancel.load(Ordering::Relaxed);
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in `uid` for every planned photo the account already holds.
|
||||||
|
///
|
||||||
|
/// Two passes on purpose: the name-only query is one request per 150 photos
|
||||||
|
/// and needs no file contents, so the expensive SHA-1 pass only runs for the
|
||||||
|
/// handful of names that actually collide.
|
||||||
|
fn mark_existing(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let names: Vec<String> = planned.iter().map(|p| p.photo.name.clone()).collect();
|
||||||
|
let collisions = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.name_collisions(&names))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
// Hash the colliding files, grouped by archive so each zip opens once.
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, planned) in planned.iter().enumerate() {
|
||||||
|
if collisions.get(index).copied().unwrap_or(false) {
|
||||||
|
by_archive
|
||||||
|
.entry(planned.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept as a pair list, not a map: the duplicate lookup answers in input
|
||||||
|
// order, so the order these were built in is what maps answers back to
|
||||||
|
// planned photos.
|
||||||
|
let mut digests: Vec<(usize, String)> = Vec::new();
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
for index in indices {
|
||||||
|
let entry = planned[index].photo.entry.clone();
|
||||||
|
match read_entry(&mut archive, &entry, sha1_of) {
|
||||||
|
Ok(Ok(digest)) => digests.push((index, digest)),
|
||||||
|
Ok(Err(e)) | Err(e) => {
|
||||||
|
warn!(entry, error = %e, "cannot hash takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if digests.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<(String, String)> = digests
|
||||||
|
.iter()
|
||||||
|
.map(|(index, digest)| (planned[*index].photo.name.clone(), digest.clone()))
|
||||||
|
.collect();
|
||||||
|
let matches = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.find_duplicates_many(&items))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "check for existing photos"))?;
|
||||||
|
|
||||||
|
for (slot, (index, _)) in digests.iter().enumerate() {
|
||||||
|
if let Some(uid) = matches.get(slot).and_then(|uids| uids.first()) {
|
||||||
|
planned[*index].uid = Some(uid.clone());
|
||||||
|
summary.duplicates += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload every planned photo that is not already on the account.
|
||||||
|
fn upload_planned(
|
||||||
|
&self,
|
||||||
|
planned: &mut [Planned],
|
||||||
|
archives: &[PathBuf],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
job: &crate::transfers::JobGuard,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
let mut favorites: Vec<NodeUid> = Vec::new();
|
||||||
|
|
||||||
|
let mut by_archive: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
|
||||||
|
for (index, item) in planned.iter().enumerate() {
|
||||||
|
if item.uid.is_none() {
|
||||||
|
by_archive
|
||||||
|
.entry(item.photo.archive)
|
||||||
|
.or_default()
|
||||||
|
.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (archive_index, indices) in by_archive {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut archive = open_archive(archives, archive_index)?;
|
||||||
|
|
||||||
|
for index in indices {
|
||||||
|
if cancel.load(Ordering::Relaxed) || self.shutdown.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let photo = planned[index].photo.clone();
|
||||||
|
job.detail(photo.name.clone());
|
||||||
|
|
||||||
|
let guard = self.transfers.begin(
|
||||||
|
photo.name.clone(),
|
||||||
|
"",
|
||||||
|
TransferDirection::Upload,
|
||||||
|
photo.size,
|
||||||
|
);
|
||||||
|
let metadata = PhotoUploadMetadata {
|
||||||
|
capture_time: photo.capture_time,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let uploaded = read_entry(&mut archive, &photo.entry, |reader| {
|
||||||
|
let counting = CountingReader::new(reader, &guard);
|
||||||
|
self.rt.block_on(photos.upload_photo_from(
|
||||||
|
&photo.name,
|
||||||
|
&photo.media_type,
|
||||||
|
counting,
|
||||||
|
photo.size as i64,
|
||||||
|
Vec::new(),
|
||||||
|
metadata,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
drop(guard);
|
||||||
|
job.step();
|
||||||
|
|
||||||
|
match uploaded {
|
||||||
|
Ok(Ok(uid)) => {
|
||||||
|
summary.uploaded += 1;
|
||||||
|
summary.bytes += photo.size;
|
||||||
|
if photo.favorite {
|
||||||
|
favorites.push(uid.clone());
|
||||||
|
}
|
||||||
|
planned[index].uid = Some(uid);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "takeout photo upload failed");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
summary.failed += 1;
|
||||||
|
warn!(name = photo.name, error = %e, "cannot read takeout entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites are a separate call: the upload seal takes classification
|
||||||
|
// tags, but `Favorite` is not one of them — it goes through the photos
|
||||||
|
// volume's dedicated favorite endpoint.
|
||||||
|
if !favorites.is_empty() {
|
||||||
|
let updates: Vec<PhotoTagsUpdate> = favorites
|
||||||
|
.into_iter()
|
||||||
|
.map(|node_uid| PhotoTagsUpdate {
|
||||||
|
node_uid,
|
||||||
|
tags_to_add: vec![PhotoTag::Favorite],
|
||||||
|
tags_to_remove: Vec::new(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Err(e) = self.rt.block_on(photos.update_photos(&updates)) {
|
||||||
|
warn!(error = %e, "marking imported favorites failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the albums the export names that the account lacks, then add each
|
||||||
|
/// album's photos to it.
|
||||||
|
fn link_albums(
|
||||||
|
&self,
|
||||||
|
planned: &[Planned],
|
||||||
|
albums: &[String],
|
||||||
|
summary: &mut ImportSummary,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let photos = self.photos();
|
||||||
|
|
||||||
|
// Existing albums by decrypted name, so a second import of the same
|
||||||
|
// export adds to the album it made the first time instead of a twin.
|
||||||
|
let mut existing: HashMap<String, NodeUid> = HashMap::new();
|
||||||
|
let uids = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_album_node_uids())
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "list albums"))?;
|
||||||
|
if !uids.is_empty() {
|
||||||
|
let albums_nodes = self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.enumerate_nodes(&uids))
|
||||||
|
.map_err(|e| CoreError::from_api(&e, "read albums"))?;
|
||||||
|
for node in albums_nodes {
|
||||||
|
existing.entry(node.name.clone()).or_insert(node.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for title in albums {
|
||||||
|
let members: Vec<NodeUid> = planned
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.albums.iter().any(|album| album == title))
|
||||||
|
.filter_map(|item| item.uid.clone())
|
||||||
|
.collect();
|
||||||
|
if members.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_uid = match existing.get(title) {
|
||||||
|
Some(uid) => uid.clone(),
|
||||||
|
None => match self.rt.block_on(photos.create_album(title)) {
|
||||||
|
Ok(uid) => {
|
||||||
|
summary.albums_created += 1;
|
||||||
|
existing.insert(title.clone(), uid.clone());
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, error = %e, "cannot create album");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match self
|
||||||
|
.rt
|
||||||
|
.block_on(photos.add_photos_to_album(&album_uid, &members))
|
||||||
|
{
|
||||||
|
Ok(outcomes) => {
|
||||||
|
for (uid, outcome) in outcomes {
|
||||||
|
match outcome {
|
||||||
|
Ok(()) => summary.album_links += 1,
|
||||||
|
// A photo already in the album is the expected
|
||||||
|
// outcome of a re-run, not a failure worth counting.
|
||||||
|
Err(e) => {
|
||||||
|
warn!(album = title, %uid, error = %e, "cannot add photo to album")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => warn!(album = title, error = %e, "album addition failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run an import on a background thread, logging its outcome to the activity
|
||||||
|
/// feed. Returns immediately.
|
||||||
|
///
|
||||||
|
/// Refuses while one is already running: two imports of the same export
|
||||||
|
/// would race each other's duplicate detection and upload everything twice.
|
||||||
|
pub(crate) fn spawn_takeout_import(
|
||||||
|
&self,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> CoreResult<()> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
if state.running {
|
||||||
|
return Err(CoreError::conflict(
|
||||||
|
"an import is already running; wait for it to finish or cancel it",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
state.running = true;
|
||||||
|
state.cancel = Some(cancel.clone());
|
||||||
|
state.last = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let core = self.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let outcome = core.import_takeout(archives, dry_run, cancel);
|
||||||
|
{
|
||||||
|
let mut state = IMPORT.lock();
|
||||||
|
state.running = false;
|
||||||
|
state.cancel = None;
|
||||||
|
state.last = outcome.as_ref().ok().cloned();
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
Ok(summary) => {
|
||||||
|
let mut parts = vec![format!("{} uploaded", summary.uploaded)];
|
||||||
|
if summary.duplicates > 0 {
|
||||||
|
parts.push(format!("{} already there", summary.duplicates));
|
||||||
|
}
|
||||||
|
if summary.albums_created > 0 {
|
||||||
|
parts.push(format!("{} albums", summary.albums_created));
|
||||||
|
}
|
||||||
|
if summary.failed > 0 {
|
||||||
|
parts.push(format!("{} failed", summary.failed));
|
||||||
|
}
|
||||||
|
if summary.cancelled {
|
||||||
|
parts.push("cancelled".to_string());
|
||||||
|
}
|
||||||
|
core.log_activity(
|
||||||
|
ActivityKind::Upload,
|
||||||
|
"Google Photos import",
|
||||||
|
parts.join(" · "),
|
||||||
|
summary.failed == 0 && !summary.cancelled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "takeout import failed");
|
||||||
|
core.log_activity(ActivityKind::Upload, "Google Photos import", &e, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open one of the import's archives by index.
|
||||||
|
fn open_archive(
|
||||||
|
archives: &[PathBuf],
|
||||||
|
index: usize,
|
||||||
|
) -> CoreResult<zip::ZipArchive<std::io::BufReader<std::fs::File>>> {
|
||||||
|
let path = archives
|
||||||
|
.get(index)
|
||||||
|
.ok_or_else(|| CoreError::internal(format!("archive {index} is not in the import")))?;
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("cannot open {}: {e}", path.display())))?;
|
||||||
|
zip::ZipArchive::new(std::io::BufReader::new(file))
|
||||||
|
.map_err(|e| CoreError::invalid(format!("{} is not a readable zip: {e}", path.display())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lowercase-hex SHA-1 of everything `reader` yields — the digest Proton's
|
||||||
|
/// photo duplicate detection is keyed on.
|
||||||
|
fn sha1_of(reader: &mut (dyn Read + Send)) -> CoreResult<String> {
|
||||||
|
let mut hasher = Sha1::new();
|
||||||
|
let mut buffer = vec![0u8; 256 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|e| CoreError::invalid(format!("read failed: {e}")))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(hex_lower(&hasher.finalize()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for byte in bytes {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pdfs_core::takeout::TakeoutPhoto;
|
||||||
|
|
||||||
|
fn photo(name: &str, size: u64, bucket: Bucket) -> TakeoutPhoto {
|
||||||
|
TakeoutPhoto {
|
||||||
|
archive: 0,
|
||||||
|
entry: format!("Takeout/Google Photos/x/{name}"),
|
||||||
|
name: name.to_string(),
|
||||||
|
size,
|
||||||
|
media_type: "image/jpeg".to_string(),
|
||||||
|
capture_time: None,
|
||||||
|
favorite: false,
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_copies_of_one_photo_fold_into_a_single_upload() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into())),
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Album("Best of".into())),
|
||||||
|
photo("IMG_2.jpg", 200, Bucket::Timeline),
|
||||||
|
],
|
||||||
|
albums: vec!["Iceland".into(), "Best of".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let planned = plan(&scan);
|
||||||
|
assert_eq!(planned.len(), 2, "the three copies are one upload");
|
||||||
|
assert_eq!(planned[0].albums, vec!["Iceland", "Best of"]);
|
||||||
|
assert!(planned[1].albums.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_name_different_size_stays_two_photos() {
|
||||||
|
let scan = TakeoutScan {
|
||||||
|
photos: vec![
|
||||||
|
photo("IMG_1.jpg", 100, Bucket::Timeline),
|
||||||
|
photo("IMG_1.jpg", 101, Bucket::Album("Iceland".into())),
|
||||||
|
],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(plan(&scan).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_from_any_copy_survives_the_fold() {
|
||||||
|
// Only the album copy carries a sidecar; the fold must not lose it.
|
||||||
|
let mut bare = photo("IMG_1.jpg", 100, Bucket::Timeline);
|
||||||
|
bare.capture_time = None;
|
||||||
|
let mut rich = photo("IMG_1.jpg", 100, Bucket::Album("Iceland".into()));
|
||||||
|
rich.capture_time = Some(1_560_000_000);
|
||||||
|
rich.favorite = true;
|
||||||
|
|
||||||
|
let planned = plan(&TakeoutScan {
|
||||||
|
photos: vec![bare, rich],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(planned[0].photo.capture_time, Some(1_560_000_000));
|
||||||
|
assert!(planned[0].photo.favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha1_matches_the_known_digest_of_abc() {
|
||||||
|
let mut reader: &[u8] = b"abc";
|
||||||
|
assert_eq!(
|
||||||
|
sha1_of(&mut reader).unwrap(),
|
||||||
|
"a9993e364706816aba3e25717850c26c9cd0d89d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,18 @@
|
|||||||
//! always reflects exactly what is in flight — even if the work fails partway
|
//! always reflects exactly what is in flight — even if the work fails partway
|
||||||
//! and unwinds.
|
//! and unwinds.
|
||||||
//!
|
//!
|
||||||
|
//! The same per-block tick enforces the user's bandwidth limits: every byte a
|
||||||
|
//! transfer moves is charged to its direction's [`RateLimit`], and the thread
|
||||||
|
//! moving it sleeps off whatever the charge puts it over the cap.
|
||||||
|
//!
|
||||||
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
//! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus
|
||||||
|
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
use pdfs_core::control::{JobItem, TransferDirection, TransferItem};
|
||||||
|
|
||||||
@@ -52,6 +56,59 @@ pub struct TransferRegistry {
|
|||||||
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
inner: Mutex<HashMap<u64, Arc<Entry>>>,
|
||||||
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
jobs: Mutex<HashMap<u64, Arc<JobEntry>>>,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
|
upload_limit: RateLimit,
|
||||||
|
download_limit: RateLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far ahead of its cap a direction may run before it is made to wait, so a
|
||||||
|
/// small file still goes out at full speed.
|
||||||
|
const RATE_BURST: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// A bytes-per-second cap shared by every transfer in one direction; `0` is no
|
||||||
|
/// cap.
|
||||||
|
///
|
||||||
|
/// A virtual clock rather than a refilled bucket: each charge pushes
|
||||||
|
/// `next_free` forward by the time its bytes take at the cap, so concurrent
|
||||||
|
/// transfers split the cap between them instead of each getting all of it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RateLimit {
|
||||||
|
rate: AtomicU64,
|
||||||
|
next_free: Mutex<Option<Instant>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimit {
|
||||||
|
fn set(&self, rate: u64) {
|
||||||
|
self.rate.store(rate, Ordering::Relaxed);
|
||||||
|
// A new cap starts from now, not from the debt the old one ran up.
|
||||||
|
*self.next_free.lock() = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Charge `n` bytes moved at `now`, returning how long the mover must wait
|
||||||
|
/// to stay under the cap.
|
||||||
|
fn charge(&self, n: u64, now: Instant) -> Duration {
|
||||||
|
let rate = self.rate.load(Ordering::Relaxed);
|
||||||
|
if rate == 0 || n == 0 {
|
||||||
|
return Duration::ZERO;
|
||||||
|
}
|
||||||
|
let mut next_free = self.next_free.lock();
|
||||||
|
let start = next_free.filter(|t| *t > now).unwrap_or(now);
|
||||||
|
let done = start + Duration::from_secs_f64(n as f64 / rate as f64);
|
||||||
|
*next_free = Some(done);
|
||||||
|
done.saturating_duration_since(now + RATE_BURST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep off a rate-limit wait. The bulk uploader reads on tokio workers, so
|
||||||
|
/// the sleep is announced to the runtime there rather than silently holding a
|
||||||
|
/// worker other tasks — FUSE reads among them — may need.
|
||||||
|
fn throttle(wait: Duration) {
|
||||||
|
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||||
|
match Handle::try_current() {
|
||||||
|
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||||
|
tokio::task::block_in_place(|| std::thread::sleep(wait))
|
||||||
|
}
|
||||||
|
_ => std::thread::sleep(wait),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransferRegistry {
|
impl TransferRegistry {
|
||||||
@@ -59,6 +116,20 @@ impl TransferRegistry {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cap uploads and downloads at these bytes per second; `0` lifts a cap.
|
||||||
|
/// Applies to transfers already running from their next block.
|
||||||
|
pub fn set_limits(&self, upload: u64, download: u64) {
|
||||||
|
self.upload_limit.set(upload);
|
||||||
|
self.download_limit.set(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn limit(&self, direction: TransferDirection) -> &RateLimit {
|
||||||
|
match direction {
|
||||||
|
TransferDirection::Upload => &self.upload_limit,
|
||||||
|
TransferDirection::Download => &self.download_limit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
/// Register a new transfer of `total` bytes (`0` = unknown), returning a
|
||||||
/// guard that tracks progress and deregisters the transfer when dropped.
|
/// guard that tracks progress and deregisters the transfer when dropped.
|
||||||
pub fn begin(
|
pub fn begin(
|
||||||
@@ -169,9 +240,17 @@ pub struct TransferGuard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TransferGuard {
|
impl TransferGuard {
|
||||||
/// Record `n` more bytes moved.
|
/// Record `n` more bytes moved, then wait if they put the direction over
|
||||||
|
/// its cap.
|
||||||
pub fn add(&self, n: u64) {
|
pub fn add(&self, n: u64) {
|
||||||
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
self.entry.done.fetch_add(n, Ordering::Relaxed);
|
||||||
|
let wait = self
|
||||||
|
.reg
|
||||||
|
.limit(self.entry.direction)
|
||||||
|
.charge(n, Instant::now());
|
||||||
|
if !wait.is_zero() {
|
||||||
|
throttle(wait);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,22 +351,86 @@ impl<R: Read> Read for OwnedCountingReader<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path).
|
/// A [`Read`] that tallies bytes read through a [`TransferGuard`] (upload path),
|
||||||
|
/// and can be told to stop.
|
||||||
pub struct CountingReader<'a, R> {
|
pub struct CountingReader<'a, R> {
|
||||||
inner: R,
|
inner: R,
|
||||||
guard: &'a TransferGuard,
|
guard: &'a TransferGuard,
|
||||||
|
cancel: Option<Arc<AtomicBool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, R: Read> CountingReader<'a, R> {
|
impl<'a, R: Read> CountingReader<'a, R> {
|
||||||
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
pub fn new(inner: R, guard: &'a TransferGuard) -> Self {
|
||||||
Self { inner, guard }
|
Self {
|
||||||
|
inner,
|
||||||
|
guard,
|
||||||
|
cancel: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abandon the upload as soon as the SDK asks for its next bytes when
|
||||||
|
/// `cancel` is set.
|
||||||
|
///
|
||||||
|
/// This is the only cancellation point an upload has: the SDK takes a plain
|
||||||
|
/// blocking [`Read`] and pulls from it a block at a time, so refusing to
|
||||||
|
/// hand over the next block is what stops it. Used when a newer revision of
|
||||||
|
/// the same file is queued — the bytes on the wire have already been
|
||||||
|
/// superseded, and finishing them only spends uplink on a revision the next
|
||||||
|
/// op replaces.
|
||||||
|
pub fn with_cancel(mut self, cancel: Arc<AtomicBool>) -> Self {
|
||||||
|
self.cancel = Some(cancel);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R: Read> Read for CountingReader<'_, R> {
|
impl<R: Read> Read for CountingReader<'_, R> {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||||
|
if let Some(cancel) = &self.cancel
|
||||||
|
&& cancel.load(Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
// Not `Ok(0)`: a short read is how this reader says "end of file",
|
||||||
|
// and the SDK would seal a truncated revision from it.
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Interrupted,
|
||||||
|
"upload superseded by a newer write",
|
||||||
|
));
|
||||||
|
}
|
||||||
let n = self.inner.read(buf)?;
|
let n = self.inner.read(buf)?;
|
||||||
self.guard.add(n as u64);
|
self.guard.add(n as u64);
|
||||||
Ok(n)
|
Ok(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncapped_direction_never_waits() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
assert_eq!(limit.charge(u64::MAX / 2, Instant::now()), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_capped_direction_waits_once_past_its_burst() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
// One second of bytes is the burst: no wait yet.
|
||||||
|
assert_eq!(limit.charge(1000, now), Duration::ZERO);
|
||||||
|
// Two more seconds' worth at the same instant: wait out all but the burst.
|
||||||
|
assert_eq!(limit.charge(2000, now), Duration::from_secs(2));
|
||||||
|
// Lifting the cap drops the debt with it.
|
||||||
|
limit.set(0);
|
||||||
|
assert_eq!(limit.charge(5000, now), Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_transfers_share_one_cap() {
|
||||||
|
let limit = RateLimit::default();
|
||||||
|
limit.set(1000);
|
||||||
|
let now = Instant::now();
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_millis(500));
|
||||||
|
assert_eq!(limit.charge(1500, now), Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ impl Core {
|
|||||||
stats.bytes += size;
|
stats.bytes += size;
|
||||||
match self.fetch_node(&uid) {
|
match self.fetch_node(&uid) {
|
||||||
Ok(node) => {
|
Ok(node) => {
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(parent_ino, node);
|
let ino = st.intern(parent_ino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&parent_ino)
|
if let Some(kids) = st.children.get_mut(&parent_ino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
@@ -216,7 +216,7 @@ impl Core {
|
|||||||
self.ensure_children(pino)
|
self.ensure_children(pino)
|
||||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||||
let existing = {
|
let existing = {
|
||||||
let st = self.state.lock();
|
let st = self.state();
|
||||||
st.children.get(&pino).and_then(|kids| {
|
st.children.get(&pino).and_then(|kids| {
|
||||||
kids.iter().find_map(|ino| {
|
kids.iter().find_map(|ino| {
|
||||||
st.entries
|
st.entries
|
||||||
@@ -243,7 +243,7 @@ impl Core {
|
|||||||
let node = self
|
let node = self
|
||||||
.fetch_node(&new_uid)
|
.fetch_node(&new_uid)
|
||||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||||
let mut st = self.state.lock();
|
let mut st = self.state();
|
||||||
let ino = st.intern(pino, node);
|
let ino = st.intern(pino, node);
|
||||||
if let Some(kids) = st.children.get_mut(&pino)
|
if let Some(kids) = st.children.get_mut(&pino)
|
||||||
&& !kids.contains(&ino)
|
&& !kids.contains(&ino)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ pub(crate) fn virtual_node(parent_uid: NodeUid, name: String, timestamp: i64) ->
|
|||||||
photo: None,
|
photo: None,
|
||||||
album: None,
|
album: None,
|
||||||
verification: Default::default(),
|
verification: Default::default(),
|
||||||
|
direct_role: None,
|
||||||
|
share_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user